hermoso 0.1.536 → 0.1.539

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/client.mjs CHANGED
@@ -7,6 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import { jobQuoteLegs } from './spend-guard.mjs'; // a quoted job as named legs (get_cost)
10
11
 
11
12
  // Remote-connector identity: mcp/http.mjs wraps each request in mcpCtx.run({ token }) so every /api call a tool
12
13
  // makes carries THAT caller's bearer (bills their account). stdio keeps using the env token — ctx is simply unset.
@@ -575,7 +576,7 @@ export async function submitJob(type, input, { label = '' } = {}) {
575
576
  if (st?.quoteOnly) {
576
577
  const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
577
578
  const q = d?.quote || null;
578
- (st.quoteLegs ||= []).push({ credits: q && Number(q.credits) > 0 ? Number(q.credits) : null, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: q && Number(q.credits) > 0 ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}) });
579
+ for (const leg of jobQuoteLegs(q, { label, type })) (st.quoteLegs ||= []).push(leg); // the job's named parts when the server itemised it (a storyboard's paints, a portrait, the clip), else the job as one leg
579
580
  throw quoteStopError();
580
581
  }
581
582
  // A TOOL CALL WITH AN idempotency_key: each job it queues carries a key derived from it (one per submit in the call), so a
@@ -1276,9 +1276,10 @@ export const neutralNote = (t) => {
1276
1276
  // FAILED read is reported as a note and never as an empty list ([[failed-read-is-not-empty]]).
1277
1277
  // OUR OWN HOUSEKEEPING IS NOT A RENDER. /api/jobs lists every job on the account, including idle nudges, retention
1278
1278
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1279
- // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1280
- // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1281
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1279
+ // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1 by each type's PUBLIC
1280
+ // name (the one /api/jobs returns, lib/job-types.mjs publicJobType), and the check fails if that list gains a type this
1281
+ // one lacks. A job queued for LATER is not in progress either.
1282
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'latelanding', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'modelwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1282
1283
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1283
1284
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1284
1285
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -108,17 +108,35 @@ export function quoteFromLegs(legs) {
108
108
  const sum = L.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
109
109
  const hold = L.reduce((t, l) => t + (Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
110
110
  if (!L.length) return { credits: 0, legs: L, free: true };
111
- return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: unpriced.map((l) => l.label || 'a step') } : {}) };
111
+ const also = [...new Set(L.flatMap((l) => (Array.isArray(l.alsoBilled) ? l.alsoBilled : [])))];
112
+ return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: [...new Set(unpriced.map((l) => l.label || 'a step'))] } : {}), ...(also.length ? { alsoBilled: also } : {}) };
113
+ }
114
+ /**
115
+ * The legs of ONE job's dry-run quote (2026-11-13). When the server itemised the job (`parts`: a storyboard's image paints,
116
+ * a cast portrait, an opening frame, the clip itself), each part is its own named leg; otherwise the job is one leg, as
117
+ * before. The parts are used only when they add up to the job's own figure, so the total is always the dry run's.
118
+ * `alsoBilled` names the small reads the job bills on top at their actual cost (the storyboard check, the product photo
119
+ * read): said in the answer, never a number that is not known.
120
+ */
121
+ export function jobQuoteLegs(q, { label = '', type = '' } = {}) {
122
+ const credits = q && Number(q.credits) > 0 ? Number(q.credits) : null;
123
+ const also = q && Array.isArray(q.alsoBilled) ? q.alsoBilled.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 6) : [];
124
+ const one = { credits, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: credits != null ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}), ...(also.length ? { alsoBilled: also } : {}) };
125
+ const parts = credits != null && !(Number(q.priceCredits) > 0) && Array.isArray(q.parts) ? q.parts.filter((x) => x && Number(x.credits) > 0 && String(x.label || '').trim()) : [];
126
+ if (!parts.length || Math.abs(parts.reduce((t, x) => t + Number(x.credits), 0) - credits) > 0.05) return [one];
127
+ return parts.map((x, i) => ({ credits: Number(x.credits), basis: 'hold', label: String(x.label).trim().slice(0, 90), ...(i === parts.length - 1 && also.length ? { alsoBilled: also } : {}) }));
112
128
  }
