hermoso 0.1.535 → 0.1.537

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/client.mjs CHANGED
@@ -7,6 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
9
  import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import { jobQuoteLegs } from './spend-guard.mjs'; // a quoted job as named legs (get_cost)
10
11
 
11
12
  // Remote-connector identity: mcp/http.mjs wraps each request in mcpCtx.run({ token }) so every /api call a tool
12
13
  // makes carries THAT caller's bearer (bills their account). stdio keeps using the env token — ctx is simply unset.
@@ -575,7 +576,7 @@ export async function submitJob(type, input, { label = '' } = {}) {
575
576
  if (st?.quoteOnly) {
576
577
  const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
577
578
  const q = d?.quote || null;
578
- (st.quoteLegs ||= []).push({ credits: q && Number(q.credits) > 0 ? Number(q.credits) : null, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: q && Number(q.credits) > 0 ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}) });
579
+ for (const leg of jobQuoteLegs(q, { label, type })) (st.quoteLegs ||= []).push(leg); // the job's named parts when the server itemised it (a storyboard's paints, a portrait, the clip), else the job as one leg
579
580
  throw quoteStopError();
580
581
  }
581
582
  // A TOOL CALL WITH AN idempotency_key: each job it queues carries a key derived from it (one per submit in the call), so a
@@ -108,17 +108,35 @@ export function quoteFromLegs(legs) {
108
108
  const sum = L.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
109
109
  const hold = L.reduce((t, l) => t + (Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
110
110
  if (!L.length) return { credits: 0, legs: L, free: true };
111
- return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: unpriced.map((l) => l.label || 'a step') } : {}) };
111
+ const also = [...new Set(L.flatMap((l) => (Array.isArray(l.alsoBilled) ? l.alsoBilled : [])))];
112
+ return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: [...new Set(unpriced.map((l) => l.label || 'a step'))] } : {}), ...(also.length ? { alsoBilled: also } : {}) };
113
+ }
114
+ /**
115
+ * The legs of ONE job's dry-run quote (2026-11-13). When the server itemised the job (`parts`: a storyboard's image paints,
116
+ * a cast portrait, an opening frame, the clip itself), each part is its own named leg; otherwise the job is one leg, as
117
+ * before. The parts are used only when they add up to the job's own figure, so the total is always the dry run's.
118
+ * `alsoBilled` names the small reads the job bills on top at their actual cost (the storyboard check, the product photo
119
+ * read): said in the answer, never a number that is not known.
120
+ */
121
+ export function jobQuoteLegs(q, { label = '', type = '' } = {}) {
122
+ const credits = q && Number(q.credits) > 0 ? Number(q.credits) : null;
123
+ const also = q && Array.isArray(q.alsoBilled) ? q.alsoBilled.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 6) : [];
124
+ const one = { credits, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: credits != null ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}), ...(also.length ? { alsoBilled: also } : {}) };
125
+ const parts = credits != null && !(Number(q.priceCredits) > 0) && Array.isArray(q.parts) ? q.parts.filter((x) => x && Number(x.credits) > 0 && String(x.label || '').trim()) : [];
126
+ if (!parts.length || Math.abs(parts.reduce((t, x) => t + Number(x.credits), 0) - credits) > 0.05) return [one];
127
+ return parts.map((x, i) => ({ credits: Number(x.credits), basis: 'hold', label: String(x.label).trim().slice(0, 90), ...(i === parts.length - 1 && also.length ? { alsoBilled: also } : {}) }));
112
128
  }
113
129
  const legLine = (l) => `${l.label || 'a step'}: ${Number(l.priceCredits) > 0 ? `${l.priceCredits}` : Number(l.credits) > 0 ? `${l.credits}` : 'its actual cost'}`;
