hermoso 0.1.142 → 0.1.152

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
@@ -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
- **681 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **709 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,
@@ -22,6 +22,47 @@ ad-build tool accepts); build and read campaigns on your own ad accounts with yo
22
22
  competitors with no brand drafted and no channel connected; or generate a file with nothing connected at all and
23
23
  just download it. Use the one piece you need, or all of it together.
24
24
 
25
+ ## Which surface should your agent use?
26
+
27
+ Two shapes, and the right one is decided by **what your client can do**, not by which we prefer.
28
+
29
+ | Your client | Use | Why |
30
+ | --- | --- | --- |
31
+ | **Runs in a browser** — Claude.ai, ChatGPT, Claude Desktop | the hosted connector `https://app.hermoso.ai/mcp` | It cannot spawn a local process, so a URL is the only shape it has. Nothing to install, no key to paste, and the full toolset arrives with your saved brand context. This is the right answer for these clients, not a lesser one. |
32
+ | **Can run a shell** — Claude Code, Cursor, Codex, Cline, OpenClaw, Hermes, your own scripts | the CLI, `npm install -g hermoso` | A tool manifest is loaded into every session whether or not a tool is called. A shell command costs nothing until it runs, and it reaches **every** tool rather than the default roster. |
33
+
34
+ **The measured difference** (2026-08-24, counted as real tool definitions rather than estimated from bytes):
35
+
36
+ | | tools in range | loaded per session |
37
+ | --- | --- | --- |
38
+ | Hosted connector, default roster | 291 | **166,043 tokens** |
39
+ | Hosted connector, `?tools=all` | 703 | **456,392 tokens** |
40
+ | stdio server (`npx -y hermoso mcp`) | 269 | **153,257 tokens** |
41
+ | **CLI** | **all 681** | **0** |
42
+
43
+ The CLI answers the same questions on demand instead, and only when asked:
44
+
45
+ ```bash
46
+ npx -y hermoso tools --search reddit # every matching tool, name + one line 2,459 tokens
47
+ npx -y hermoso tools plan_ad # one tool's full argument schema 633 tokens
48
+ npx -y hermoso call plan_ad --json '{"product":"…"}' # run it
49
+ ```
50
+
51
+ So a terminal agent reaches its first call in roughly **3.4K tokens with the whole roster in range**, against
52
+ **153K for a fraction of it**. `tools` and `tools <name>` read a registry bundled in the package — no key, no
53
+ network, no sign-in — so an agent can browse the entire product before anyone signs in. Only `call` spends, and
54
+ only that needs `hermoso auth login` once.
55
+
56
+ **Both at once is fine, and is what we suggest for Claude Code.** One `hermoso auth login` covers the CLI *and*
57
+ lets `claude mcp add hermoso -- npx -y hermoso mcp` pick the key up with no `env` block, so the agent can reach for
58
+ a native tool when it wants structured results and shell out when it wants breadth. If you only want one, take the
59
+ CLI: it covers strictly more.
60
+
61
+ **When the connector is still the better trade on a shell-capable client:** a session that is going to make many
62
+ calls into one area. `enable_tools({groups:['ads']})` turns campaign management on in a single free call and the
63
+ tools are then native — no shell quoting, structured results. One shell round trip beats loading a 221K-token
64
+ group for a single tool; the reverse is true once a session settles into that area.
65
+
25
66
  ## Instant: the hosted Claude.ai connector
26
67
 
27
68
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
@@ -77,7 +118,7 @@ block entirely if you signed in above; it is there for CI, where the process can
77
118
 
78
119
  Then ask your agent: *“Generate an image ad with Hermoso.”*
79
120
 
80
- ### What the 681 tools cover
121
+ ### What the 709 tools cover
81
122
 
82
123
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
83
124
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -180,6 +221,21 @@ hermoso research "Liquid Death’s longest-running ads"
180
221
 
181
222
  Add `--json` to any command for machine output.
182
223
 
224
+ **Those shortcuts are the common path, not the limit.** Every tool the MCP server has is reachable here too,
225
+ including the ad-campaign and analytics groups a connector leaves out of its default roster:
226
+
227
+ ```bash
228
+ hermoso tools # every tool, grouped, name + one line
229
+ hermoso tools --group ads --search reddit # narrow it
230
+ hermoso tools create_meta_campaign # that tool's full argument schema
231
+ hermoso call create_meta_campaign --json '{"name":"…"}' # run it
232
+ hermoso create_meta_campaign --name "…" # same thing, shorter
233
+ ```
234
+
235
+ `call` goes through the same handler, the same argument validation and the same confirm/spend gates the MCP
236
+ server uses — there is no second implementation to drift. `tools` and `tools <name>` read a registry bundled in
237
+ the package, so they need no key, no network and no sign-in.
238
+
183
239
  ## 3. Claude skills — slash commands that wrap the CLI
