hermoso 0.1.162 → 0.1.177

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
- **718 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **739 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,
@@ -163,7 +163,7 @@ block entirely if you signed in above; it is there for CI, where the process can
163
163
 
164
164
  Then ask your agent: *“Generate an image ad with Hermoso.”*
165
165
 
166
- ### What the 718 tools cover
166
+ ### What the 739 tools cover
167
167
 
168
168
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
169
169
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // Tiny fetch wrapper around the Hermoso HTTP API, shared by the MCP server (mcp/tools.mjs) and the CLI (bin/hermoso.mjs).
1
+ // Tiny fetch wrapper around the Hermoso HTTP API, shared by the MCP server (mcp/tools.mjs) and the CLI (bin/heist.mjs).
2
2
  // LOCAL today: no auth needed — the server's local auth adapter resolves the fixed dev account, so requireAuth/
3
- // gateSpend pass. When real auth lands, set HERMOSO_TOKEN (a Bearer) and the SAME calls become authoritative — no
4
- // changes here. We attach the x-hermoso-plan / x-hermoso-user fallbacks the browser also sends, purely for parity;
3
+ // gateSpend pass. When real auth lands, set HEIST_TOKEN (a Bearer) and the SAME calls become authoritative — no
4
+ // changes here. We attach the x-heist-plan / x-heist-user fallbacks the browser also sends, purely for parity;
5
5
  // the server treats them as non-authoritative (identity comes from the verified token / local dev user).
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
@@ -11,12 +11,14 @@ import { AsyncLocalStorage } from 'node:async_hooks';
11
11
  // makes carries THAT caller's bearer (bills their account). stdio keeps using the env token — ctx is simply unset.
12
12
  export const mcpCtx = new AsyncLocalStorage();
13
13
 
14
- export const API_BASE = (process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai').replace(/\/+$/, '');
15
- const TOKEN = process.env.HERMOSO_TOKEN || '';
16
- // PINNED profile, or '' when unpinned. MUST stay unset by default: the server resolves an API key's profile as
17
- // header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so always sending the header permanently
18
- // masks the brand `use_brand` saved against the key connectors on any non-default brand then look disconnected.
19
- export const PROFILE = process.env.HERMOSO_PROFILE || '';
14
+ export const API_BASE = (process.env.HEIST_API_BASE || 'http://localhost:3000').replace(/\/+$/, '');
15
+ const TOKEN = process.env.HEIST_TOKEN || '';
16
+ // PINNED profile, or '' when the caller hasn't pinned one. This MUST stay unset by default: the server resolves
17
+ // an API key's profile as header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so a client
18
+ // that ALWAYS sends the header permanently masks the brand `use_brand` saved against the key. Live 2026-07-27:
19
+ // use_brand reported "Now acting on Hermoso", and every connector tool still answered for the default brand —
20
+ // so Meta/Google Ads/YouTube/OneDrive all looked disconnected over MCP while being connected in the web app.
21
+ export const PROFILE = process.env.HEIST_PROFILE || '';
20
22
  // SHARED TEAM WORKSPACE: the OWNING account. The web client sends this as x-hermoso-owner from PROFILE_OWNER
21
23
  // (public/app.js ctxHeaders) whenever the active brand belongs to someone else's account; the MCP twins never did,
22
24
  // so a member driving Hermoso headlessly resolved every brand-scoped read against their OWN empty account —
@@ -27,10 +29,10 @@ export const PROFILE = process.env.HERMOSO_PROFILE || '';
27
29
  // default — sending an owner for your own account would make resolveWs take the shared branch against yourself.
28
30
  // PAIR IT WITH THE PROFILE UUID, not the slug: profile_members keys on profiles.id, so a client_slug is the one
29
31
  // thing isMember() cannot match and it 403s. list_brands names both values for every workspace you can enter.
30
- export const OWNER = process.env.HERMOSO_OWNER || '';
32
+ export const OWNER = process.env.HEIST_OWNER || '';
31
33
  // The env-var prefix THIS build reads. tools.mjs is byte-identical across the two twins, so it cannot
32
34
  // hardcode either name when it tells a user which variables to set — it asks its own client.
33
- export const ENV_PREFIX = 'HERMOSO';
35
+ export const ENV_PREFIX = 'HEIST';
34
36
 
35
37
  // WHICH TOOL IS RUNNING. The error ledger groups on the OP, and a path alone cannot name the tool: `plan_ad`,
36
38
  // `render_ad` and `make_template_ad` all fail through POST /api/create, so without this every MCP defect would be
@@ -42,14 +44,15 @@ export const toolCtx = new AsyncLocalStorage();
42
44
  function headers(extra = {}) {
43
45
  const ctx = mcpCtx.getStore();
44
46
  // A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
45
- // ONLY scope it may carry: falling through to this process's HERMOSO_PROFILE / HERMOSO_OWNER would scope one
47
+ // ONLY scope it may carry: falling through to this process's HEIST_PROFILE / HEIST_OWNER would scope one
46
48
  // customer's tool call to whatever workspace the SERVER's environment happens to name — a cross-tenant leak that
47
49
  // is invisible because it succeeds. stdio/CLI keeps the env fallback: there the process and the caller are the
48
50
  // same person. Presence of the ctx store IS "remote" (see isRemote below).
49
- const prof = ctx ? (ctx.profile || '') : PROFILE; // omitted when unpinned so the key's saved brand wins server-side
51
+ const prof = ctx ? (ctx.profile || '') : PROFILE; // omit entirely when unpinned so the key's saved brand wins server-side
50
52
  const own = ctx ? (ctx.owner || '') : OWNER; // the wire name is x-hermoso-owner on BOTH twins — it is the server's header, not a brand
51
53
  const tool = toolCtx.getStore()?.tool || '';
52
- const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-hermoso-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...(tool ? { 'x-hermoso-tool': tool } : {}), ...extra };
54
+ const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-heist-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...(tool ? { 'x-hermoso-tool': tool } : {}), ...extra };
55
+ if (process.env.EDGE_SECRET) h['x-edge-auth'] = process.env.EDGE_SECRET; // belt: in-process self-calls satisfy the edge shield even if the loopback exemption ever changes
53
56
  const tok = ctx?.token || TOKEN;