130
+ const alsoLine = (q) => (Array.isArray(q.alsoBilled) && q.alsoBilled.length ? ` Billed on top at their actual cost, a small read each: ${q.alsoBilled.join(', ')}.` : '');
114
131
  export function quoteText(tool, q) {
115
132
  if (q.refused) return `NO PRICE (nothing ran, nothing was charged): ${tool} would not start: ${q.refused}`;
116
133
  if (q.free) return `PRICE (nothing ran, nothing was charged): ${tool} with these arguments spends no credits.`;
117
134
  if (q.credits == null) {
118
135
  const known = q.legs.filter((l) => Number(l.credits) > 0);
119
- return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${known.map(legLine).join(', ')} credits.` : ''} expected_credits cannot bind this call; run it without it.`;
136
+ const knownSum = round1(known.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits)), 0));
137
+ return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${knownSum} credits: ${known.map(legLine).join('; ')}.` : ''}${alsoLine(q)} expected_credits cannot bind this call; run it without it.`;
120
138
  }
121
- return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
139
+ return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''}${alsoLine(q)} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
122
140
  }
123
141
  export function expectedMismatchText(tool, expected, q) {
124
142
  if (q.credits == null) return `Refused, nothing ran and nothing was charged: expected_credits was ${expected}, but ${tool} has no fixed price here (${q.refused || `billed at the actual cost of ${(q.unpriced || []).join(' and ') || 'its model steps'}`}). Run it without expected_credits, or ask the user first.`;
package/mcp/tools.mjs CHANGED
@@ -15785,7 +15785,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15785
15785
  const amzPlacements = z.record(z.number()).optional().describe('bid increase per placement in whole percent 0-900, keys PLACEMENT_TOP, PLACEMENT_REST_OF_SEARCH, PLACEMENT_PRODUCT_PAGE, SITE_AMAZON_BUSINESS');
15786
15786
  server.registerTool('list_amazon_ads_profiles', {
15787
15787
  title: 'List Amazon Ads profiles',
15788
- description: 'The Amazon Ads advertiser profiles shared with this brand (ticked under Manage accounts), one per advertiser per marketplace across North America, Europe and the Far East: profileId, marketplace, currency, seller or vendor, account name. Only these are usable. Every other Amazon Ads tool takes amazonProfileId. Read-only, free.',
15788
+ description: 'The Amazon Ads advertiser profiles shared with this brand (ticked under Manage accounts, or call list_connector_accounts with provider amazon_ads, then set_connector_accounts), one per advertiser per marketplace across North America, Europe and the Far East: profileId, marketplace, currency, seller or vendor, account name. Only these are usable. Every other Amazon Ads tool takes amazonProfileId. Read-only, free.',
15789
15789
  inputSchema: {},
15790
15790
  outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), profiles: z.array(z.any()).optional(), note: z.string().optional() },
15791
15791
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -25818,7 +25818,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
25818
25818
  loop: z.boolean().optional().describe('true = a seamless loop whose last frame flows back into its first. Only models with loop true in hermoso_capabilities; needs refImage and cannot be combined with endImage.'),
25819
25819
  shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order; the seconds add up to a length the model renders. Replaces `prompt`. Only models with multiShot true in hermoso_capabilities; a shot prompt over that model’s shotPromptMaxChars (Kling 3.0: 512) is refused free, never cut.'),
25820
25820
  extend: z.boolean().optional().describe('true = EXTEND refVideo by durationSeconds (default 5) with what your prompt says happens next, as ONE clip. By default it CUTS to a NEW SHOT: new angle, same scene, room sound carried, no music at the cut. A named model (any i2v) renders it.'),
25821
- extendMode: z.enum(['cut', 'continue']).optional().describe("with extend: 'continue' = the SAME take past its last frame, only when asked seamless (models with extend in hermoso_capabilities)."),
25821
+ extendMode: z.enum(['cut', 'continue']).optional().describe("with extend: 'continue' = the SAME take past its last frame when asked (continue it, keep going; models in hermoso_capabilities)."),
25822
25822
  interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
25823
25823
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds: one of the CHOSEN model’s listed durations in hermoso_capabilities (they differ per model, 4–8s on the short ones up to 30s). One continuous generation, so a longer ask than the model’s longest clip is REFUSED, nothing charged (never quietly truncated). Past ~15s NAME a long-clip model in `model`. For a longer spot use plan_ad with durationSeconds then render_ad, which stitches acts (on a 15s model, 40s = 15+15+10).'),
25824
25824
  aspectRatio: z.string().optional().describe("default '9:16'"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.535",
3
+ "version": "0.1.537",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1233 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",