hermoso 0.1.223 → 0.1.225

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 (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +97 -13
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **822 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **823 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -171,7 +171,7 @@ block entirely if you signed in above; it is there for CI, where the process can
171
171
 
172
172
  Then ask your agent: *“Generate an image ad with Hermoso.”*
173
173
 
174
- ### What the 822 tools cover
174
+ ### What the 823 tools cover
175
175
 
176
176
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
177
177
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -427,7 +427,9 @@ async function renderJob(type, input, label) {
427
427
  deliveredWidth: result?.deliveredWidth ?? null, deliveredHeight: result?.deliveredHeight ?? null,
428
428
  raw: result };
429
429
  } catch (e) {
430
- if (remote && e?.jobId) return { jobId: job.id, url: null, stillRendering: true, raw: null }; // not an error — resume via get_job
430
+ // not an error — resume via get_job. On EVERY transport (2026-09-11): a 20–24s long-clip render queued behind two
431
+ // others outlived the local 10-minute poll, and the tool reported "Render timed out" while the job finished fine.
432
+ if (e?.jobId) return { jobId: job.id, url: null, stillRendering: true, raw: null };
431
433
  throw e;
432
434
  }
433
435
  }
@@ -500,6 +502,15 @@ const AR_UNIVERSAL = ['9:16', '16:9'];
500
502
  const RAW_TOOL_NOTE = 'raw:true dispatches your prompt to the model BYTE-IDENTICAL — no rewriting, no appended guidance, no negative prompt, no brand references attached on your behalf. Credits, the durable delivery of the finished asset and the per-model validation are unchanged.';
501
503
  const AD_LENGTH_MAX = 180, AD_LENGTH_MIN = 4;
502
504
  const clampAdSeconds = (n) => Math.max(AD_LENGTH_MIN, Math.min(AD_LENGTH_MAX, Math.round(n)));
505
+ // What a video reference ACTUALLY gave the planner (/api/create's `reference_watched`), in one line. A remix of footage
506
+ // nobody watched must say so, so the ⚠ note is printed whenever the server wrote one.
507
+ const refWatchedLine = (w) => {
508
+ if (!w) return '';
509
+ const src = { tiktok: 'TikTok', instagram: 'Instagram', facebook: 'Facebook', x: 'X', youtube: 'YouTube' }[w.platform] || 'video';
510
+ const got = [w.durationSeconds ? `${w.durationSeconds}s` : '', w.frames ? `${w.frames} ${w.footage ? 'frames' : 'thumbnail'}` : '', w.transcript ? 'transcript' : ''].filter(Boolean).join(' · ');
511
+ const shot = { 'raw-self-filmed': 'self-filmed on a phone, so the remix is too', 'creator-polished': 'a creator on a phone', 'produced-commercial': 'a produced commercial' }[w.register] || '';
512
+ return `\nWatched: ${src}${got ? ` — ${got}` : ''}${shot ? `\nShot as: ${shot}` : ''}${w.note ? `\n⚠ ${w.note}` : ''}`;
513
+ };
503
514
 
504
515
  // ── THE ATTRIBUTION PAIR EVERY PUBLISH TOOL CARRIES (2026-08-05) ─────────────────────────────────────────────────
505
516
  // The hook and the subject are the INTENT behind a post, and publish time is the ONLY moment they exist: a caption
@@ -15629,7 +15640,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15629
15640
  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.'),
15630
15641
  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.'),
15631
15642
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
15632
- reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
15643
+ reference: z.string().optional().describe('a reference to remix: 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'),
15633
15644
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
15634
15645
  },