54
57
  if (tok) h.Authorization = `Bearer ${tok}`;
55
58
  return h;
@@ -99,12 +102,53 @@ export async function apiGet(p, query) {
99
102
  // digit-strip reduced them to '' → GAQL "segments.date BETWEEN '' and ''". Drop empties before building the qs.
100
103
  const clean = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== ''));
101
104
  const qs = clean && Object.keys(clean).length ? '?' + new URLSearchParams(clean).toString() : '';
102
- const res = await fetch(`${API_BASE}${p}${qs}`, { headers: headers() });
105
+ const res = await fetchRead(`${API_BASE}${p}${qs}`, { headers: headers() });
103
106
  return unwrap(res);
104
107
  }
105
108
 
109
+ // ── A TRANSPORT FAILURE IS NOT A DEFECT, AND IT MUST NOT READ LIKE ONE (2026-08-31) ────────────────────────────
110
+ // Every tool here reaches the app over HTTP, so a network blip between this process and the app surfaces as node's
111
+ // bare `TypeError: fetch failed` — no status, no message anyone can act on. Two of those paged us in one afternoon
112
+ // (`list_connectors` 2m after a deploy, `list_meta_posts` 1.6h into a settled revision), and the error ledger is
113
+ // right to classify a raw TypeError as ours: it cannot tell a genuine bug from a dropped connection.
114
+ //
115
+ // RETRYING A READ IS SAFE. RETRYING A WRITE IS NOT, and that asymmetry is the whole design: when a POST dies at the
116
+ // transport layer we do not know whether the server processed it, so a retry is how one scheduled post becomes two.
117
+ // That is the same rule publishOnce already enforces one layer up — a timeout is neither success nor failure — so
118
+ // GET (and only GET) gets one more attempt, and a write says plainly that it may or may not have landed.
119
+ // THE THREE UPLOAD/PUT PATHS BYPASSED ALL OF THIS UNTIL 2026-09-01, and a real user found it: `upload_file` over
120
+ // the hosted connector answered a bare `TypeError: fetch failed` (fp 3d7f69f6-1fc), which the ledger correctly
121
+ // files as OURS because a raw runtime error is indistinguishable from a genuine bug. They are WRITES, so they take
122
+ // `fetchWrite` — no retry (a repeated upload is a duplicate file) and the honest "it may or may not have landed"
123
+ // sentence instead of a stack-trace word the caller cannot act on.
124
+ const TRANSPORT_RE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|network|terminated/i;
125
+ const isTransport = (e) => !!e && e.name === 'TypeError' && TRANSPORT_RE.test(String(e.message || '') + ' ' + String(e.cause?.code || e.cause?.message || ''));
126
+
127
+ async function fetchRead(url, init) {
128
+ try { return await fetch(url, init); }
129
+ catch (e) {
130
+ if (!isTransport(e)) throw e;
131
+ await new Promise((r) => setTimeout(r, 400));
132
+ try { return await fetch(url, init); }
133
+ catch (e2) {
134
+ if (!isTransport(e2)) throw e2;
135
+ throw Object.assign(new Error('Could not reach Hermoso just now — the request never got a response, so nothing was read. This is a connection problem, not a rejected request; try again.'), { _transport: true, _viaApi: true });
136
+ }
137
+ }
138
+ }
139
+
140
+ async function fetchWrite(url, init) {
141
+ try { return await fetch(url, init); }
142
+ catch (e) {
143
+ if (!isTransport(e)) throw e;
144
+ // NOT RETRIED, DELIBERATELY. The request may already have been processed; sending it again is how a duplicate
145
+ // is made. The caller is told the honest thing — that the outcome is unknown — so it can CHECK rather than repeat.
146
+ throw Object.assign(new Error(`Could not reach Hermoso while sending that ${init?.method || 'request'} — the connection dropped before any answer came back, so it MAY OR MAY NOT have been applied. Check whether it took effect before sending it again; retrying blindly can duplicate it.`), { _transport: true, _viaApi: true });
147
+ }
148
+ }
149
+
106
150
  export async function apiPost(p, body = {}) {
107
- const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
151
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
108
152
  return unwrap(res);
109
153
  }
