hermoso 0.1.525 → 0.1.526

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 +70 -26
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -3411,7 +3411,7 @@ export const APP_WIDGET_RENAMES = Object.freeze({ remake_video_for_brand: 'plan_
3411
3411
  export const APP_WIDGET_ALIAS_OF = Object.freeze({ plan_video_remake: 'clone_video' });
3412
3412
  const APP_WIDGET_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand' });
3413
3413
  const APP_WIDGET_DESCRIPTIONS = Object.freeze({
3414
- 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, YouTube Short or video, or a direct video file URL. 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 60s) 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.",
3414
+ 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 60s) 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.",
3415
3415
  });
3416
3416
  // OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
3417
3417
  // ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
@@ -3595,7 +3595,7 @@ export function stripOutputSchema(def) {
3595
3595
  export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
3596
3596
  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.",
3597
3597
  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.",
3598
- 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, X or YouTube post link; 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 and to check the result. 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.",
3598
+ 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 and to check the result. 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.",
3599
3599
  list_openai_ads_feeds: "The product feeds on this ChatGPT Ads account: id, name, countries, currencies and the campaigns using each. A product-feed campaign advertises the products in one of these feeds. Read-only, 0 credits. Needs ChatGPT Ads connected.",
3600
3600
  create_openai_ads_feed: "Create an empty feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). By default it is a product feed, and products are added afterwards with update_openai_ads_feed_products. inventoryType 'hotel' asks for a hotel property feed instead, which OpenAI accepts only for advertisers in its hotel beta and refuses for everyone else. Needs ChatGPT Ads connected.",
3601
3601
  update_reddit_ads_ad_group: "Edit an existing Reddit ad group: its name, budget, goal type, bid, schedule dates or targeting. Budget and bid are amounts in the ad account's currency. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. It does not start or pause delivery (set_reddit_ads_status does). The saved ad group is read back from Reddit. Needs Reddit Ads connected.",
@@ -7268,8 +7268,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
7268
7268
  description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
7269
7269
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
7270
7270
  thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
7271
- paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure)'),
7272
- brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle)'),
7271
+ paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure). Carried to the upload when the post fires.'),
7272
+ brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle). Carried to the upload when the post fires.'),
7273
+ // THE REST OF A YOUTUBE UPLOAD (2026-11-07) — everything post_to_youtube takes now rides the queue, so a video can be scheduled through
7274
+ // OUR scheduler instead of uploaded early with YouTube's publishAt.
7275
+ categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id (28 Science & Technology, 27 Education, 24 Entertainment, 22 People & Blogs…; list_youtube_categories names them). Checked against YouTube’s assignable list when you schedule. Default 22.'),
7276
+ madeForKids: z.boolean().optional().describe('YOUTUBE: declare the video MADE FOR KIDS (COPPA). Default false.'),
7277
+ notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live. Omit to follow the privacy (public notifies, unlisted/private do not); true announces anyway, false stays quiet even when public.'),
7278
+ captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: a subtitle track (SRT or WebVTT) uploaded right after the video goes up. Pass url (a Hermoso-hosted file — upload_file returns one) OR text (the file body), plus language (BCP-47, required). If the caption upload fails the video is still posted and the result row carries the exact manage_youtube_caption call to retry just the track.'),
7273
7279
  // THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
7274
7280
  // connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
7275
7281
  // they worked when you posted now and vanished when you scheduled.
@@ -7494,6 +7500,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
7494
7500
  thumbnailUrl: z.string().optional().describe('YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.'),
7495
7501
  paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure) — false removes it'),
7496
7502
  brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it'),
7503
+ categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id; "" goes back to the default (22).'),
7504
+ madeForKids: z.boolean().optional().describe('YOUTUBE: made-for-kids declaration.'),
7505
+ notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live.'),
7506
+ captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: replace the queued caption track (url OR text, plus language).'),
7497
7507
  // The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
7498
7508
  // and reschedule_post is how an agent corrects a queued post it got wrong.
7499
7509
  replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
@@ -8653,6 +8663,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8653
8663
  tags: z.array(z.string()).optional().describe('up to 30 tags'),
8654
8664
  thumbnailUrl: z.string().optional().describe('the custom thumbnail: a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel.'),
8655
8665
  privacy: z.enum(['private', 'unlisted', 'public']).optional().describe('default public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); private = eyes-only (cannot run as an ad)'),