113
129
  const legLine = (l) => `${l.label || 'a step'}: ${Number(l.priceCredits) > 0 ? `${l.priceCredits}` : Number(l.credits) > 0 ? `${l.credits}` : 'its actual cost'}`;
130
+ const alsoLine = (q) => (Array.isArray(q.alsoBilled) && q.alsoBilled.length ? ` Billed on top at their actual cost, a small read each: ${q.alsoBilled.join(', ')}.` : '');
114
131
  export function quoteText(tool, q) {
115
132
  if (q.refused) return `NO PRICE (nothing ran, nothing was charged): ${tool} would not start: ${q.refused}`;
116
133
  if (q.free) return `PRICE (nothing ran, nothing was charged): ${tool} with these arguments spends no credits.`;
117
134
  if (q.credits == null) {
118
135
  const known = q.legs.filter((l) => Number(l.credits) > 0);
119
- return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${known.map(legLine).join(', ')} credits.` : ''} expected_credits cannot bind this call; run it without it.`;
136
+ const knownSum = round1(known.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits)), 0));
137
+ return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${knownSum} credits: ${known.map(legLine).join('; ')}.` : ''}${alsoLine(q)} expected_credits cannot bind this call; run it without it.`;
120
138
  }
121
- return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
139
+ return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''}${alsoLine(q)} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
122
140
  }
123
141
  export function expectedMismatchText(tool, expected, q) {
124
142
  if (q.credits == null) return `Refused, nothing ran and nothing was charged: expected_credits was ${expected}, but ${tool} has no fixed price here (${q.refused || `billed at the actual cost of ${(q.unpriced || []).join(' and ') || 'its model steps'}`}). Run it without expected_credits, or ask the user first.`;
package/mcp/tools.mjs CHANGED
@@ -435,7 +435,7 @@ export const MCP_INSTRUCTIONS_WIDGET = [
435
435
  "Hermoso runs a brand's marketing: research competitors' ads, make image and video ads, post to their accounts, run paid campaigns, read results. Each area works on its own.",
436
436
  'Asked to make something, make it; render tools pick a sound default model (hermoso_capabilities lists models and costs).',
437
437
  '• RESEARCH: find_competitors, pull_competitor_ads, research_ads, competitor_teardown, mine_angles.',
438
- '• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, remake_video_for_brand; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
438
+ '• CREATE: get_brand / draft_brand, plan_ad, render_ad, generate_image, generate_video, make_template_ad, clone_static, plan_video_remake; variants: edit_image, headline_variants, hook_variants, multiply_ad. Renders run in background; poll get_job.',
439
439
  "• FILES & SCHEDULING: upload_file turns a file into a URL; schedule_post queues a post; list_scheduled shows the queue; list_connectors shows what is connected; list_library lists past creations.",
440
440
  '• PUBLISH: post_to_* (post_to_meta, post_to_tiktok...) post now; list_inbox and reply_to_inbox_item answer comments and messages.',
441
441
  '• PAID ADS (Meta, Google, LinkedIn, TikTok, Reddit, Snapchat, ChatGPT): create_* builds campaigns paused; set_*_status and set_*_budget control spend.',
@@ -3059,7 +3059,9 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
3059
3059
  // After publication OpenAI rescans daily, so a tool ChatGPT should have is added to its list by name instead.
3060
3060
  // 2. "Tool names should be … specific, and descriptive of what the tool actually does." The scan found `clone_video`
3061
3061
  // unclear. Renaming it everywhere would break every agent that calls it, so a widget host lists the SAME tool
3062
- // (same definition, same handler) as `remake_video_for_brand`, and `clone_video` stays the name everywhere else.
3062
+ // (same definition, same handler) as `plan_video_remake`, and `clone_video` stays the name everywhere else.
3063
+ // (It was `remake_video_for_brand` until the 2026-11-14 scan read that name as promising a finished video, which
3064
+ // the tool does not make: it plans one. The old name stays registered, unlisted and callable: WIDGET_HOST_RETIRED_NAMES.)
3063
3065
  // A direct call to `clone_video` from a widget host still answers (an older scan holds that name).
3064
3066
  // 3. ANNOTATIONS FOLLOW THE DOC'S DEFINITIONS (developers.openai.com/plugins/deploy/app-review.md). openWorldHint is TRUE
3065
3067
  // on every generation and post-production tool: each sends the caller's prompt or media to an independently
@@ -3069,9 +3071,13 @@ const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIE
3069
3071
  // caller's own credits is metering, not a change to the environment the tool acts on, so a research read that
3070
3072
  // bills stays read-only. destructiveHint is TRUE on draft_brand: save:true replaces the saved brand profile.
3071
3073
  export const EXECUTOR_TOOLS = new Set(['find_tools', 'call_tool', 'enable_tools']);
3072
- export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'remake_video_for_brand', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object', make_from_preset: 'plan_video_from_preset', make_short: 'plan_short_video' }); // make_from_preset + make_short (2026-11-09 scan, "name appears promotional, exaggerated, or inconsistent with its behavior"): both PLAN and render nothing, render_ad makes the video // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
3074
+ export const WIDGET_HOST_RENAMES = Object.freeze({ clone_video: 'plan_video_remake', click_to_ad: 'plan_video_ad_from_product_link', delete_openai_ads_object: 'archive_openai_ads_object', make_from_preset: 'plan_video_from_preset', make_short: 'plan_short_video' }); // make_from_preset + make_short (2026-11-09 scan, "name appears promotional, exaggerated, or inconsistent with its behavior"): both PLAN and render nothing, render_ad makes the video // delete_openai_ads_object (2026-11-03 scan, "name … inconsistent with its behavior"): OpenAI has no delete, the tool ARCHIVES // click_to_ad: the scan (2026-10-27) read the name as promotional / not what it does — it PLANS the ad, render_ad makes it
3073
3075
  export const WIDGET_HOST_ALIAS_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_RENAMES).map(([from, to]) => [to, from])));
