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 +2 -1
- package/mcp/openai-extensions.mjs +4 -3
- package/mcp/spend-guard.mjs +21 -3
- package/mcp/tools.mjs +37 -22
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1280
|
-
//
|
|
1281
|
-
|
|
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; };
|
package/mcp/spend-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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 `
|
|
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: '
|
|
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
|
-
|
|
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
|
|
3759
|
-
//
|
|
3760
|
-
//
|
|
3761
|
-
|
|
3762
|
-
export const
|
|
3763
|
-
|
|
3764
|
-
const
|
|
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
|
|
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:
|
|
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":
|
|
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":
|
|
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:
|
|
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
|
|
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.
|
|
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",
|