110
154
 
@@ -112,17 +156,17 @@ export async function apiPost(p, body = {}) {
112
156
  // "leave that field exactly as it was", so sending a whole object where a patch was meant would blank the fields
113
157
  // the caller never mentioned. Only ever send the keys that are actually changing.
114
158
  export async function apiPatch(p, body = {}) {
115
- const res = await fetch(`${API_BASE}${p}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
159
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
116
160
  return unwrap(res);
117
161
  }
118
162
 
119
163
  export async function apiDelete(p) {
120
- const res = await fetch(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
164
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
121
165
  return unwrap(res);
122
166
  }
123
167
 
124
168
  export async function apiPut(p, body = {}) {
125
- const res = await fetch(`${API_BASE}${p}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
169
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
126
170
  return unwrap(res);
127
171
  }
128
172
 
@@ -145,7 +189,7 @@ export const hostRendersWidgets = () => WIDGET_HOSTS.test(mcpCtx.getStore()?.cli
145
189
  // ── WHICH WORKSPACE'S STORE KEYS THIS CALL WRITES ──────────────────────────────────────────────────────────────
146
190
  // The suffix synced store keys carry (`` = the bare/anchor keys, `<clientSlug>` = a sub-brand). It comes from the
147
191
  // SERVER (`GET /api/workspace` → resolveWs), never from this process's environment, because on the hosted twin the
148
- // caller and the process are different tenants: HERMOSO_PROFILE names whatever workspace the SERVER's env happens to
192
+ // caller and the process are different tenants: HEIST_PROFILE names whatever workspace the SERVER's env happens to
149
193
  // mention, which for a hosted connector is nothing at all. That is how `use_brand "Client X"` kept reading and
150
194
  // WRITING the anchor brand (live 2026-08-01), and how draft_brand's save overwrote the default brand's profile.
151
195
  let _suffixMemo = null; // stdio/CLI only: one process = one caller, so a module-level memo is honest here
@@ -207,7 +251,7 @@ export async function connectedProviders() {
207
251
  export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
208
252
  const h = headers({ 'Content-Type': contentType });
209
253
  if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
210
- const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
254
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
211
255
  return unwrap(res);
212
256
  }
213
257
  // Ingest by URL: the SERVER fetches the bytes (SSRF-guarded on every redirect hop) so nothing has to cross this
@@ -216,13 +260,13 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
216
260
  const h = headers({});
217
261
  delete h['Content-Type']; // a body-less POST must not claim one; the server sniffs the FETCHED bytes
218
262
  if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
219
- const res = await fetch(`${API_BASE}${p}?url=${encodeURIComponent(url)}`, { method: 'POST', headers: h });
263
+ const res = await fetchWrite(`${API_BASE}${p}?url=${encodeURIComponent(url)}`, { method: 'POST', headers: h });
220
264
  return unwrap(res);
221
265
  }
222
266
 
223
267
  // /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
224
268
  export async function apiSSE(p, body = {}) {
225
- const res = await fetch(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
269
+ const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
226
270
  if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
227
271
  const reader = res.body.getReader(); const dec = new TextDecoder();
228
272
  let buf = '', done = null, error = null; const progress = [];
@@ -260,7 +304,7 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
260
304
  onTick?.(job);
261
305
  if (job.status === 'done') return { job, result: jobResult(job) };
262
306
  if (job.status === 'error') throw new Error(job.error || 'Render failed');
263
- if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
307
+ if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `heist jobs get ' + id + '`'), { jobId: id });
264
308
  await new Promise(r => setTimeout(r, intervalMs));
265
309
  }
266
310
  }