hermoso 0.1.524 → 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.
- package/README.md +63 -1
- package/mcp/client.mjs +9 -1
- package/mcp/directory-neutral.mjs +105 -0
- package/mcp/http.mjs +6 -4
- package/mcp/media-ref.mjs +132 -0
- package/mcp/openai-extensions.mjs +1 -1
- package/mcp/tool-hints.mjs +23 -14
- package/mcp/tools.mjs +629 -110
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
|
-
# Hermoso: MCP, CLI
|
|
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
|
+
## 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
|
+
|
|
8
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
|
|
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(
|
|
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
|
-
|
|
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; };
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -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:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
? `
|
|
103
|
-
: `
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|