184
240
 
185
241
  `skills/` holds four installable skills: `hermoso-generate`, `hermoso-ad-from-brand`,
@@ -198,7 +254,7 @@ Then invoke `/hermoso-ad-from-brand an ad for yourbrand.com — our hero product
198
254
  | `HERMOSO_API_BASE` | The Hermoso API origin (default `https://app.hermoso.ai` — set `http://localhost:3000` if you run the app yourself) |
199
255
  | `HERMOSO_TOKEN` | Bearer agent key (`hmk_…`) — required against the hosted app |
200
256
  | `HERMOSO_PROFILE` | Brand-workspace id, for accounts with multiple brand profiles |
201
- | `HERMOSO_OWNER` | Only for a brand **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** — a brand's short slug is refused. Run `list_brands` (or `hermoso brands`) to print both values for every workspace you can enter. The server re-authorizes the pair on every request, so a wrong value is refused, never trusted. |
257
+ | `HERMOSO_OWNER` | Only for a brand **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** — a brand's short slug is refused. Run `list_brands` (or `hermoso list_brands` from the CLI) to print both values for every workspace you can enter. The server re-authorizes the pair on every request, so a wrong value is refused, never trusted. |
202
258
 
203
259
  `mcp/http.mjs` is the hosted remote-connector transport (paste-a-URL into Claude.ai → Connectors). It ships in
204
260
  this repo for transparency and refuses to mount without authenticated identity — no anonymous spend, ever.
package/bin/hermoso.mjs CHANGED
@@ -22,6 +22,11 @@
22
22
  import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
23
23
  import os from 'node:os';
24
24
  import path from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ // READ THE REAL VERSION. This line printed a hard-coded "1.0.0" while package.json shipped 0.1.151 — so the
27
+ // one command a user runs to find out what they have installed reported a version that has never existed.
28
+ // Read from package.json so it cannot drift again; a bump is already a release step, and this now follows it.
29
+ const CLI_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version; } catch { return '0.0.0'; } })();
25
30
 
26
31
  const CONFIG_DIR = path.join(os.homedir(), '.hermoso');
27
32
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -78,7 +83,7 @@ async function main() {
78
83
  return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
79
84
  }