3074
- export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ remake_video_for_brand: 'Remake a video for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad', plan_video_from_preset: 'Plan a video from a saved preset', plan_short_video: 'Plan a short video from a format' });
3076
+ // A WIDGET-HOST NAME THAT WAS REPLACED, and the canonical tool it still runs. ChatGPT keeps the roster it last published
3077
+ // until a scan publishes the new one, so the old name stays registered on every widget host, never listed, and a call to
3078
+ // it runs the same handler.
3079
+ export const WIDGET_HOST_RETIRED_NAMES = Object.freeze({ remake_video_for_brand: 'clone_video' });
3080
+ export const WIDGET_HOST_ALIAS_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand', plan_video_ad_from_product_link: 'Plan a video ad from a product link', archive_openai_ads_object: 'Archive a ChatGPT Ads campaign, ad group or ad', plan_video_from_preset: 'Plan a video from a saved preset', plan_short_video: 'Plan a short video from a format' });
3075
3081
  // ── ONE OPERATION, ONE TOOL, ON A WIDGET HOST (2026-10-18) ─────────────────────────────────────────────────────
3076
3082
  // OpenAI's daily scan HELD 18 tools that take an `action` enum and do different things per value: "This tool appears
3077
3083
  // to execute operations that are not individually exposed. Please expose each model-callable operation upfront as a