15635
15646
  outputSchema: {
@@ -15690,7 +15701,56 @@ function buildTools(rawServer, opts = {}, sink = null) {
15690
15701
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
15691
15702
  + (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '');
15692
15703
  }
15693
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
15704
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
15705
+ return ok(text, c);
15706
+ }));
15707
+
15708
+ // ── CLONE A VIDEO FROM A LINK (2026-09-11) ────────────────────────────────────────────────────────────────────
15709
+ // Arcads' "paste a TikTok, clone it for my brand". The server does the watching (/api/create resolves a social post
15710
+ // to its real file, samples frames across it and transcribes it) and plans a board that keeps the ORIGINAL'S
15711
+ // skeleton — hook device, segment map, deliberate jump cuts, pacing — with this brand's product, cast and words.
15712
+ // It stops at the plan on purpose: render_ad is the spend, and the user sees the concept before paying for it.
15713
+ // Streamed (apiSSE), because watching + planning can outlast a 100s proxy limit on the non-streaming path.
15714
+ server.registerTool('clone_video', {
15715
+ title: 'Clone a video for your brand',
15716
+ 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.',
15717
+ inputSchema: {
15718
+ url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X / YouTube link, or a direct https video file URL'),
15719
+ product: z.string().optional().describe('what the new ad sells, plus any angle or offer; omit to use the saved brand\'s product'),
15720
+ 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")'),
15721
+ 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)'),
15722
+ durationSeconds: z.number().optional().describe('override the length in seconds; omit to match the original'),
15723
+ language: z.string().optional().describe('language for the new ad\'s script and copy — default English'),
15724
+ },
15725
+ outputSchema: {
15726
+ format: z.string().optional().describe("always 'video'"),
15727
+ concept: z.string().optional().describe('the one-line creative concept'),
15728
+ copy: z.array(z.any()).optional().describe('copy variants ({headline, primary, cta})'),
15729
+ video_storyboard: z.any().optional().describe('the timed storyboard rebuilt from the original'),
15730
+ render_plan: z.any().optional().describe('the routing plan (structure/duration) render_ad honors'),
15731
+ vmodel: z.string().optional().describe('the video model id to render with'),
15732
+ reference_watched: z.any().optional().describe('what was actually read from the link: platform, durationSeconds, frames, transcript, footage, note'),
15733
+ brand: z.any().optional().describe('the brand grounding embedded in the creative'),
15734
+ },
15735
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
15736
+ }, wrap(async ({ url, product, changes, brand, durationSeconds, language }) => {
15737
+ const link = String(url || '').trim();
15738
+ 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.');
15739
+ const _askedLen = +durationSeconds > 0 ? Math.round(+durationSeconds) : 0;
15740
+ const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
15741
+ let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
15742
+ if (typeof brand === 'string' && brand.trim()) {
15743
+ const _n = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
15744
+ try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
15745
+ }
15746
+ const brief = [`Recreate the reference video for this brand${product ? ` — advertising ${product}` : ''}: keep its hook device, structure, cuts and pacing, but make every word, face, setting and product this brand's own`, changes ? `What the user wants changed or kept: ${changes}` : ''].filter(Boolean).join('. ');
15747
+ const d = await apiSSE('/api/create', { stream: true, brand: brandObj, product: brief, format: 'video', reference: { url: link }, matchReferenceLength: !_len, ...(_len ? { durationSeconds: _len } : {}), language: language || '', userAsk: brief });
15748
+ const c = d?.data?.creative || d?.creative || d;
15749
+ if (brandObj && !c.brand) c.brand = { name: brandObj.name || '', domain: brandObj.domain || '', logo: brandObj.logo || '', sells: brandObj.sells || '', palette: (brandObj.palette || []).slice(0, 4), productImages: (brandObj.productImages || []).slice(0, 4) };
15750
+ const planned = Math.round(+c.render_plan?.duration_seconds || (c.video_storyboard?.scenes || []).reduce((s, x) => s + (+x.seconds || 0), 0) || 0);
15751
+ const w = c.reference_watched || null;
15752
+ const lenNote = _askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, outside the supported 4–180s, so it was clamped to ${_len}s` : (!_len && w?.lengthMatched ? (w.durationSeconds > w.lengthMatched + 1 ? ` — the original is ${w.durationSeconds}s; a clone matches its length up to 60s, so pass durationSeconds for a different length` : ' — matches the original') : '') + (!_len && w?.lengthMatched && planned && Math.abs(planned - w.lengthMatched) > 1 ? ` — ⚠ the plan came out at ${planned}s, not the ${w.lengthMatched}s asked for; say so before rendering` : '');
15753
+ const text = `Clone plan: "${c.concept || ''}"${refWatchedLine(w)}${w ? '' : '\n⚠ The link was not watched — the plan is not grounded in the original video.'}\nLength: ${planned || '—'}s${lenNote}\nHeadline: ${c.copy?.[0]?.headline || ''}\nScenes: ${(c.video_storyboard?.scenes || []).length}. Render model: ${c.vmodel || '—'}.\nNext: call render_ad with THIS ENTIRE creative object to make the video (it spends credits; show the user the concept first).`;
15694
15754
  return ok(text, c);
15695
15755
  }));
15696
15756
 
@@ -15838,7 +15898,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15838
15898
  server.group('create');
15839
15899
  server.registerTool('render_ad', {
15840
15900
  title: 'Render ad video',
15841
- description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
15901
+ description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
15842
15902
  inputSchema: {
15843
15903
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
15844
15904
  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”). 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 — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.'),
@@ -15846,10 +15906,23 @@ function buildTools(rawServer, opts = {}, sink = null) {
15846
15906
  durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
15847
15907
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
15848
15908
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
15849
- captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE — leave it off unless the user asks for on-screen text (no captions, or true subtitles of what is said; never scene or emphasis labels); a recipe whose format IS on-screen text keeps its text either way'),
15850
- endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
15909
+ captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE on every recipe — set true ONLY when the user asks for on-screen text or captions; no recipe turns them on by itself'),
15910
+ endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE on every recipe — set true ONLY when the user asks for an end card (a clone of a video that had none should not grow one)'),
15851
15911
  music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
15852
- lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
15912
+ lockup: z.boolean().optional().describe('brand wordmark + tagline composited over the closing seconds. DEFAULT FALSE — set true ONLY when the user asks for branding on the close'),
15913
+ textStyle: z.union([
15914
+ z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']),
15915
+ z.object({
15916
+ preset: z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']).optional(),
15917
+ font: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
15918
+ subFont: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
15919
+ weight: z.number().optional(), size: z.union([z.enum(['s', 'm', 'l', 'xl']), z.number()]).optional(),
15920
+ color: z.string().optional().describe('#hex'), background: z.string().optional().describe('"none", "pill", or a #hex box'),
15921
+ position: z.enum(['top', 'center', 'lower', 'bottom']).optional(), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
15922
+ italic: z.boolean().optional(), subItalic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
15923
+ tilt: z.number().optional().describe('degrees, ±12'), cardColor: z.string().optional().describe('#hex end card background'),
15924
+ }),
15925
+ ]).optional().describe('THE LOOK of captions and the end card — only meaningful with captions:true or endCard:true, and only when the user described a look. Presets: editorial (a large elegant serif title mid-frame with a small italic line under it, no box), bold (tall condensed caps with a black outline), minimal (small lowercase near the bottom), handwritten (tilted marker), boxed (dark words on a white box), pill (the plain default). Pass a preset name, or an object with a preset plus overrides. A caption written "TITLE · small line" puts the part after the middle dot on a second line. An invalid field is refused by name before anything renders.'),
15853
15926
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
15854
15927
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
15855
15928
  allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
@@ -16036,7 +16109,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16036
16109
  subtitles: z.boolean().optional().describe('which on-screen text, once `captions` is on. LEAVE IT UNSET (or true) for SUBTITLES — every spoken word, in order, timed to the narration; free, no extra render, no extra credits, and there is NO cue limit, so the whole film is subtitled however long it runs (at most 5 words / 32 characters a line). Set it FALSE only if the user explicitly wants section HEADINGS instead: one short summary label held over each ~7-15s section. That is NOT what is being said — it is a label about it — so it is the wrong answer to "add captions" and to anyone watching on mute. `subtitles:true` also implies `captions:true`. TIMING: each cue is anchored to that section’s REAL measured narration length and distributed inside the section by character count — exact at every section boundary, approximate to a few tenths of a second within one. It is not a word-level speech clock, so never promise frame-accurate sync.'),
16037
16110
  music: z.string().optional().describe("music bed under the narration, measured to sit about 14 dB under the voice and sidechain-ducked beneath it. Omit and the KIDS and FAIRYTALE channels get their recommended bed COMPOSED for this film — those two are the only channels a bed is due on unasked, and it costs a small flat fee; every other channel ships dry. 'off' forces silence. 'library' takes a free curated track only, and ships dry when none is on file. NAME A MOOD — upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic — to compose one on ANY channel, at the same fee. hermoso_capabilities reports the exact figure as explainerMusicCredits; quote it before you turn a bed on or pick a mood."),
16038
16111
  upscale: z.number().optional().describe("optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose."),
16039
- endCard: z.boolean().optional().describe('append the branded end card (default true)'),
16112
+ endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
16040
16113
  brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
16041
16114
  },
16042
16115
  outputSchema: { ...JOB_OUT },
@@ -18440,15 +18513,24 @@ function memoryNoteVerdict(text) {
18440
18513
  axis: z.enum(['hook', 'subject', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook'),
18441
18514
  channel: z.string().optional().describe('restrict to one channel'),
18442
18515
  },
18443
- outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional() },
18516
+ outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional(), trend: z.any().optional(), health: z.any().optional() },
18444
18517
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
18445
18518
  }, wrap(async (a) => {
18446
18519
  const d = await apiGet('/api/posts/performance', { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) });
18447
18520
  const gs = d.groups || [];
18448
- if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim(), d);
18521
+ // OVER TIME + MEASUREMENT HEALTH (2026-09-11): week-by-week medians per channel and why unmeasured posts have no
18522
+ // numbers. Printed even when no hook comparison exists yet — "is it getting better" does not need five hooks.
18523
+ const fmtN = (v) => (v == null ? '—' : Number(v).toLocaleString('en-US', { maximumFractionDigits: v < 10 ? 1 : 0 }));
18524
+ const trendTxt = (d.trend?.channels || []).slice(0, 10).map(c => {
18525
+ const recent = c.buckets.slice(-4).map(b => `${b.week.slice(5)}: ${b.posts ? `${fmtN(c.reachUnit ? b.medianReach : b.medianEngagement)} (${b.posts}p${b.maturing ? ', maturing' : ''})` : '·'}`).join(' | ');
18526
+ return `• ${c.channel} — median ${c.reachUnit || 'engagement'}/post by week: ${recent}. ${c.direction?.verdict ? c.direction.why : `No direction yet: ${c.direction?.why || 'not enough data'}`}`;
18527
+ });
18528
+ const healthTxt = (d.health || []).filter(h => h.coverage == null || h.coverage < 0.8 || h.failed || h.neverRead).slice(0, 10).map(h => `• ${h.channel}: ${h.measured}/${h.posts} measured${h.coverage == null ? '' : ` (${Math.round(h.coverage * 100)}% of what could be)`}${h.neverRead ? ` · ${h.neverRead} never read` : ''}${h.empty ? ` · ${h.empty} read but empty${h.topEmpty ? ` (${h.topEmpty.message})` : ''}` : ''}${h.failed ? ` · ${h.failed} failed${h.topError ? ` — ${h.topError.message}` : ''}` : ''}${h.pending ? ` · ${h.pending} too new` : ''}`);
18529
+ const overTime = `${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}`;
18530
+ if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
18449
18531
  const rows = gs.map(g => `• "${g.key}" · ${g.channel} — ${g.meanRate == null ? (g.meanEngagement == null ? 'no measurable engagement' : `${g.meanEngagement.toFixed(1)} engagements (no reach denominator on this channel, so no rate)`) : `${(g.meanRate * 100).toFixed(2)}% engagement`} · ${g.n} post(s), ${g.nRated} measured${g.verdict === 'ready' ? '' : ` — ${g.suppressed}`}`);
18450
18532
  const head = d.finding?.finding ? `FINDING: ${d.finding.finding}` : `NO FINDING YET: ${d.finding?.why || 'not enough measured posts'}`;
18451
- return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}`, d);
18533
+ return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}${overTime}`, d);
18452
18534
  }));
18453
18535
 
18454
18536
  server.registerTool('diagnose_posts', {
@@ -18474,11 +18556,13 @@ function memoryNoteVerdict(text) {
18474
18556
  inputSchema: {
18475
18557
  includeMetered: z.boolean().optional().describe('also read X, which BILLS CREDITS per post read — ask the user first'),
18476
18558
  max: z.number().optional().describe('cap how many posts to read in this run (default 40)'),
18559
+ remeasure: z.boolean().optional().describe('ALSO re-read posts older than 7 days whose every reading came back empty or failed — use after post_performance reports posts "read but empty", or once a channel\'s reader has been fixed. Otherwise those windows stay closed.'),
18477
18560
  },
18478
- outputSchema: { collected: z.number().optional(), due: z.number().optional(), remaining: z.number().optional(), couldNotTell: z.number().optional(), skippedMetered: z.number().optional(), meteredNote: z.string().optional(), windows: z.array(z.any()).optional() },
18561
+ outputSchema: { collected: z.number().optional(), due: z.number().optional(), remaining: z.number().optional(), couldNotTell: z.number().optional(), skippedMetered: z.number().optional(), meteredNote: z.string().optional(), windows: z.array(z.any()).optional(), remeasured: z.number().optional(), remeasuredWithNumbers: z.number().optional() },
18479
18562
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
18480
18563
  }, wrap(async (a) => {
18481
- const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}) });
18564
+ const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) });
18565
+ if (a.remeasure) return ok(`Re-read ${d.remeasured || 0} old post(s) whose earlier readings were empty or failed; ${d.remeasuredWithNumbers || 0} now have numbers. Read ${d.collected} post(s) in total${d.remaining ? `, ${d.remaining} still waiting — run it again to continue` : ''}.${d.meteredNote ? ` ${d.meteredNote}` : ''}`, d);
18482
18566
  const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.gone ? `${d.gone} no longer exist at the platform (deleted or taken down) and will not be read again` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
18483
18567
  return ok(`${bits.join('. ')}.${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
18484
18568
  }));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.223",
3
+ "version": "0.1.225",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 822 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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.",
5
+ "description": "AI ad studio and marketing MCP server with 823 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"