8666
+ madeForKids: z.boolean().optional().describe('declare the video MADE FOR KIDS (COPPA) — default false; YouTube then turns off comments and personalised ads'),
8656
8667
  categoryId: z.string().optional().describe('YouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.'),
8657
8668
  publishAt: z.string().optional().describe('SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.'),
8658
8669
  notifySubscribers: z.boolean().optional().describe('THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel\'s subscribers — that is YouTube\'s own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody\'s whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.'),
@@ -9004,7 +9015,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9004
9015
  server.registerTool('manage_youtube_caption', {
9005
9016
  title: 'Upload, replace or delete a caption track',
9006
9017
  description: 'Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.',
9007
- inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload.'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
9018
+ inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload unless you pass url.'), url: z.string().optional().describe('upload only: a Hermoso-hosted caption file (upload_file returns one) instead of pasting the body as text'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
9008
9019
  outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), videoId: z.string().nullable().optional(), captionId: z.string().optional(), language: z.string().optional(), name: z.string().optional(), status: z.string().optional(), isDraft: z.boolean().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
9009
9020
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
9010
9021
  }, wrap(async (a) => { const d = await apiPost('/api/youtube/caption', a); return ok(d.note, d); }));
@@ -22900,7 +22911,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22900
22911
  };
22901
22912
  server.registerTool('plan_ad', {
22902
22913
  title: 'Plan an ad concept',
22903
- description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. Uses credits for the planning step.',
22914
+ description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
22904
22915
  inputSchema: {
22905
22916
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
22906
22917
  product: z.string().describe('what to advertise + any angle/offer the user specified'),
@@ -22909,7 +22920,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
22909
22920
  hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
22910
22921
  setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
22911
22922
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
22912
- reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post, YouTube Short/video or a direct video file — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
22923
+ reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post or a direct video file (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).) — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
22913
22924
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
22914
22925
  talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
22915
22926
  concepts: z.array(z.string()).max(4).optional().describe('several variants: one line per concept (a different angle each); built exactly'),
@@ -23001,9 +23012,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
23001
23012
  // Streamed (apiSSE), because watching + planning can outlast a 100s proxy limit on the non-streaming path.
23002
23013
  server.registerTool('clone_video', {
23003
23014
  title: 'Clone a video for your brand',
23004
- description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube Short or video, or a direct video file URL. Hermoso WATCHES it first (frames across the whole clip plus a transcript of the voiceover, on-screen text and cut map), then plans a storyboard that keeps its hook device, structure, jump cuts and pacing while swapping in THIS brand\'s product, cast, setting and words — never the original\'s words, face or brand. The new ad MATCHES THE ORIGINAL\'S LENGTH (capped at 60s) unless durationSeconds is given. Renders nothing: pass the returned creative to render_ad to make the video. Costs the plan plus about 2 credits to read the link. The reply says exactly what was watched, and when a platform will not hand over the footage (YouTube sometimes refuses servers) it says the plan rests on the captions and thumbnail only. For a local file, upload_file it first and pass the URL.',
23015
+ description: 'Remake a video you like FOR THIS BRAND from its link — 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). Hermoso WATCHES it first (frames across the whole clip plus a transcript of the voiceover, on-screen text and cut map), then plans a storyboard that keeps its hook device, structure, jump cuts and pacing while swapping in THIS brand\'s product, cast, setting and words — never the original\'s words, face or brand. The new ad MATCHES THE ORIGINAL\'S LENGTH (capped at 60s) unless durationSeconds is given. Renders nothing: pass the returned creative to render_ad to make the video. Costs the plan plus about 2 credits to read the link. The reply says exactly what was watched, and when a platform will not hand over the footage (YouTube sometimes refuses servers) it says the plan rests on the captions and thumbnail only. For a local file, upload_file it first and pass the URL.',
23005
23016
  inputSchema: {
23006
- url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X / YouTube link, or a direct https video file URL; ' + MEDIA_PARAM_NOTE),
23017
+ url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X link, or a direct https video file URL (not YouTube, which cannot be downloaded); ' + MEDIA_PARAM_NOTE),
23007
23018
  product: z.string().optional().describe('what the new ad sells, plus any angle or offer; omit to use the saved brand\'s product'),
23008
23019
  changes: z.string().optional().describe('what to change or keep from the original, in the user\'s words (e.g. "same hook but in a gym", "keep the jump cut, older creator")'),
23009
23020
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace\'s saved brand (see get_brand)'),
@@ -23023,7 +23034,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23023
23034
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
23024
23035
  }, wrap(async ({ url, product, changes, brand, durationSeconds, language }) => {
23025
23036
  const link = String(url || '').trim();
23026
- if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook, X or YouTube post, or a direct file). For a file on this machine, call upload_file first and pass the URL it returns. Nothing was charged.');
23037
+ if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook or X post, or a direct file; YouTube links cannot be downloaded, so for a YouTube video the user downloads it and you upload_file it). For a file on this machine, call upload_file first and pass the URL it returns. Nothing was charged.');
23027
23038
  const _askedLen = +durationSeconds > 0 ? Math.round(+durationSeconds) : 0;
23028
23039
  const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
23029
23040
  let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
@@ -23084,7 +23095,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23084
23095
  server.group('create');
23085
23096
  server.registerTool('generate_image', {
23086
23097
  title: 'Generate ad image',
23087
- description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. AN ANIMATED GIF OR AN SVG: format "gif" (a short looping animation, frames + frameMs) or "svg" (an editable vector) on gpt-image-2.6 / gpt-image-2.6-flare, from the prompt alone (no refImages). RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
23098
+ description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand\u2019s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. AN ANIMATED GIF OR AN SVG: format "gif" (a short looping animation, frames + frameMs) or "svg" (an editable vector) on gpt-image-2.6 / gpt-image-2.6-flare, from the prompt alone (no refImages). RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
23088
23099
  inputSchema: {
23089
23100
  prompt: z.string().optional().describe('REQUIRED on every model EXCEPT the pose rows below. the full image prompt — subject, composition, lighting, and any on-image ad text. ON A POSE MODEL (product-in-hand / try-on) THIS IS EXTRA DIRECTION AND IT IS OPTIONAL — leave it out and the pose is built for you. If you do write one, DESCRIBE THE POSE ("she holds the bottle upright in her right hand at chest height, label to camera"); do NOT phrase it as a swap ("replace the mug with the bottle"), which is REFUSED for free, because the product then comes out the size of whatever it replaced — a 30ml bottle rendered mug-sized in testing.'),
23090
23101
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so. ' + LIKENESS_TERMS),
@@ -23432,7 +23443,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23432
23443
  server.group('create');
23433
23444
  server.registerTool('render_ad', {
23434
23445
  title: 'Render ad video',
23435
- description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product. It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
23446
+ description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product. It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand\u2019s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
23436
23447
  inputSchema: {
23437
23448
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
23438
23449
  creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand (casting skips the portrait we would otherwise generate). Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
@@ -23759,7 +23770,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23759
23770
  title: 'Compose an edit (timeline)',
23760
23771
  description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes, plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, speed ramp, slow motion, freeze frame, reverse, flash, J-cut / L-cut, circle or shape wipe, split screen, picture in picture: all composed from the fields below. Local render, the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks for one. "
23761
23772
  + "LOOK FIRST AND AFTER: video_frames(url, start, end, fps) shows exact frames free, so find the moment (the second the egg tips) before you cut, and check the seam of what you rendered before you call it done. "
23762
- + "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X / YouTube post link (a viral hook's link works as is). in/out are SOURCE seconds; out:'payoff' cuts just before the hook's payoff, found from its motion. "
23773
+ + "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X post link (a viral hook's link works as is; a YouTube link is refused, YouTube does not let us download videos, so upload the file instead). in/out are SOURCE seconds; out:'payoff' cuts just before the hook's payoff, found from its motion. "
23763
23774
  + "AUDIO: audio.tail:'payoff' carries the hook's OWN payoff sound across the cut (the egg's real splat over our clip); tail seconds = an L-cut; lead = a J-cut; gain dB or keys; mute. "
23764
23775
  + "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
23765
23776
  + "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
@@ -23894,9 +23905,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
23894
23905
  // leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
23895
23906
  server.registerTool('clip_video', {
23896
23907
  title: 'Clip a long video',
23897
- description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED), a direct https .mp4/.mov/.webm, or 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 ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
23908
+ description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED watch link (NOT YouTube: YouTube does not let us download videos, so for a YouTube video the user downloads it and uploads the file), a direct https .mp4/.mov/.webm, or 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 ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
23898
23909
  inputSchema: {
23899
- 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; ' + MEDIA_PARAM_NOTE),
23910
+ video: z.string().describe('the long video to clip — a Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL; ' + MEDIA_PARAM_NOTE),
23900
23911
  count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
23901
23912
  aspectRatio: z.string().optional().describe("clip shape: '9:16' (default), '1:1', '16:9', any 'W:H', or 'keep' for the source framing"),
23902
23913
  captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
@@ -23926,7 +23937,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23926
23937
  // upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
23927
23938
  server.registerTool('add_subtitles', {
23928
23939
  title: 'Add subtitles to a video',
23929
- description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a YouTube/Vimeo-style page, or a TikTok / Reel / X post link. No speech is refused and refunded. Runs in the background and lands in the Library.",
23940
+ description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a Vimeo-style page, or a TikTok / Reel / X post link (not YouTube: YouTube does not let us download videos, so upload the file). No speech is refused and refunded. Runs in the background and lands in the Library.",
23930
23941
  inputSchema: {
23931
23942
  video: z.string().describe('the video to subtitle; ' + MEDIA_PARAM_NOTE),
23932
23943
  textStyle: TEXT_STYLE_Z.optional().describe('the look: words, a preset or fields; omit for the default'),
@@ -24077,7 +24088,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
24077
24088
  };
24078
24089
  server.registerTool('make_series', {
24079
24090
  title: 'Make a series of faceless short videos (Content Factory)',
24080
- description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / YouTube Short / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
24091
+ description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
24081
24092
  inputSchema: {
24082
24093
  topic: z.string().optional().describe('what the series is about, in the user\'s words'),
24083
24094
  episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
@@ -26491,8 +26502,29 @@ function memoryNoteVerdict(text) {
26491
26502
  // so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
26492
26503
  const who = d?.personaNote ? `\n${d.personaNote}` : '';
26493
26504
  const _pt = d?.profileType ? ` · ${d.profileType} profile` : '';
26505
+ // WHAT THE BRAND HOLDS, IN WORDS (2026-11-06): the product photos, logo, palette and product names were in
26506
+ // structuredContent only, and an agent reading the TEXT told a user the brand had no product photo while one was saved.
26507
+ const _brandFacts = (b) => {
26508
+ if (!b || typeof b !== 'object') return '';
26509
+ const _s = (x) => (typeof x === 'string' ? x.trim() : '');
26510
+ const imgs = [...new Set((Array.isArray(b.productImages) ? b.productImages : []).map((x) => _s(typeof x === 'string' ? x : x?.url)).filter(Boolean))];
26511
+ const hero = _s(b.product) || imgs[0] || '';
26512
+ const titles = new Map((Array.isArray(b.productTitles) ? b.productTitles : []).filter((x) => x && x.image).map((x) => [x.image, _s(x.title)]));
26513
+ const all = hero && !imgs.includes(hero) ? [hero, ...imgs] : imgs;
26514
+ const lines = [];
26515
+ if (all.length) {
26516
+ const ordered = [hero, ...all.filter((u) => u !== hero)].filter(Boolean);
26517
+ lines.push(`Product photos: ${ordered.length} saved (never ask the user to upload them again; plan_ad / render_ad / generate_image ground on the primary one automatically). Primary (hero): ${abs(ordered[0])}${titles.get(ordered[0]) ? ' (' + titles.get(ordered[0]) + ')' : ''}.` + (ordered.length > 1 ? ' Others: ' + ordered.slice(1, 8).map((u) => abs(u) + (titles.get(u) ? ' (' + titles.get(u) + ')' : '')).join('; ') + (ordered.length > 8 ? ` (+${ordered.length - 8} more, list_product_photos)` : '') + '.' : ''));
26518
+ } else lines.push('Product photos: none saved (set_product_image or draft_brand with confirmed:true adds them).');
26519
+ if (_s(b.logo)) lines.push(`Logo: ${abs(_s(b.logo))}`);
26520
+ const pal = (Array.isArray(b.palette) ? b.palette : []).map((c) => _s(typeof c === 'string' ? c : c?.hex || c?.color)).filter(Boolean);
26521
+ if (pal.length) lines.push(`Palette: ${pal.slice(0, 8).join(', ')}`);
26522
+ const prods = (Array.isArray(b.products) ? b.products : []).map((x) => _s(typeof x === 'string' ? x : x?.title || x?.name)).filter(Boolean);
26523
+ if (prods.length) lines.push(`Products: ${prods.slice(0, 12).join(', ')}`);
26524
+ return lines.length ? '\n' + lines.join('\n') : '';
26525
+ };
26494
26526
  const text = d?.hasBrand
26495
- ? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${who}`
26527
+ ? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${_brandFacts(d.brand)}${who}`
26496
26528
  : (d?.brand && d?.profileType === 'explore')
26497
26529
  ? `The profile “${d.brand.name || d.brand.domain}” is saved but set to just exploring, so nothing you make uses it. Make what they ask with no brand; switch it back with update_brand profileType (website, social or describe) only if THEY ask for on-brand work.${who}`
26498
26530
  : (d?.persona === 'explorer'
@@ -26618,13 +26650,14 @@ function memoryNoteVerdict(text) {
26618
26650
 
26619
26651
  server.registerTool('list_library', {
26620
26652
  title: 'List library',
26621
- description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
26653
+ description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), a human TITLE (the ad's headline or concept, the prompt's first words, or the upload's name), kind, model, and age. When the user names an ad by its headline (\"the Afternoon fix ad\"), pass `query` to find it by title. Free, read-only.",
26622
26654
  inputSchema: {
26623
26655
  kind: z.enum(['image', 'video', 'document', 'all']).optional().describe("filter by asset kind (default 'all')"),
26624
26656
  uploads: z.boolean().optional(),
26657
+ query: z.string().optional().describe('only assets whose title contains this text (case-insensitive), e.g. an ad headline the user quoted'),
26625
26658
  limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
26626
26659
  },
26627
- outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
26660
+ outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), title: z.string().optional(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
26628
26661
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
26629
26662
  }, wrap(async (a) => {
26630
26663
  let list = await readStore('heist.assets.v1'); // via /api/store/bootstrap — there is no GET /api/store/:key route
@@ -26633,10 +26666,21 @@ function memoryNoteVerdict(text) {
26633
26666
  const lim = Math.min(60, Math.max(1, +a.limit || 20));
26634
26667
  // id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
26635
26668
  const up = a.uploads === true;
26636
- const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => (!kind || x.kind === kind) && (!up || x.source === 'upload')).slice(0, lim)
26637
- .map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
26669
+ // A HUMAN TITLE ON EVERY ROW (2026-11-06): a user quotes an ad by its headline and the Library had only ids and urls.
26670
+ // Newer rows carry `title`; older ones are named from their linked creation (headline, concept, title) or their concept.
26671
+ let _cr = []; try { const c = await readStore('heist.creations.v1'); if (Array.isArray(c)) _cr = c; } catch {}
26672
+ const _crById = new Map(_cr.filter((c) => c && c.id).map((c) => [c.id, c]));
26673
+ const _clean = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 120);
26674
+ const titleOf = (x) => {
26675
+ const c = x.creationId ? _crById.get(x.creationId) : null;
26676
+ return _clean(x.title) || (x.source === 'upload' ? _clean(x.name) : '') || _clean(c?.title) || _clean(c?.creative?.headline) || _clean(c?.creative?.concept) || _clean(x.concept) || _clean(c?.product) || '';
26677
+ };
26678
+ const q = String(a.query || '').trim().toLowerCase();
26679
+ const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i, title: titleOf(x) })).filter(({ x, title }) => (!kind || x.kind === kind) && (!up || x.source === 'upload') && (!q || (title + ' ' + _clean(x.concept) + ' ' + _clean(x.name)).toLowerCase().includes(q))).slice(0, lim)
26680
+ .map(({ x, id, title }) => ({ id, url: abs(x.url), ...(title ? { title } : {}), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
26681
+ if (!assets.length && q) return ok(`No Library asset has a title containing “${a.query}”. Call list_library without query to see every title.`, { assets: [] });
26638
26682
  if (!assets.length) return ok(up ? 'Nothing uploaded to this workspace\'s Library yet — upload_file keeps an image, video or PDF there.' : 'The Library is empty for this workspace — render something or upload_file a file first.', { assets: [] });
26639
- return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
26683
+ return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. ${x.title ? '“' + x.title + '” ' : ''}[${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
26640
26684
  }));
26641
26685
 
26642
26686
  // DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
@@ -27082,9 +27126,9 @@ function memoryNoteVerdict(text) {
27082
27126
  // motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is a `multiply` job (opening-frame keyframe edit → motion transfer driven by the source, so length, cut, motion and audio are the original's).
27083
27127
  server.registerTool('multiply_ad', {
27084
27128
  title: 'Multiply an ad',
27085
- description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Your own pictures: images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook, X or YouTube post link works as the source: one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
27129
+ description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Your own pictures: images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook or X post link works as the source (not YouTube: upload the file): one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
27086
27130
  inputSchema: {
27087
- video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook, X or YouTube post link'),
27131
+ video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook or X post link (not YouTube)'),
27088
27132
  images: z.array(z.union([z.string(), z.object({ url: z.string(), role: z.string().optional().describe('what it is, in your words, e.g. "our founder"') })])).optional().describe('up to 9 pictures to put in the variants, in the order @Image1, @Image2 name them: URLs or local paths'),
27089
27133
  quality: z.enum(['1080p', '720p']).optional().describe('1080p is the default; 720p costs less, and hands holding a product are less reliable at 720p'),
27090
27134
  useBrand: z.boolean().optional().describe('false = send no brand product photo (for a video that is not this workspace brand)'),
@@ -27246,7 +27290,7 @@ function memoryNoteVerdict(text) {
27246
27290
  // the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
27247
27291
  server.registerTool('recast_hook', {
27248
27292
  title: 'Recast a viral hook with your subject',
27249
- description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X, YouTube; one lookup) or a video file. Subject: creator (saved name or id), image (a product or person; a real person's photo records consent), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?, outfit?}], each bound to its place in the frame, keeping that performer's movement and timing on the original sound. LOCATION AND OUTFITS, same pass: location (a place photo or words) moves the scene; outfit (a photo or words) re-dresses the subject, subjects[].outfit each person; with no subject named nobody is swapped and only those change (useBrand:true also swaps in the brand product). Pictures are references, 4 at most; more go in words. Person for person: Seedance 2.5 (720p) if the hook is our render or faceRoute is set, else Wan 3.0 (≤15s; Kling 4.0 Edit past). FULL LENGTH: past its one pass (30 s) the window is recast in passes, each continuing from the previous one's frame and crossfaded across the cut, under the original sound: one video up to 60 s, quoted as passes plus join; the reply names the cut. mode 'auto' is always 'swap' (a region edit: camera, set, light, timing, text and sound kept; a multi-shot hook goes shot by shot); 'motion' only if asked (your person moves in their own photo's setting). swap: your own words for what changes. start/end pick the window (3 s minimum; default: a group or a location/outfit change takes the whole clip, one subject the first 10 s). PAID, QUOTED FIRST: no confirm returns the plan and credits; confirm:true once the user okays it. Then get_job until done, and post_edit with videoUrl = the result and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
27293
+ description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X; one lookup; not YouTube, which cannot be downloaded) or a video file. Subject: creator (saved name or id), image (a product or person; a real person's photo records consent), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?, outfit?}], each bound to its place in the frame, keeping that performer's movement and timing on the original sound. LOCATION AND OUTFITS, same pass: location (a place photo or words) moves the scene; outfit (a photo or words) re-dresses the subject, subjects[].outfit each person; with no subject named nobody is swapped and only those change (useBrand:true also swaps in the brand product). Pictures are references, 4 at most; more go in words. Person for person: Seedance 2.5 (720p) if the hook is our render or faceRoute is set, else Wan 3.0 (≤15s; Kling 4.0 Edit past). FULL LENGTH: past its one pass (30 s) the window is recast in passes, each continuing from the previous one's frame and crossfaded across the cut, under the original sound: one video up to 60 s, quoted as passes plus join; the reply names the cut. mode 'auto' is always 'swap' (a region edit: camera, set, light, timing, text and sound kept; a multi-shot hook goes shot by shot); 'motion' only if asked (your person moves in their own photo's setting). swap: your own words for what changes. start/end pick the window (3 s minimum; default: a group or a location/outfit change takes the whole clip, one subject the first 10 s). PAID, QUOTED FIRST: no confirm returns the plan and credits; confirm:true once the user okays it. Then get_job until done, and post_edit with videoUrl = the result and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
27250
27294
  inputSchema: {
27251
27295
  hook: z.string().describe('the viral hook: a post link or a video URL'),
27252
27296
  creator: z.string().optional().describe("a saved creator's name or id (list_creators). NONE SAVED: cast a preset by exact name (list_creators), or pass image instead: a portrait of an AI person that generate_image painted from words (no reference photo) swaps in on every plan, nothing to save. A described lookalike of the user is fine on every plan; a photo of anyone is a real face (paid plan)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.525",
3
+ "version": "0.1.526",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1178 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 10 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",