80
85
  if (group === 'version' || flags.version) {
81
- console.log(`hermoso-cli 1.0.0 · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : 'no token — run: hermoso auth login --token <key>'}`);
86
+ console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : 'no token — run: hermoso auth login --token <key>'}`);
82
87
  return;
83
88
  }
84
89
 
package/mcp/client.mjs CHANGED
@@ -240,11 +240,71 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
240
240
  }
241
241
  }
242
242
 
243
- // Read a local image path data URI (so --ref local files force Nano-Banana compositing); pass http(s) URLs through.
243
+ // ── A REFERENCE THAT IS NOT A URL IS A LOCAL PATH, AND ON THE HOSTED CONNECTOR THERE IS NO SUCH THING ──────────
244
+ // ChatGPT renders an image in its OWN sandbox and hands that path straight to generate_video. We opened it and
245
+ // leaked `ENOENT: no such file or directory, open 'sandbox:/mnt/data/…'`, a Node filesystem error sitting where an
246
+ // instruction belongs (reproduced on the live hosted connector 2026-08-24). `upload_file` has answered this
247
+ // correctly since it was written; this says the same thing at the ONE seam every render tool with a file-ish input
248
+ // already passes through (generate_image refImages, make_thumbnail faceImages + logo, generate_video refImage,
249
+ // generate_avatar image), so a render tool added tomorrow inherits the refusal instead of having to remember it.
250
+ // TWO DISTINCT REASONS, deliberately not collapsed into one:
251
+ // • A NON-FILE URI SCHEME (sandbox:, file:, blob:, gs:, s3:) is never a readable path on ANY surface, so it is
252
+ // refused on stdio too. `sandbox:/mnt/data/x.png` does not fail because we are hosted; it fails because that
253
+ // string was never a path.
254
+ // • A BARE PATH while hosted names the caller's disk, which we cannot see. Refusing is not merely the politer
255
+ // answer: hosted MCP tool code runs INSIDE our own container, so readFile() there reads OUR filesystem, and a
256
+ // path that happened to exist would be base64'd into a data: URI and shipped to a video model.
257
+ // A Windows drive letter (`C:\\shots\\hero.png`) looks exactly like a one-character scheme, so a scheme needs two or
258
+ // more characters to count. That path must still open on stdio.
259
+ const REF_SCHEME_NOTE = {
260
+ sandbox: 'That is a path inside your own sandbox, which only your process can read.',
261
+ file: 'A file:// URL is not something I can open.',
262
+ blob: 'A blob: URL only exists inside the browser tab that created it.',
263
+ };
264
+ // VERIFIED ON THE LIVE HOSTED CONNECTOR 2026-08-24 rather than assumed: upload_file with `dataUri` returned a real
265
+ // assets.hermoso.ai URL, and upload_file with `path` refused with its own message. So "call upload_file first" on
266
+ // its own would walk the caller into the same wall. The SOURCE is the part that has to be named.
267
+ const REF_WAYS_OUT = 'Send the bytes instead of a path. Pass the image as a `data:` URI (data:image/png;base64,…), or put it at a public https URL and pass that. Both work here. If you want a reusable Hermoso URL first, call upload_file with `dataUri` or `url` (its `path` source is refused on the hosted connector for this same reason).';
268
+
269
+ // PURE, so tools/local-ref-refusal-check.mjs runs the real decision instead of reading it.
270
+ export function localRefVerdict(src, { remote = false } = {}) {
271
+ const s = String(src ?? '').trim();
272
+ if (!s) return { action: 'skip' };
273
+ if (/^(https?:|data:)/i.test(s)) return { action: 'pass', value: s };
274
+ const m = /^([a-z][a-z0-9+.-]+):/i.exec(s);
275
+ if (m) return { action: 'refuse', reason: 'scheme', scheme: m[1].toLowerCase() };
276
+ if (remote) return { action: 'refuse', reason: 'hosted' };
277
+ return { action: 'read' };
278
+ }
279
+
280
+ export function localRefMessage(src, verdict, { remote = false } = {}) {
281
+ const s = String(src ?? '').trim().slice(0, 120);
282
+ const head = verdict.reason === 'scheme'
283
+ ? `\`${s}\` is a ${verdict.scheme}: URI, not a file I can open.${REF_SCHEME_NOTE[verdict.scheme] ? ` ${REF_SCHEME_NOTE[verdict.scheme]}` : ''}`
284
+ : `\`${s}\` is a local file path, and on the hosted connector I cannot see your disk.`;
285
+ const tail = verdict.reason === 'scheme' && !remote ? ' If you meant a file on this machine, pass its real filesystem path.' : '';
286
+ return `${head} Nothing was rendered and nothing was charged.\n\n${REF_WAYS_OUT}${tail}`;
287
+ }
288
+
289
+ // `status: 400` is what files this as `user` on the error board. /api/errors/report rebuilds the error from
290
+ // ALLOWLISTED fields and deliberately will not read a `_userInput` marker off an untrusted reporter (a caller could
291
+ // otherwise file its own crash under "the user's fault"), so the status is the honest channel: 400 is in the
292
+ // ledger's AUTHORED_USER_STATUSES. `_userInput` is set too, for the in-process classifier, and it is the accurate
293
+ // marker of the two here since this is a bad ARGUMENT rather than a capability we decline to offer.
294
+ const refRefusal = (msg) => Object.assign(new Error(msg), { status: 400, _userInput: true });
295
+
296
+ // Read a local image path → data URI (so --ref local files force Nano-Banana compositing); pass http(s) and data
297
+ // URLs through; refuse anything we cannot open, with the way out named.
244
298
  export async function toRef(srcOrPath) {
245
- if (!srcOrPath) return null;
246
- if (/^(https?:|data:)/.test(srcOrPath)) return srcOrPath;
247
- const buf = await readFile(srcOrPath);
299
+ const remote = isRemote();
300
+ const v = localRefVerdict(srcOrPath, { remote });
301
+ if (v.action === 'skip') return null;
302
+ if (v.action === 'pass') return v.value;
303
+ if (v.action === 'refuse') throw refRefusal(localRefMessage(srcOrPath, v, { remote }));
304
+ let buf;
305
+ // Even on stdio a raw ENOENT is a filesystem error where an instruction belongs.
306
+ try { buf = await readFile(srcOrPath); }
307
+ catch (e) { throw refRefusal(`I couldn't open \`${String(srcOrPath).trim().slice(0, 120)}\` (${e?.code || 'the read failed'}). Nothing was rendered and nothing was charged.\n\nCheck the path is right, or send the bytes instead: ${REF_WAYS_OUT}`); }
248
308
  const ext = path.extname(srcOrPath).toLowerCase().replace('.', '');
249
309
  const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
250
310
  return `data:${mime};base64,${buf.toString('base64')}`;
package/mcp/http.mjs CHANGED
@@ -66,22 +66,38 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
66
66
 
67
67
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
68
68
  //
69
- // ── A SESSION IS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01) ───────────────────────────────────────
70
- // registerTools() builds 248 tool definitions with their zod schemas: **~36 MB of RETAINED heap per McpServer**
71
- // (measured, node --expose-gc, 25 instances). This map used to be unbounded and never expired — the only removal
72
- // was transport.onclose, which never fires for a client that simply goes away. Prod took 238 `initialize`
73
- // handshakes in 49 minutes (238 × 36 MB = ~8.6 GB) on a 4 GiB, maxScale=1 instance and died of
69
+ // ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
70
+ // registerTools() used to rebuild all 681 tool definitions with their zod schemas on EVERY `initialize`:
71
+ // **114.36 MB of RETAINED heap per McpServer** (measured, node --expose-gc, 25 instances; 114.48 MB measured
72
+ // again end-to-end through this very transport). The older note here said ~36 MB true at the 248-tool roster
73
+ // it was written against, and 3.2x stale by August. This map used to be unbounded and never expired — the only
74
+ // removal was transport.onclose, which never fires for a client that simply goes away. Prod took 238
75
+ // `initialize` handshakes in 49 minutes on a 4 GiB, maxScale=1 instance and died of
74
76
  // `FATAL ERROR: Reached heap limit` six times in that hour, every crash a full outage.
75
77
  //
78
+ // THE CAP AND THE TTL WERE NEVER THE FIX, AND 2026-08-24 PROVED IT: they worked exactly as designed (peak
79
+ // concurrent sessions measured at exactly SESSION_MAX) and prod still crashed four times in twelve hours,
80
+ // because the cost is CHURN, not retention — one client opened ~175 sessions in 65 minutes. Worse, the cap was
81
+ // FEEDING the churn: 157 of 258 session closures in a 3-day window were 'over cap', each one answering that
82
+ // client's next call with a 404, which obliges it to re-initialize, which allocates another full roster and
83
+ // evicts somebody else. A self-sustaining spiral. The durable fix is in mcp/tools.mjs: the tool definitions are
84
+ // static per process, so they are built ONCE and replayed. A session now costs **3.06-3.19 MB** — 36x less —
85
+ // and it is the per-`initialize` figure, not the per-live-session one, that decides whether this survives a
86
+ // retry storm. See tools/mcp-tool-canon-check.mjs.
87
+ //
76
88
  // The 400-on-a-session-miss below was the ACCELERANT: a client that cannot re-initialize opens a NEW session
77
89
  // instead of reusing its own, so the leak fed itself. Both are fixed here — bound + expire the map, and answer
78
90
  // the one status code that obliges a client to re-initialize.
79
- const SESSION_MAX = Math.max(2, Number(process.env.MCP_SESSION_MAX || 16)); // ~580 MB ceiling for MCP
91
+ // 16 was sized against the old 114 MB session (~1.8 GB). At 3.1 MB it buys ~50 MB, so the cap is now far more
92
+ // conservative than the heap requires — deliberately left alone here so the memory fix lands as ONE measurable
93
+ // change; it is env-tunable with no deploy (`--update-env-vars MCP_SESSION_MAX=64` ≈ 200 MB) and raising it is
94
+ // what finally stops the over-cap eviction churn described above.
95
+ const SESSION_MAX = Math.max(2, Number(process.env.MCP_SESSION_MAX || 16));
80
96
  const SESSION_IDLE_MS = Math.max(1000, Number(process.env.MCP_SESSION_IDLE_MS || 30 * 60e3)); // 1s floor so the expiry is TESTABLE; a short TTL is merely wasteful now that eviction is recoverable
81
97
  const sessions = new Map(); // mcp-session-id -> { transport, server, user, lastSeen } (insertion-ordered = LRU)
82
98
  const challenge = (res) => res.status(401).set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`).json({ error: 'Authentication required' });
83
99
 
84
- // Tear a session down for real — dropping the map entry alone would leave the 36 MB McpServer reachable from
100
+ // Tear a session down for real — dropping the map entry alone would leave the McpServer reachable from
85
101
  // the transport's own callbacks. Deleting FIRST makes this re-entrant-safe: transport.close() fires onclose,
86
102
  // which calls back in here, and the second pass is a no-op.
87
103
  function dropSession(id, why) {
@@ -195,7 +211,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
195
211
  // liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
196
212
  if (sid && !init) return sessionGone(res);
197
213
  // Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
198
- // answered here so we never pay 36 MB to build a server whose only job would be to reject the request.
214
+ // answered here so we never build a server whose only job would be to reject the request.
199
215
  if (!init) return needSession(res);
200
216
  const scope = scopeFor(req, res);
201
217
  if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
@@ -219,7 +235,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
219
235
  await server.connect(transport);
220
236
  // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
221
237
  // objects are otherwise reachable only from this request's still-open response — close them explicitly
222
- // rather than leaving 36 MB pinned by a dead socket.
238
+ // rather than leaving a session pinned by a dead socket.
223
239
  res.on('close', () => { if (!transport.sessionId || !sessions.has(transport.sessionId)) { try { transport.close(); } catch {} try { server.close(); } catch {} } });
224
240
  } else {
225
241
  // Touch = LRU. Re-inserting moves the key to the end of the Map's insertion order, so the cap evicts the