hermoso 0.1.523 → 0.1.525

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/README.md CHANGED
@@ -1,11 +1,73 @@
1
- # Hermoso: MCP, CLI & Skills
1
+ # Hermoso: AI marketing agent and AI ad generator for Claude, ChatGPT and Cursor (MCP server, CLI and skills)
2
2
 
3
3
  **The AI marketing agent with an AI ad generator built in**, run from **any AI agent**: Claude Code, Claude.ai, Cursor, Codex, or your own
4
4
  scripts. Research the ads already winning in a market, generate image & video ads (your real product
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
- **1177 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ ## What it does
9
+
10
+ - **Meta Ads MCP server.** Build, read and manage Facebook and Instagram campaigns, ad sets, ads, audiences and
11
+ insights from your agent, preview an ad per placement, and estimate reach before you spend. New campaigns are
12
+ created paused.
13
+ - **Google Ads MCP.** Search and Performance Max campaigns, ad groups, keywords and keyword ideas, assets,
14
+ conversion actions, budgets and reports.
15
+ - **TikTok Ads MCP**, and the same campaign tools for LinkedIn Ads, Reddit Ads, Pinterest Ads, Snapchat Ads,
16
+ Microsoft Advertising, X Ads, Apple Ads and ChatGPT Ads.
17
+ - **AI ad generator API.** Image, video and UGC avatar ads with your real product, copy and CTA, from `render_ad`,
18
+ `generate_image` and `generate_video`, the CLI, or the REST API at `/v1`
19
+ ([OpenAPI](https://app.hermoso.ai/openapi.json)).
20
+ - **Ad spy.** Search the Meta, Google and LinkedIn ad libraries and organic TikTok, Instagram, YouTube, Reddit and
21
+ Threads, and turn a competitor's longest-running ads into angles you can use.
22
+ - **Posting.** Post and schedule to your own social channels, then read each post's results. The channel list
23
+ is under *What it connects to* below.
24
+
25
+ ## Set up in Claude, ChatGPT or Cursor
26
+
27
+ **Claude (claude.ai, Claude Desktop):** Customize → Connectors → Add → *Add custom connector*, paste the URL
28
+ below, choose **Sign in now** under Authentication, then Add and Connect.
29
+
30
+ ```text
31
+ https://app.hermoso.ai/mcp
32
+ ```
33
+
34
+ **Claude Code:**
35
+
36
+ ```bash
37
+ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermoso@hermoso
38
+ ```
39
+
40
+ **ChatGPT:** turn on Developer mode once (Settings → Security and login), then Plugins → + → Create app →
41
+ Create MCP App. Paste `https://app.hermoso.ai/mcp` as the Server URL, choose OAuth, leave the advanced OAuth
42
+ settings empty, and press Create.
43
+
44
+ **Cursor:**
45
+
46
+ ```bash
47
+ npx skills add hermoso-ai/hermoso -a cursor && npx -y hermoso auth login
48
+ ```
49
+
50
+ Or add the MCP server to Cursor's `mcp.json` after `npx -y hermoso auth login`:
51
+
52
+ ```json
53
+ { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"] } } }
54
+ ```
55
+
56
+ Codex, Gemini CLI, Grok and the rest are in [Install in one command](#install-in-one-command).
57
+
58
+ ## Examples
59
+
60
+ Ask in your own words. The agent picks the tools:
61
+
62
+ - "Pull the longest-running Meta ads for allbirds.com and tell me the three angles they keep paying for."
63
+ - "Make a 15-second UGC video ad for our product in 9:16, with a voiceover and captions."
64
+ - "Make four static ads for the new collection, one per Instagram placement."
65
+ - "Build a Meta Ads campaign for this video with the daily budget I give you, leave it paused, and show me the
66
+ preview."
67
+ - "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
68
+ - "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
69
+
70
+ **1178 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
71
  catalog with exact per-render credit costs plus the full capability map.
10
72
 
11
73
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -242,7 +304,7 @@ block entirely if you signed in above; it is there for CI, where the process can
242
304
 
243
305
  Then ask your agent: *“Generate an image ad with Hermoso.”*
244
306
 
245
- ### What the 1177 tools cover
307
+ ### What the 1178 tools cover
246
308
 
247
309
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
248
310
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -270,6 +270,14 @@ export function isFrontDoorRefusal(status, contentType, { read = false } = {}) {
270
270
  return st === 429 || (read && (st === 502 || st === 503 || st === 504));
271
271
  }
272
272
  export const FRONT_DOOR_MESSAGE = 'Hermoso\u2019s server was briefly too busy to take the request and turned it away before the app saw it (HTTP 429). It was retried and the server was still busy. Nothing ran and nothing was charged: try again in a minute.';
273
+ // THE SENTENCE NAMES THE STATUS THAT CAME BACK (2026-11-06, error 78e0378413c4): a read that kept getting a plain-text 504
274
+ // was reported as "(HTTP 429)", so the board and the person both read a throttle that never happened. A 429 keeps the
275
+ // sentence above; a 502/503/504 (retried on a read only) says it was a gateway error and that a read changed nothing.
276
+ export function frontDoorMessage(status) {
277
+ const st = Number(status);
278
+ if (st === 429 || !(st >= 500)) return FRONT_DOOR_MESSAGE;
279
+ return `Hermoso\u2019s hosting gateway answered HTTP ${st} instead of the app, so no answer came back. It was retried and kept failing. It was a read, so nothing was changed and nothing was charged: try again in a minute.`;
280
+ }
273
281
  // A REFUSAL THAT NEVER CAME FROM HERMOSO IS SAID TO BE ONE (2026-10-18). A paying customer's agent gateway got HTTP 429 on
274
282
  // every call for a day; the CLI printed FRONT_DOOR_MESSAGE ("Hermoso's server was briefly too busy"), so he and his
275
283
  // agent asked us to unblock his IP. Cloudflare's own logs held ZERO requests from that IP: the 429 was answered by
@@ -295,7 +303,7 @@ async function throughFrontDoor(send, { read }) {
295
303
  await new Promise((r) => setTimeout(r, ms));
296
304
  res = await send();
297
305
  }
298
- if (isFrontDoorRefusal(res.status, res.headers?.get?.('content-type'), { read })) throw Object.assign(new Error(FRONT_DOOR_MESSAGE), { status: res.status, _frontDoor: true });
306
+ if (isFrontDoorRefusal(res.status, res.headers?.get?.('content-type'), { read })) throw Object.assign(new Error(frontDoorMessage(res.status)), { status: res.status, _frontDoor: true });
299
307
  return res;
300
308
  }
301
309
 
@@ -0,0 +1,105 @@
1
+ // DIRECTORY-SCOPE DESCRIPTION NEUTRALISER (2026-11-06).
2
+ // The Claude Connectors Directory asks every listing to acknowledge "Tool descriptions contain no instructions about
3
+ // model behavior, other tools, or external instruction sources". Measured on the live `?tools=directory` roster that
4
+ // day: 175 of 1,043 descriptions told the model to call or use another named tool and 91 used ALWAYS/NEVER/MUST.
5
+ // On the directory scope ONLY (defForDirectory in tools.mjs), every tool, title-free description and parameter
6
+ // description is passed through this: a phrase that merely names where an id comes from ("comment id from
7
+ // list_meta_comments") loses the tool name and keeps the fact; a clause or bracket that still names another tool, or
8
+ // that directs the model (ALWAYS/NEVER/MUST, "tell the user", "call this first", "Never publish …"), is dropped.
9
+ // Every other connection is untouched (defForDirectory returns the canonical def unchanged off the scope).
10
+ // Pinned by tools/directory-description-neutral-check.mjs, which runs the REAL scoped registration.
11
+ import { readdirSync, readFileSync } from 'node:fs';
12
+
13
+ const DIRECTIVE_CAPS = /\b(?:ALWAYS|NEVER|MUST|IMPORTANT|CRITICAL|YOU|YOUR)\b/;
14
+ const DIRECTIVE_WORDS = /\byou(?:'ll| will)? (?:must|should|need to|have to|cannot|can't|may not|are required)\b|\b(?:always|never) (?:call|use|run|ask|tell|show|pass|hand|relay|retry|invent|guess|say|claim|present|offer|check|confirm|re-?run)\b|\b(?:do not|don't|must not)\s+(?:tell|say|claim|promise|describe|report|present|call|use|re-?run|retry|guess|invent|ask|offer|assume|suggest|show|mention|loop|poll|pass|send)\b|\b(?:tell|ask|show|remind|warn|inform|confirm with|check with|hand)\s+the\s+user\b|\blet the user\b|\b(?:relay|quote) (?:it|this|that|the (?:refusal|error|reply))\b|\b(?:call|use|run|invoke|try)\s+(?:this|it)\s+(?:first|before|after|once|again|instead)\b|\bbefore (?:calling|using|you call|you use)\b|\bprefer (?:this|it)\b|\binstead of (?:calling|using)\b/i;
15
+ const DIRECTIVE_LEAD = /(?:^|[.!?]\s+|[;:—·(]\s*)(?:never|always|do not|don't|make sure|be sure|remember to|remember that)\b/i;
16
+ // true when the text directs the model's behaviour (not when it states a fact about the tool or the platform)
17
+ export const isModelDirective = (t) => { const s = String(t || '').trim(); return DIRECTIVE_CAPS.test(s) || DIRECTIVE_WORDS.test(s) || DIRECTIVE_LEAD.test(s); };
18
+
19
+ // Every tool name this package can register, read off the registration calls in its own modules. A static read,
20
+ // because the first scoped session of a process rewrites early tools before later ones are registered.
21
+ let _names = null;
22
+ export function directoryToolNames() {
23
+ if (_names) return _names;
24
+ const out = new Set();
25
+ try {
26
+ const dir = new URL('.', import.meta.url);
27
+ for (const f of readdirSync(dir)) {
28
+ if (!f.endsWith('.mjs')) continue;
29
+ const src = readFileSync(new URL(f, dir), 'utf8');
30
+ for (const m of src.matchAll(/registerTool\(\s*['"]([a-z0-9_]+)['"]/g)) out.add(m[1]);
31
+ }
32
+ } catch {}
33
+ _names = out;
34
+ return out;
35
+ }
36
+
37
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ const OPEN = '([{', CLOSE = ')]}';
39
+ // Clause boundaries at bracket depth 0: sentence ends, newlines, "; ", " — ", " · ", and a "(1) " enumeration.
40
+ function clauses(s) {
41
+ const out = []; let depth = 0, cur = '';
42
+ for (let i = 0; i < s.length; i++) {
43
+ const c = s[i];
44
+ if (OPEN.includes(c)) depth++; else if (CLOSE.includes(c)) depth = Math.max(0, depth - 1);
45
+ if (depth === 0) {
46
+ let sep = null;
47
+ if (c === '\n') sep = '\n';
48
+ else if ((c === '.' || c === '!' || c === '?') && /\s/.test(s[i + 1] || '') && !/\b(?:e\.g|i\.e|vs|etc|approx|incl|eg|ie)$/i.test(cur.slice(-6))) { sep = c + ' '; i++; while (s[i + 1] === ' ') i++; }
49
+ else if (c === ';' && s[i + 1] === ' ') { sep = '; '; i++; }
50
+ else if (s.startsWith(' — ', i)) { sep = ' — '; i += 2; }
51
+ else if (s.startsWith(' · ', i)) { sep = ' · '; i += 2; }
52
+ else if (c === ' ' && /^\(\d\) /.test(s.slice(i + 1, i + 5))) sep = ' ';
53
+ if (sep !== null) { out.push({ text: cur, sep }); cur = ''; continue; }
54
+ }
55
+ cur += c;
56
+ }
57
+ if (cur) out.push({ text: cur, sep: '' });
58
+ return out;
59
+ }
60
+ const RANK = (sep) => (/^[.!?] ?$/.test(sep) ? 5 : sep === '\n' ? 4 : sep === '; ' ? 3 : sep === ' — ' ? 2 : sep === ' · ' ? 1 : 0);
61
+ const tidy = (t) => t.replace(/\(\s*[,;:]?\s*\)/g, '').replace(/\[\s*\]/g, '').replace(/\s+([,.;:)])/g, '$1').replace(/([(])\s+/g, '$1')
62
+ .replace(/,\s*,/g, ',').replace(/[ \t]{2,}/g, ' ').replace(/^[\s,;:—·]+/, '').replace(/[\s,;:—·]+$/, '').trim();
63
+
64
+ // `opts.directives:false` (the focused ChatGPT apps, 2026-11-06) strips only mentions of the named tools and leaves
65
+ // directive wording alone: those apps keep the instructions every ChatGPT tool already passed review with.
66
+ export function makeDirectoryNeutral(names = directoryToolNames(), opts = {}) {
67
+ const list = [...names].sort((a, b) => b.length - a.length).map(esc);
68
+ if (!list.length) return { neutral: (t) => t, mentions: () => [] };
69
+ const N = '(?:' + list.join('|') + ')';
70
+ const nameRe = new RegExp('\\b' + N + '\\b', 'g');
71
+ const mentions = (t, self) => [...String(t || '').matchAll(nameRe)].map((m) => m[0]).filter((n) => n !== self);
72
+ const directives = opts.directives !== false;
73
+ const offends = (t, self) => (directives && isModelDirective(t)) || mentions(t, self).length > 0;
74
+ const MASKS = [
75
+ [/\ban? upload_file (?:URL|url|link)s?\b/g, 'an uploaded file URL'],
76
+ [/\bupload_file (?:URL|url|link)s?\b/g, 'uploaded file URLs'],
77
+ [new RegExp('\\s*,?\\s*\\b(?:as |that |which )?(?:the )?' + N + '(?:\\s*(?:/|or|and)\\s*' + N + ')* (?:returned|returns|gave|gives|lists|listed|reports|created|made|found)(?: it| them| one)?\\b', 'g'), ''],
78
+ [new RegExp('\\s*,?\\s*\\b(?:as returned by|returned by|listed by|from the result of|from|by|via|through|using|with|on|e\\.g\\.|eg|like|see|or|and|after)\\s+(?:the\\s+)?(?:result of\\s+|output of\\s+)?' + N + '(?:\\s+(?:result|output|response))?(?:\\s*(?:/|,|or|and)\\s*' + N + ')*\\b', 'g'), ''],
79
+ ];
80
+ // `opts.masks:false` drops a clause that names a tool rather than masking the name out of it: on a focused app the
81
+ // masked remainder could read as a claim about THIS tool ("Make a new one" was the create tool's clause).
82
+ const maskNames = opts.masks === false ? (t) => t : (t) => { for (const [re, to] of MASKS) t = t.replace(re, to); return tidy(t); };
83
+ const stripBrackets = (t, self) => { let prev; do { prev = t; t = t.replace(/\s?[([]([^()[\]]*)[)\]]/g, (m, inner) => (offends(inner, self) ? '' : m)); } while (t !== prev); return t; };
84
+ function neutral(text, self, fallback) {
85
+ if (typeof text !== 'string' || !text || !offends(text, self)) return text;
86
+ const parts = clauses(stripBrackets(maskNames(text), self));
87
+ let out = '', pending = null;
88
+ for (const p of parts) {
89
+ if (!p.text.trim() || offends(p.text, self)) { if (pending !== null && RANK(p.sep) > RANK(pending)) pending = p.sep; continue; }
90
+ if (pending !== null) out += pending || ' ';
91
+ out += p.text; pending = p.sep;
92
+ }
93
+ if (pending && /^[.!?]/.test(pending)) out += pending.trim();
94
+ out = tidy(out.replace(/([.!?]\s+|\n|^)([a-z])/g, (m, a, b) => a + b.toUpperCase()));
95
+ if (out && /[.!?]$/.test(text.trim()) && !/[.!?)]$/.test(out)) out += '.';
96
+ if (!out) for (const f of [].concat(fallback ?? [])) if (typeof f === 'string' && f && !offends(f, self)) { out = f; break; }
97
+ return out;
98
+ }
99
+ return { neutral, mentions, offends };
100
+ }
101
+
102
+ let _default = null;
103
+ export const directoryNeutral = () => (_default ||= makeDirectoryNeutral());
104
+ // a parameter whose every clause went: fall back to its own name in words ("commentId" -> "comment id"), else the key itself
105
+ export const paramFallback = (key) => [String(key).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ').toLowerCase(), String(key)];
package/mcp/http.mjs CHANGED
@@ -377,9 +377,11 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
377
377
  // was the right instinct for a scope the SERVER changes silently, and the wrong one for a change the CLIENT
378
378
  // asked for and is told about.
379
379
  function scopeFor(req, res) {
380
- const { groups, error, directory } = parseToolScope(req.query?.tools ?? req.headers['x-hermoso-tools']);
380
+ const { groups, error, directory, app } = parseToolScope(req.query?.tools ?? req.headers['x-hermoso-tools']);
381
381
  if (error) { res.status(400).json({ jsonrpc: '2.0', error: { code: -32602, message: error }, id: null }); return false; }
382
- return { groups, directory: directory || false }; // `directory` = the Claude Connectors Directory cage (see DIRECTORY_GROUPS in tools.mjs)
382
+ // `app` = one of the focused Hermoso apps (APP_SCOPE_TOKENS in tools.mjs); set ONLY by those tokens, so it is absent
383
+ // on every other connection and nothing below changes for them.
384
+ return app ? { groups, directory: directory || false, app } : { groups, directory: directory || false }; // `directory` = the Claude Connectors Directory cage (see DIRECTORY_GROUPS in tools.mjs)
383
385
  }
384
386
  // `?src=<surface>` on the published URL (registry / glama / smithery / cursor / readme …) — attribution only. It may
385
387
  // not change auth, scope or spend, and an unrecognisable value is simply dropped. The OAuth resource metadata is
@@ -412,7 +414,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
412
414
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
413
415
  // A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
414
416
  // chatgptRosterMode in tools.mjs: ChatGPT cannot reach an unlisted tool).
415
- registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
417
+ registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, app: scope?.app || null, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
416
418
  // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
417
419
  // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
418
420
  // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
@@ -527,7 +529,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
527
529
  // ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
528
530
  const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
529
531
  const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
530
- registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost, hosted: true, ext: extAsked(req), client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
532
+ registerTools(server, { only: scope.groups, directory: scope.directory || false, app: scope.app || null, connectors, widgetHost, hosted: true, ext: extAsked(req), client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
531
533
  const transport = new StreamableHTTPServerTransport({
532
534
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
533
535
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -0,0 +1,132 @@
1
+ // ── A MEDIA REFERENCE IS A URL, NEVER A PATH ON THE CALLER'S MACHINE (2026-11-05) ──────────────────────────────────
2
+ // ONE rule for every tool and route that takes a video, image or audio by reference. ChatGPT hands its own sandbox
3
+ // paths straight to our tools: analyze_video got "/mnt/data/IMG_6358(1).mp4", the server read it as one of OUR served
4
+ // paths, and the caller was told "ENOENT … open '/app/public/mnt/data/IMG_6358(1).mp4'" as a 500 (fp bf206cd60b30);
5
+ // a subtitles JOB was queued with the same kind of path and died the same way inside the worker. An earlier fix only
6
+ // covered upload_meta_asset. This module is the shared decision, PURE and import-free so the server (route
7
+ // middleware, the job enqueue, the file sinks), both MCP twins (byte-identical: cli/mcp/media-ref.mjs) and
8
+ // tools/client-local-media-check.mjs all run the same code.
9
+ //
10
+ // What it decides, per string:
11
+ // • `url` — http(s)://… (fetched later through the SSRF guards, not here)
12
+ // • `data` — a data: URI
13
+ // • `served` — a path WE serve: its first segment is a real entry of public/ (the server passes that list), e.g.
14
+ // /generated/…, /music/…. Normalised and checked for `..` first.
15
+ // • `local` — a file on the CALLER's side: /mnt/data/…, file://…, sandbox:/…, blob:…, C:\…, \\host\share, ~/…,
16
+ // ./x, or a bare absolute path that is not ours and looks like a file (an extension, or an OS/sandbox
17
+ // root such as /tmp, /home, /Users, /var, /private). Refused with the one sentence below.
18
+ // • `traversal` — a `..` segment (raw, %2e-encoded, or with backslashes) or a NUL. Never a legitimate media input.
19
+ // • `other` — anything else (a Library id like L0, an asset id, attachment://1, a bare word, '/settings'): passed
20
+ // through untouched for the route to judge, so this rule can never refuse something it does not know.
21
+ export const LOCAL_FILE_MESSAGE = "That file is on your side (e.g. ChatGPT's sandbox). Upload it first with upload_file (or attach it), then pass the URL it returns.";
22
+ // upload_file's `path` source only works on a local (stdio) install; on a hosted connector the bytes must travel as
23
+ // `dataUri` (or the file must already be at a public `url`), so the sentence names the source that works everywhere.
24
+ export const LOCAL_FILE_HOW = 'upload_file takes the file itself as `dataUri` (base64) or a public `url`.';
25
+ export const MEDIA_PARAM_NOTE = 'a URL, not a local file path; use upload_file for local files';
26
+ export const TRAVERSAL_MESSAGE = 'That path is not a file Hermoso serves. Pass a public https URL, or the URL upload_file returns.';
27
+
28
+ // The param NAMES that carry a media reference. Anchored at the END so fileName / imageId / mediaType / videoTitle
29
+ // never match; `path` is deliberately absent (upload_file's own stdio `path`, Drive folder paths, /api/sc/run's __path).
30
+ export const MEDIA_KEY_RE = /(?:^|[a-z0-9_])(?:url|urls|uri|src|video|videos|image|images|img|audio|media|clip|clips|photo|photos|picture|pictures|logo|logos|thumb|thumbnail|thumbnails|cover|poster|asset|assets|music|still|stills|frame|frames|file|files|ref|refs|slide|slides|track|voiceover|source|sources|attachment|attachments|avatar|portrait|background|reference|references)$/i;
31
+ export const isMediaKey = (k) => typeof k === 'string' && MEDIA_KEY_RE.test(k);
32
+
33
+ const LOCAL_SCHEME_RE = /^(sandbox|file|blob|content|ph|assets-library):/i;
34
+ const WINDOWS_RE = /^[a-z]:[\\/]/i;
35
+ const UNC_RE = /^\\\\[^\\]/;
36
+ const HOME_RE = /^~(?:[\\/]|$)/;
37
+ // Roots that are a machine's filesystem, never a URL path anyone serves media from.
38
+ const LOCAL_ROOTS = new Set(['mnt', 'tmp', 'home', 'users', 'var', 'private', 'root', 'sdcard', 'storage', 'volumes', 'etc', 'proc', 'dev', 'sys', 'opt', 'srv', 'library', 'applications', 'workspace', 'workspaces']);
39
+ const FILE_EXT_RE = /\.[a-z0-9]{2,5}$/i;
40
+
41
+ const decodeOnce = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
42
+ const hasDotDot = (s) => s.split(/[\\/]+/).some((seg) => seg === '..');
43
+
44
+ /** Classify one string. `served` is the list of first path segments we really serve; null = unknown (see below). */
45
+ export function mediaRefKind(raw, { served = ['generated'] } = {}) {
46
+ if (typeof raw !== 'string') return 'other';
47
+ const s = raw.trim();
48
+ if (!s) return 'other';
49
+ if (/^https?:\/\//i.test(s)) return 'url';
50
+ if (/^data:/i.test(s)) return 'data';
51
+ if (LOCAL_SCHEME_RE.test(s) || WINDOWS_RE.test(s) || UNC_RE.test(s) || HOME_RE.test(s)) return 'local';
52
+ const pathPart = s.split(/[?#]/)[0];
53
+ const dec = decodeOnce(pathPart);
54
+ if (dec.includes('\0') || hasDotDot(pathPart) || hasDotDot(dec)) return 'traversal';
55
+ if (/^\.{1,2}[\\/]/.test(s)) return 'local';
56
+ if (s.startsWith('/') && !s.startsWith('//')) {
57
+ const first = (dec.replace(/^\/+/, '').split(/[\\/]/)[0] || '').toLowerCase();
58
+ // `served: null` = the caller does not know what the server serves (an MCP process): only an OS/sandbox root
59
+ // counts as local then, and the server, which does know, judges the rest.
60
+ if (served == null) return LOCAL_ROOTS.has(first) ? 'local' : 'other';
61
+ const ours = new Set(served.map((x) => String(x).toLowerCase()));
62
+ if (first && ours.has(first)) return 'served';
63
+ if (LOCAL_ROOTS.has(first) || FILE_EXT_RE.test(dec)) return 'local';
64
+ }
65
+ return 'other';
66
+ }
67
+
68
+ /** The refusal for one string, or null when it is fine to pass on. */
69
+ export function mediaRefRefusal(raw, opts = {}) {
70
+ const kind = mediaRefKind(raw, opts);
71
+ if (kind !== 'local' && kind !== 'traversal') return null;
72
+ const shown = String(raw).trim().slice(0, 160);
73
+ const message = kind === 'local'
74
+ ? `${LOCAL_FILE_MESSAGE} ${LOCAL_FILE_HOW} (\`${shown}\` is a path on your machine or sandbox, which Hermoso cannot open. Nothing was queued and nothing was charged.)`
75
+ : `${TRAVERSAL_MESSAGE} (\`${shown}\`. Nothing was queued and nothing was charged.)`;
76
+ return { kind, value: shown, message, code: kind === 'local' ? 'local_file_path' : 'bad_media_path' };
77
+ }
78
+
79
+ /**
80
+ * Walk a request body / query / tool args and return the FIRST media-keyed string that is a client-local path or a
81
+ * traversal, as {key, path, kind, value, message, code} — or null. Only values under a media-named key are judged
82
+ * (a caption that happens to mention C:\ is the user's text, not a reference). Bounded in depth and size.
83
+ */
84
+ export function findClientLocalMedia(value, opts = {}) {
85
+ let budget = 4000;
86
+ const walk = (v, key, trail, depth, media) => {
87
+ if (budget-- <= 0 || depth > 8 || v == null) return null;
88
+ if (typeof v === 'string') {
89
+ if (!media) return null;
90
+ const r = mediaRefRefusal(v, opts);
91
+ return r ? { key, path: trail, ...r } : null;
92
+ }
93
+ if (Array.isArray(v)) {
94
+ for (let i = 0; i < v.length && i < 500; i++) { const h = walk(v[i], key, `${trail}[${i}]`, depth + 1, media); if (h) return h; }
95
+ return null;
96
+ }
97
+ if (typeof v === 'object') {
98
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(v)) return null;
99
+ for (const k of Object.keys(v)) { const h = walk(v[k], k, trail ? `${trail}.${k}` : k, depth + 1, isMediaKey(k)); if (h) return h; }
100
+ }
101
+ return null;
102
+ };
103
+ return walk(value, '', '', 0, false);
104
+ }
105
+
106
+ /**
107
+ * A file object ChatGPT passes for an attached file ({download_url, file_id, mime_type?, file_name?}, OpenAI Apps SDK
108
+ * "openai/fileParams") sitting under a media-named key is replaced IN PLACE by its download_url, so a raw API caller that
109
+ * forwards ChatGPT's argument as-is reaches the same https fetch as any other link. Returns how many were replaced.
110
+ */
111
+ export const isOpenAIFileRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.download_url === 'string' && typeof v.file_id === 'string';
112
+ export function flattenOpenAIFiles(value) {
113
+ let n = 0, budget = 4000;
114
+ const walk = (v, depth) => {
115
+ if (budget-- <= 0 || depth > 8 || !v || typeof v !== 'object') return;
116
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(v)) return;
117
+ if (Array.isArray(v)) { for (let i = 0; i < v.length && i < 500; i++) { if (isOpenAIFileRef(v[i])) { v[i] = v[i].download_url; n++; } else walk(v[i], depth + 1); } return; }
118
+ for (const k of Object.keys(v)) {
119
+ const x = v[k];
120
+ if (isMediaKey(k) && isOpenAIFileRef(x)) { v[k] = x.download_url; n++; }
121
+ else if (isMediaKey(k) && Array.isArray(x)) { for (let i = 0; i < x.length && i < 500; i++) { if (isOpenAIFileRef(x[i])) { x[i] = x[i].download_url; n++; } else walk(x[i], depth + 2); } }
122
+ else walk(x, depth + 1);
123
+ }
124
+ };
125
+ walk(value, 0);
126
+ return n;
127
+ }
128
+
129
+ /** The Error a caller throws for a hit: status 400 + _userInput, so the ledger files it as the caller's. */
130
+ export function clientLocalMediaError(hit) {
131
+ return Object.assign(new Error(`\`${hit.path || hit.key}\`: ${hit.message}`), { status: 400, _userInput: true, code: hit.code, param: hit.path || hit.key });
132
+ }
@@ -1268,7 +1268,7 @@ export const neutralNote = (t) => {
1268
1268
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1269
1269
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1270
1270
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1271
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts']);
1271
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts']);
1272
1272
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1273
1273
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1274
1274
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -77,34 +77,43 @@ export function videoChoiceDraftCall(tool, d) {
77
77
  if (t === 'plan_ad' || t === 'clone_video') return `${t}({…the same arguments, format: 'video', draft: {model: '${m}', durationSeconds: ${s}}})`;
78
78
  return `${t || 'the same call'}({…the same arguments, model: '${m}', durationSeconds: ${s}})`;
79
79
  }
80
+ // THE WALL SELLS CREDITS FIRST (owner 2026-11-06, fi:3d3bd64bedef: "Shouldnt it just tells them they can't afford it and
81
+ // then try to upsell the plans/credits?"). The agent says plainly that the account does not have enough credits and offers
82
+ // credits and plans; the image and the light draft stay listed LAST, for a user who asks for something cheaper themselves,
83
+ // never as the agent's own pick. A video-plus-static ask is priced whole (`needCredits`, `alsoImage`) and neither half is made.
80
84
  export function videoChoiceHints(tool, choice, opts = {}) {
81
85
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
82
- const out = [
83
- { do: videoChoiceImageCall(tool), why: `the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}; the video needs ~${c.videoCredits ?? '?'}` },
84
- opts.widgetHost
85
- ? { do: `tell the user the video needs ${c.short ?? '?'} more credits than the account has; they manage credits in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }
86
- : { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short of the video; a pack or a plan covers it, then the same call plans the video` },
87
- ];
86
+ const out = opts.widgetHost
87
+ ? [{ do: `tell the user they do not have enough credits: it needs ${c.short ?? '?'} more than the account has; they add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }]
88
+ : [
89
+ { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short; a pack covers it, then the same call makes it as asked` },
90
+ { do: 'upgrade_plan({})', why: 'a monthly plan (Starter, Pro, Studio) includes monthly credits and no markup on models' },
91
+ ];
88
92
  if (c.welcomeClaim && c.welcomeClaim.url) out.unshift(welcomeClaimHint(c.welcomeClaim));
89
- if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance; render the premium version after topping up` });
93
+ out.push({ do: videoChoiceImageCall(tool), why: `only if the user asks for something cheaper instead: the image version is ~${o.image?.credits ?? '?'} credits against a balance of ${c.balance ?? '?'}` });
94
+ if (o.draft && o.draft.model) out.push({ do: videoChoiceDraftCall(tool, o.draft), why: `only if the user asks for something cheaper instead: a light draft on ${o.draft.label || o.draft.model} (${o.draft.durationSeconds}s) is ~${(Number(o.draft.credits) || 0) + (Number(o.draft.planCredits) || 0)} credits and fits the balance` });
90
95
  return out;
91
96
  }
92
97
  export function videoChoiceText(tool, choice, opts = {}) {
93
98
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
94
99
  const d = o.draft && o.draft.model ? o.draft : null;
100
+ const need = Number(c.needCredits) > 0 ? Number(c.needCredits) : (c.videoCredits ?? '?');
101
+ const what = c.alsoImage
102
+ ? `The ${c.seconds ? `${c.seconds}s ` : ''}video and the static together`
103
+ : `${c.seconds ? `A ${c.seconds}s video${c.lengthBasis === 'source' ? ' (the original’s length)' : ''}` : 'This video'}`;
95
104
  const lines = [
96
105
  // A clone names which length it priced (lengthBasis, 2026-10-07) and any post lookup reading that length cost.
97
- `${c.seconds ? `A ${c.seconds}s video${c.lengthBasis === 'source' ? ' (the original’s length)' : ''}` : 'This video'} would cost about ${c.videoCredits ?? '?'} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short).${c.lengthBasis === 'estimate' && c.seconds ? ` That price ASSUMES ${c.seconds}s: the original’s length could not be read before planning, and the clone will match its real length, so say it is an estimate.` : ''} ${Number(c.lookupCredits) > 0 ? `Nothing was planned or rendered; reading the original’s length cost ${Math.round(Number(c.lookupCredits))} credit${Math.round(Number(c.lookupCredits)) === 1 ? '' : 's'} (the post lookup the clone reuses).` : 'Nothing was planned, rendered or charged.'} Tell the user and let them choose — never switch the format for them:`,
98
- ` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
106
+ `Not enough credits for this. ${what} would cost about ${need} credits and the account has ${c.balance ?? '?'} (${c.short ?? '?'} short).${c.lengthBasis === 'estimate' && c.seconds ? ` That price ASSUMES ${c.seconds}s: the original’s length could not be read before planning, and the clone will match its real length, so say it is an estimate.` : ''} ${Number(c.lookupCredits) > 0 ? `Nothing was planned or rendered; reading the original’s length cost ${Math.round(Number(c.lookupCredits))} credit${Math.round(Number(c.lookupCredits)) === 1 ? '' : 's'} (the post lookup the clone reuses).` : 'Nothing was planned, rendered or charged.'} Tell the user plainly they do not have enough credits for it and offer credits or a plan; never switch the format or make part of the ask for them:`,
99
107
  // A WIDGET HOST (ChatGPT) GETS NO PURCHASE ROUTE (2026-09-30): its plugin guidelines let a plugin explain that a
100
108
  // feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
101
- opts.widgetHost
102
- ? ` 2. The user can add credits in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes the video as asked. Nothing can be bought from here.`
103
- : ` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
109
+ ...(opts.widgetHost
110
+ ? [` 1. The user can add credits or pick a plan in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes it as asked. Nothing can be bought from here.`]
111
+ : [` 1. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and it goes ahead as asked.`,
112
+ ` 2. Or a monthly plan: upgrade_plan({}) lists them; Starter, Pro and Studio include monthly credits and no markup on models, so credits go further.`]),
104
113
  ];
105
114
  if (c.welcomeClaim && c.welcomeClaim.url) lines.splice(1, 0, ` First: ${welcomeClaimSentence(c.welcomeClaim)}`);
106
- if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}.${opts.widgetHost ? '' : ' Premium models once they top up.'}`);
107
- else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
115
+ lines.push(` Only if the user asks for something cheaper instead: make it as an image (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`);
116
+ if (d) lines.push(` Or, again only if they ask, render the video as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}.`);
108
117
  return lines.join('\n');
109
118
  }
110
119