@@ -3319,7 +3325,7 @@ export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(W
3319
3325
  // Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
3320
3326
  // `app` (optional) is the focused app of the session: a tool that app offers (WIDGET_APP_OFFERS) is not hidden there.
3321
3327
  export const widgetHostHides = (name, widgetHost, app = null) => (widgetHost
3322
- ? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
3328
+ ? ((WITHHELD_FROM_WIDGET_HOSTS.has(name) && !widgetAppOffers(name, app)) || EXECUTOR_TOOLS.has(name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RENAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_RETIRED_NAMES, name) || Object.prototype.hasOwnProperty.call(WIDGET_HOST_SPLITS, name))
3323
3329
  : (Object.prototype.hasOwnProperty.call(WIDGET_HOST_ALIAS_OF, name) || Object.prototype.hasOwnProperty.call(WIDGET_SPLIT_OF, name)));
3324
3330
  // HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
3325
3331
  // self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
@@ -3755,16 +3761,13 @@ const APP_ADS_ALSO = /_post_ad_authorization$|^tiktok_spark_recommendations$/;
3755
3761
  // Manager, and the read-only post-performance tools written in `create` stay reachable in Social (they generate
3756
3762
  // nothing; DIRECTORY_BANNED_GROUPS would otherwise hold them by group alone).
3757
3763
  export const APP_DIRECTORY_CREATE_ALLOW = new Set([...APP_SOCIAL_FROM_CREATE].filter((n) => n !== 'plan_week').concat(['list_library', 'fetch_asset', 'get_job', 'list_skills', 'get_skill']));
3758
- // ON THE CHATGPT APPS ONE WIDGET-HOST NAME IS RENAMED AGAIN (2026-11-06 scan: remake_video_for_brand "appears promotional,
3759
- // exaggerated, or inconsistent with its behavior"). It PLANS a remake and renders nothing, so on a ChatGPT app it is
3760
- // listed as plan_video_remake with a factual description; the unscoped ChatGPT roster and every other host keep their
3761
- // names (click_to_ad -> plan_video_ad_from_product_link is the precedent).
3762
- export const APP_WIDGET_RENAMES = Object.freeze({ remake_video_for_brand: 'plan_video_remake' });
3763
- export const APP_WIDGET_ALIAS_OF = Object.freeze({ plan_video_remake: 'clone_video' });
3764
- const APP_WIDGET_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand' });
3765
- const APP_WIDGET_DESCRIPTIONS = Object.freeze({
3766
- plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
3767
- });
3764
+ // ON THE CHATGPT APPS A WIDGET-HOST NAME CAN BE RENAMED AGAIN (APP_WIDGET_RENAMES; empty today). The one it held,
3765
+ // remake_video_for_brand -> plan_video_remake (2026-11-06 scan), became the widget-host name on every ChatGPT roster after
3766
+ // the 2026-11-14 scan flagged the all-in-one app's name too (WIDGET_HOST_RENAMES, WIDGET_HOST_DESCRIPTIONS).
3767
+ export const APP_WIDGET_RENAMES = Object.freeze({});
3768
+ export const APP_WIDGET_ALIAS_OF = Object.freeze({});
3769
+ const APP_WIDGET_TITLES = Object.freeze({});
3770
+ const APP_WIDGET_DESCRIPTIONS = Object.freeze({});
3768
3771
  // OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
3769
3772
  // ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
3770
3773
  // products is dropped, and the three bulk tools that take an operations list ("operations that are not individually
@@ -3806,7 +3809,7 @@ export const APP_DESC_LEADS = Object.freeze({
3806
3809
  });
3807
3810
  export const APP_WIDGET_UNLISTED = new Set(['microsoft_ads_bulk_download', 'microsoft_ads_bulk_upload', 'pinterest_ads_bulk']);
3808
3811
  const appWidgetUnlisted = (name) => Object.prototype.hasOwnProperty.call(APP_WIDGET_RENAMES, name) || APP_WIDGET_UNLISTED.has(name);
3809
- const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_SPLIT_OF[name] || name;
3812
+ const appBaseName = (name) => APP_WIDGET_ALIAS_OF[name] || WIDGET_HOST_ALIAS_OF[name] || WIDGET_HOST_RETIRED_NAMES[name] || WIDGET_SPLIT_OF[name] || name;
3810
3813
  export function appScopeHas(app, name, group, directory = false) {
3811
3814
  if (!app) return true;
3812
3815
  const n = appBaseName(name);
@@ -3948,6 +3951,9 @@ export function stripOutputSchema(def) {
3948
3951
  // unclear. Every other host keeps the longer text, which carries real operating detail; ChatGPT gets the same behaviour
3949
3952
  // described plainly. Each override is a complete description: what the tool does, its inputs, its cost.
3950
3953
  export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
3954
+ // 2026-11-14 scan (all-in-one app): "name appears promotional, exaggerated, or inconsistent with its behavior". It plans and
3955
+ // renders nothing, so the name and the first words both say so.
3956
+ plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 120s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
3951
3957
  search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
3952
3958
  list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
3953
3959
  edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point before the edit. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
@@ -4085,7 +4091,7 @@ const widgetProxyHit = (d) => WIDGET_PROXY_REFS.some(([re]) => { re.lastIndex =
4085
4091
  // definition goes back through OpenAI's scan review: remake_video_for_brand's live text names make_short, and renaming
4086
4092
  // that one word to plan_short_video put it back in review, where it was held ("name appears promotional"). Its text
4087
4093
  // stays byte-identical to the published one; the make_short handler still answers that name.
4088
- export const WIDGET_RENAME_KEEP = Object.freeze({ remake_video_for_brand: Object.freeze(['make_short']) });
4094
+ export const WIDGET_RENAME_KEEP = Object.freeze({}); // remake_video_for_brand's entry went with its name (2026-11-14): plan_video_remake carries its own WIDGET_HOST_DESCRIPTIONS text
4089
4095
  function renamedForWidgetHost(def, name) {
4090
4096
  const keep = (name && WIDGET_RENAME_KEEP[name]) || null;
4091
4097
  const d = def && typeof def.description === 'string' ? def.description : '';
@@ -4610,6 +4616,15 @@ function registerWidgetHostAliases(rawServer, ctx, opts) {
4610
4616
  ctx.handleOf[to] = h; ctx.groupOf[to] = ctx.groupOf[from]; ctx.srcOf[to] = { def, handler: src.handler };
4611
4617
  applyToolGates(h, to, ctx.groupOf[to], ctx, opts);
4612
4618
  }
4619
+ for (const [old, from] of Object.entries(WIDGET_HOST_RETIRED_NAMES)) {
4620
+ const src = ctx.srcOf[from];
4621
+ if (!src || !src.def || typeof src.handler !== 'function' || ctx.handleOf[old]) continue;
4622
+ const to = WIDGET_HOST_RENAMES[from]; const title = (to && WIDGET_HOST_ALIAS_TITLES[to]) || src.def.title;
4623
+ const def = { ...src.def, title, ...(src.def.annotations ? { annotations: { ...src.def.annotations, title } } : {}) };
4624
+ const h = rawServer.registerTool(old, defForApp(defForHost(old, def, true), ctx.app, ctx.directory, old, true), src.handler);
4625
+ ctx.handleOf[old] = h; ctx.groupOf[old] = ctx.groupOf[from]; ctx.srcOf[old] = { def, handler: src.handler };
4626
+ applyToolGates(h, old, ctx.groupOf[old], ctx, opts); // widgetHostHides keeps it unlisted; a direct call runs it
4627
+ }
4613
4628
  if (!ctx.app) return;
4614
4629
  // A ChatGPT app renames once more (APP_WIDGET_RENAMES); the name it replaces stays registered and callable but unlisted.
4615
4630
  for (const [alias, to] of Object.entries(APP_WIDGET_RENAMES)) {
@@ -6203,7 +6218,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6203
6218
  text: z.string().describe('The reply, exactly as it should appear publicly.'),
6204
6219
  },
6205
6220
  outputSchema: { ok: z.boolean(), source: z.string().optional(), id: z.string().optional(), note: z.string() },
6206
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
6221
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
6207
6222
  }, wrap(async ({ id, text }) => {
6208
6223
  const body = String(text || '').trim();
6209
6224
  if (!body) return { content: [{ type: 'text', text: 'Nothing to post — pass the reply text.' }], isError: true };
@@ -21513,7 +21528,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
21513
21528
  maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
21514
21529
  confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
21515
21530
  },
21516
- annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true},
21531
+ annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": true},
21517
21532
  }, wrap(async (a) => {
21518
21533
  const d = await apiPost('/api/meta/rules/limits', a);
21519
21534
  return ok(adRuleText(d), d);
@@ -21651,7 +21666,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
21651
21666
  maxBudget: z.number().nullable().optional().describe("no rule may raise a budget above this, account currency; null = no cap"),
21652
21667
  confirm: z.boolean().optional().describe("required when this LOOSENS a cap (tightening never needs it)"),
21653
21668
  },
21654
- annotations: {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
21669
+ annotations: {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
21655
21670
  }, wrap(async (a) => {
21656
21671
  const d = await apiPost('/api/google-ads/rules/limits', a);
21657
21672
  return ok(adRuleText(d), d);
@@ -25667,7 +25682,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
25667
25682
  creative: z.object({}).passthrough().optional().describe('an edited screenplay (the creative this tool returned, changed) to use instead'),
25668
25683
  lengthSeconds: z.number().optional().describe('re-outline at this length (throws away the screenplay and boards)'),
25669
25684
  },
25670
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
25685
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
25671
25686
  }, wrap(async (a) => {
25672
25687
  const id = encodeURIComponent(a.runId);
25673
25688
  let d = (a.creative || +a.lengthSeconds > 0) ? await apiPost(`/api/story/runs/${id}/edit`, { ...(a.creative ? { creative: a.creative } : {}), ...(+a.lengthSeconds > 0 ? { lengthSeconds: +a.lengthSeconds } : {}) }) : await apiGet(`/api/story/runs/${id}`);
@@ -25818,7 +25833,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
25818
25833
  loop: z.boolean().optional().describe('true = a seamless loop whose last frame flows back into its first. Only models with loop true in hermoso_capabilities; needs refImage and cannot be combined with endImage.'),
25819
25834
  shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order; the seconds add up to a length the model renders. Replaces `prompt`. Only models with multiShot true in hermoso_capabilities; a shot prompt over that model’s shotPromptMaxChars (Kling 3.0: 512) is refused free, never cut.'),
25820
25835
  extend: z.boolean().optional().describe('true = EXTEND refVideo by durationSeconds (default 5) with what your prompt says happens next, as ONE clip. By default it CUTS to a NEW SHOT: new angle, same scene, room sound carried, no music at the cut. A named model (any i2v) renders it.'),
25821
- extendMode: z.enum(['cut', 'continue']).optional().describe("with extend: 'continue' = the SAME take past its last frame, only when asked seamless (models with extend in hermoso_capabilities)."),
25836
+ extendMode: z.enum(['cut', 'continue']).optional().describe("with extend: 'continue' = the SAME take past its last frame when asked (continue it, keep going; models in hermoso_capabilities)."),
25822
25837
  interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
25823
25838
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds: one of the CHOSEN model’s listed durations in hermoso_capabilities (they differ per model, 4–8s on the short ones up to 30s). One continuous generation, so a longer ask than the model’s longest clip is REFUSED, nothing charged (never quietly truncated). Past ~15s NAME a long-clip model in `model`. For a longer spot use plan_ad with durationSeconds then render_ad, which stitches acts (on a 15s model, 40s = 15+15+10).'),
25824
25839
  aspectRatio: z.string().optional().describe("default '9:16'"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.536",
3
+ "version": "0.1.539",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1233 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. 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, WordPress, Discord, Slack, Whop 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",