hermoso 0.1.531 → 0.1.533

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
@@ -24,8 +24,12 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
24
24
 
25
25
  ## Set up in Claude, ChatGPT or Cursor
26
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.
27
+ **Claude (claude.ai, Claude Desktop):** one click. **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)**
28
+ opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
29
+ if Claude asks about Authentication, then sign in with your Hermoso account.
30
+
31
+ By hand: Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
32
+ Authentication, then Add and Connect. On a Team or Enterprise plan an Owner adds it for the organization first.
29
33
 
30
34
  ```text
31
35
  https://app.hermoso.ai/mcp
@@ -67,7 +71,7 @@ Ask in your own words. The agent picks the tools:
67
71
  - "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
68
72
  - "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
69
73
 
70
- **1198 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
74
+ **1231 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
71
75
  catalog with exact per-render credit costs plus the full capability map.
72
76
 
73
77
  **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 +246,10 @@ the routes.
242
246
 
243
247
  ## Instant: the hosted Claude.ai connector
244
248
 
245
- Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
249
+ **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)** opens Claude with the connector already filled in: press Continue, sign in with your
250
+ Hermoso account, and you are done.
251
+
252
+ By hand: paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
246
253
  press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
247
254
  discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
248
255
  Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
@@ -304,7 +311,7 @@ block entirely if you signed in above; it is there for CI, where the process can
304
311
 
305
312
  Then ask your agent: *“Generate an image ad with Hermoso.”*
306
313
 
307
- ### What the 1198 tools cover
314
+ ### What the 1231 tools cover
308
315
 
309
316
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
310
317
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/arg-guard.mjs CHANGED
@@ -71,11 +71,17 @@ export function declaredArgKeys(schema) {
71
71
  return null;
72
72
  }
73
73
 
74
- /** The keys of `args` the schema does not declare (host `_` metadata excluded). [] when there are none or none can be judged. */
74
+ // META ARGUMENTS (2026-11-11, spend-guard.mjs): accepted on EVERY tool without being in its schema, never "unknown".
75
+ // `context` is the user's goal in one sentence, for analytics only; declared once (on call_tool) instead of on 1,200
76
+ // schemas, so it costs the roster nothing. The guard lifts it onto the parsed arguments under CONTEXT_SYM, because the
77
+ // SDK's own parse strips any key the schema does not declare.
78
+ export const META_ARGS = Object.freeze(['context']);
79
+ export const CONTEXT_SYM = Symbol.for('hermoso.agentContext');
80
+ /** The keys of `args` the schema does not declare (host `_` metadata and META_ARGS excluded). [] when there are none or none can be judged. */
75
81
  export function unknownArgs(declared, args) {
76
82
  if (!Array.isArray(declared) || !args || typeof args !== 'object' || Array.isArray(args)) return [];
77
83
  const known = new Set(declared);
78
- return Object.keys(args).filter((k) => !known.has(k) && !k.startsWith('_'));
84
+ return Object.keys(args).filter((k) => !known.has(k) && !k.startsWith('_') && !META_ARGS.includes(k));
79
85
  }
80
86
 
81
87
  function lev(a, b) {
@@ -1,7 +1,8 @@
1
1
  // GENERATED by tools/chatgpt-roster-rank.mjs. Do not edit by hand: re-run the tool (it ranks by the committed usage in
2
2
  // tools/lib/chatgpt-roster-usage.json). The provider families ChatGPT lists, best first; a family is all or nothing.
3
3
  // The core (every tool that needs no connection) is always listed and is not in these lists. Cap: CHATGPT_TOOL_CAP
4
- // (950) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit.
4
+ // (1000) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit; the families get that minus the room
5
+ // the ChatGPT extension tools take (CHATGPT_EXT_TOOL_ROOM), because those tools are on the same list.
5
6
  export const CHATGPT_ROSTER_FAMILIES = Object.freeze([
6
7
  'google_drive', // long tail, pinned (plugin skills)
7
8
  'intercom', // long tail, pinned (plugin skills)
@@ -49,4 +50,4 @@ export const CHATGPT_ROSTER_LEFT_OUT = Object.freeze([
49
50
  'vercel', // long tail
50
51
  'whop', // long tail
51
52
  ]);
52
- export const CHATGPT_ROSTER_META = Object.freeze({ cap: 950, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
53
+ export const CHATGPT_ROSTER_META = Object.freeze({ cap: 1000, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
package/mcp/client.mjs CHANGED
@@ -124,6 +124,14 @@ function headers(extra = {}) {
124
124
  // reliable discriminator here. Forgeable, and harmlessly so: the worst a caller achieves is under-reporting
125
125
  // their own activity, which is why this decides bookkeeping and nothing else.
126
126
  if (ctx) h['x-hermoso-inproc'] = '1';
127
+ // WHICH HOST THE PERSON IS IN, FOR A REFUSAL ONLY (2026-11-12, lib/buy-page.mjs). A hosted session remembers its client
128
+ // (mcp/http.mjs rememberedClient); a ChatGPT one is named here so the server can mail the out-of-credits note in ~10
129
+ // minutes, since nothing can be bought inside ChatGPT. Forgeable and harmless: the worst a caller does is hurry its own email.
130
+ if (ctx && /openai|chatgpt/i.test(String(ctx.client || ''))) h['x-hermoso-host'] = 'chatgpt';
131
+ // A PRICE QUOTE AND THE AGENT'S STATED GOAL (2026-11-11, spend-guard.mjs): get_cost / expected_credits run the tool's own
132
+ // handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
133
+ // as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
134
+ { const st = toolCtx.getStore(); if (st?.quoteOnly) h['x-hermoso-quote'] = '1'; if (st?.contextHeader) h['x-hermoso-context'] = st.contextHeader; }
127
135
  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
128
136
  // THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
129
137
  // no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
@@ -149,17 +157,29 @@ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
149
157
  return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
150
158
  }
151
159
 
160
+ // A PRICE QUOTE'S STOP (2026-11-11, spend-guard.mjs): the server answers a request made in quote mode, at the point it would
161
+ // have spent, with 409 {code:'quote_only', quote:{legs}}. The legs join the running call's tally and the call stops here;
162
+ // wrap() turns the tally into the price. `_quoteStop` is never reported as an error.
163
+ export const QUOTE_STOP_MESSAGE = 'Price quote only: nothing ran and nothing was charged.';
164
+ export const quoteStopError = () => Object.assign(new Error(QUOTE_STOP_MESSAGE), { _quoteStop: true, status: 409, _viaApi: true });
165
+ function quoteStopFrom(status, body) {
166
+ if (Number(status) !== 409 || body?.code !== 'quote_only') return;
167
+ const st = toolCtx.getStore();
168
+ if (st?.quoteOnly) for (const l of (Array.isArray(body?.quote?.legs) ? body.quote.legs : [])) (st.quoteLegs ||= []).push(l);
169
+ throw quoteStopError();
170
+ }
152
171
  // unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
153
172
  async function unwrap(res, { read = false } = {}) {
154
173
  let body = null;
155
174
  try { body = await res.json(); } catch (e) { if (read && isDeadline(e)) throw readDeadlineError(); } // a body cut off by the read deadline is not an empty answer
156
175
  if (!res.ok) {
176
+ quoteStopFrom(res.status, body); // a price quote that stopped at a spending point (throws; never data)
157
177
  const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
158
178
  // `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
159
179
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
160
180
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
161
181
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
162
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
182
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
163
183
  }
164
184
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
165
185
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
@@ -522,7 +542,7 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
522
542
  // /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
523
543
  export async function apiSSE(p, body = {}) {
524
544
  const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
525
- if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
545
+ if (!res.ok) { let e, b = null; try { b = await res.json(); e = b?.error; } catch {} quoteStopFrom(res.status, b); throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
526
546
  // A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
527
547
  if (!/text\/event-stream/i.test(res.headers.get('content-type') || '')) { const j = await res.json(); return j && Object.prototype.hasOwnProperty.call(j, 'data') ? j.data : j; }
528
548
  const reader = res.body.getReader(); const dec = new TextDecoder();
@@ -549,7 +569,19 @@ export async function apiSSE(p, body = {}) {
549
569
  // Submit a render to the job queue and (optionally) poll it to completion. Returns the UNWRAPPED worker result
550
570
  // (the {image|video, model} object) on success — the job's `result` is itself a {data} envelope, so we peel it.
551
571
  export async function submitJob(type, input, { label = '' } = {}) {
552
- return apiPost('/api/jobs', { type, input, label }); // → publicJob {id, status, ...}
572
+ const st = toolCtx.getStore();
573
+ // A PRICE QUOTE (get_cost / expected_credits): the same submit as a dry run, priced by the job's own hold, then the call
574
+ // stops: nothing is queued. The server forces the dry run in quote mode too; this keeps the tally exact.
575
+ if (st?.quoteOnly) {
576
+ const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
577
+ const q = d?.quote || null;
578
+ (st.quoteLegs ||= []).push({ credits: q && Number(q.credits) > 0 ? Number(q.credits) : null, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: q && Number(q.credits) > 0 ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}) });
579
+ throw quoteStopError();
580
+ }
581
+ // A TOOL CALL WITH AN idempotency_key: each job it queues carries a key derived from it (one per submit in the call), so a
582
+ // retry of a lost answer collapses onto the same job, and the claim learns the job id (pendingText names it).
583
+ const idem = st?.idemKey ? { idempotencyKey: `${st.idemJobKey}.${(st.idemJobs = (st.idemJobs || 0) + 1)}`, toolIdemKey: st.idemKey } : {};
584
+ return apiPost('/api/jobs', { type, input, label, ...idem }); // → publicJob {id, status, ...}
553
585
  }
554
586
  export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
555
587
  // ONE READ THAT A ROLLOVER CANNOT TURN INTO "IT DOES NOT EXIST" (2026-10-19). get_job is a single read, and its 404
package/mcp/http.mjs CHANGED
@@ -16,8 +16,8 @@ import { randomUUID, createHash } from 'node:crypto';
16
16
  import { createRequire } from 'node:module';
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
19
- import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups } from './tools.mjs';
20
- import { chatgptExtAsked } from './openai-extensions.mjs';
19
+ import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups, widgetHostScope, withFirstRunLine, firstRunOffers } from './tools.mjs';
20
+ import { chatgptExtRequested } from './openai-extensions.mjs';
21
21
  import { mcpCtx, connectedProviders } from './client.mjs';
22
22
 
23
23
  // Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
@@ -398,7 +398,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
398
398
  // `?ext=1` asks for the ChatGPT extensions app (the Library panel + sidebar). Only a REQUEST: registerTools turns it
399
399
  // on only for a widget host on a process with HERMOSO_CHATGPT_EXT=1 (chatgptExtOn in openai-extensions.mjs), so on
400
400
  // the published URL, or with the flag unset, the roster is exactly what it was. Presentation only, like `?tools=`.
401
- const extAsked = (req) => chatgptExtAsked(req.query?.ext);
401
+ // HERMOSO_CHATGPT_EXT_PUBLISHED=1 stands in for `?ext=1` on an unscoped connection (the published all-in-one plugin's
402
+ // URL cannot carry it; see chatgptExtRequested). Still only a request: the widget-host and HERMOSO_CHATGPT_EXT keys hold.
403
+ const extAsked = (req, scope) => chatgptExtRequested({ asked: req.query?.ext, app: scope?.app || null });
402
404
  const srcOf = (req) => { const v = String(req.query?.src || '').trim().toLowerCase(); return /^[a-z0-9][a-z0-9_.-]{0,39}$/.test(v) ? v : null; };
403
405
  const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
404
406
 
@@ -406,6 +408,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
406
408
  // A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
407
409
  // MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
408
410
  const widgetHost = isWidgetHost(clientInfoOf(req.body), req);
411
+ scope = widgetHostScope(scope, widgetHost); // the URL our app handed ChatGPT users is the plain URL there (LEGACY_WIDGET_CONNECT_GROUPS)
409
412
  const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
410
413
  // `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
411
414
  // as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
@@ -423,7 +426,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
423
426
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
424
427
  // A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
425
428
  // chatgptRosterMode in tools.mjs: ChatGPT cannot reach an unlisted tool).
426
- 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
429
+ registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, app: scope?.app || null, widgetHost, hosted: true, ext: extAsked(req, scope) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
427
430
  // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
428
431
  // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
429
432
  // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
@@ -455,7 +458,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
455
458
  // doing anything"). Only a tools/call is somebody doing something, so only a tools/call stamps. Auth itself is
456
459
  // unchanged in both branches: this decides bookkeeping, never access.
457
460
  const didWork = methodsOf(req.body).includes('tools/call');
458
- const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
461
+ // A KEY STORE THAT COULD NOT BE READ IS NOT A REJECTED KEY (2026-11-11). During the database restart at 22:41 a
462
+ // Codex session's valid hmk_ key was answered with the 401 challenge, logged as "bearer token rejected"; a 401 tells
463
+ // the client to drop its credential and sign in again, for an outage of a few seconds. verifyBearer answers null for
464
+ // an unknown or revoked key and THROWS when the lookup itself fails, so a throw is a 503 the client retries. Same rule
465
+ // as the refresh path in mcp/oauth.mjs ("a failed read is not a revocation").
466
+ let storeDown = false;
467
+ const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => { storeDown = true; return null; }) : null;
468
+ if (storeDown) {
469
+ connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'key store read failed', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
470
+ res.setHeader('Retry-After', '5');
471
+ return res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Hermoso is briefly unavailable. Please retry in a few seconds; your connection is fine.' }, id: null });
472
+ }
459
473
  if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
460
474
  if (!user) {
461
475
  // No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
@@ -518,8 +532,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
518
532
  // Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
519
533
  // answered here so we never build a server whose only job would be to reject the request.
520
534
  if (!init) return needSession(res);
521
- const scope = scopeFor(req, res);
522
- if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
535
+ const scope0 = scopeFor(req, res);
536
+ if (scope0 === false) return; // unknown group — already answered 400, and nothing was allocated
537
+ // On ChatGPT the URL our own app handed out (seven named groups, no ads or analytics) is read as the plain URL:
538
+ // a widget host has no executor, so a group it does not list is a feature it does not have. See
539
+ // LEGACY_WIDGET_CONNECT_GROUPS in tools.mjs. The same host decision as `widgetHost` below.
540
+ const scope = widgetHostScope(scope0, isWidgetHost(entry?.client || clientInfoOf(req.body), req));
523
541
  sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
524
542
  // ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
525
543
  // One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
@@ -537,8 +555,21 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
537
555
  const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
538
556
  // ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
539
557
  const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
540
- const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
541
- 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
558
+ // AN ACCOUNT THAT HAS MADE NOTHING YET, ON EVERY HOST (2026-10-19 for ChatGPT; every host since 2026-11-12). Read once
559
+ // per session, bounded so a slow read can never hold the connect: null (no guidance) is the answer to every failure.
560
+ // Read BEFORE the server is built, because it changes what initialize says: a fresh account's `instructions` open with
561
+ // one line saying how to start (withFirstRunLine in tools.mjs), which a host reads before the user types anything. The
562
+ // first successful tool reply of the session then carries the same next ask (firstRunNote), for hosts that ignore
563
+ // instructions. `offer` is what this session's own instructions can name, so a focused app or the directory cage is
564
+ // never told to offer a tool it does not list.
565
+ const baseInstructions = instructionsFor(scope, { widgetHost });
566
+ let firstRun = null;
567
+ if (typeof firstRunFor === 'function' && user.accountId) {
568
+ try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
569
+ }
570
+ firstRun = firstRun && typeof firstRun === 'object' && firstRun.fresh === true ? { ...firstRun, offer: firstRunOffers(baseInstructions) } : null;
571
+ const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: withFirstRunLine(baseInstructions, firstRun) });
572
+ registerTools(server, { only: scope.groups, directory: scope.directory || false, app: scope.app || null, connectors, widgetHost, hosted: true, ext: extAsked(req, scope), 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
542
573
  const transport = new StreamableHTTPServerTransport({
543
574
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
544
575
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -548,15 +579,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
548
579
  // WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
549
580
  // request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
550
581
  // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
551
- // A CHATGPT ACCOUNT THAT HAS MADE NOTHING YET (2026-10-19). Read once per session, ChatGPT hosts only, bounded so a slow
552
- // read can never hold the connect: null (no note) is the answer to every failure. The first successful tool reply of
553
- // the session then offers one concrete next ask (firstRunNote in tools.mjs). Most ChatGPT signups since the
554
- // 10-15 publish had made nothing, and a live "make me a video ad" chat on a connected account never called us.
555
- let firstRun = null;
556
- if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
557
- try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
558
- }
559
- entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun: firstRun && typeof firstRun === 'object' ? firstRun : null };
582
+ // The fresh-account read (firstRun) happened above, before the server was built; the session keeps it so the first
583
+ // successful tool reply can carry the same next ask.
584
+ entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun };
560
585
  if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
561
586
  // LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
562
587
  // ever read is a guess. One line per session (not per call) so a new host identifies itself once and
@@ -166,6 +166,16 @@ export const chatgptExtAsked = (v) => /^(1|true|on|yes)$/i.test(String(Array.isA
166
166
  // All three keys, in one predicate so the transport and the check cannot disagree about what "on" means.
167
167
  export const chatgptExtOn = ({ env = process.env, asked, widgetHost } = {}) =>
168
168
  !!widgetHost && String(env?.[CHATGPT_EXT_ENV] ?? '').trim() === '1' && chatgptExtAsked(asked);
169
+ // THE PUBLISHED APP CANNOT ASK (2026-11-11). The all-in-one plugin's mcp.json URL is plain /mcp, and OpenAI does not
170
+ // let an update change a published plugin's MCP URL ("To change an existing MCP server's URL, contact support",
171
+ // developers.openai.com/plugins/deploy/submission.md, read 2026-11-11), so `?ext=1` can never reach it. This second
172
+ // env stands in for the ask on an UNSCOPED connection only (no `?tools=` app scope, so the three focused apps are
173
+ // untouched). It is still only a REQUEST: chatgptExtOn above keeps its other two keys (a widget host and
174
+ // HERMOSO_CHATGPT_EXT=1), so with this env unset the roster is byte-identical. Turning it on adds NEW tools, which
175
+ // OpenAI's continuous review checks before any user sees them; no reviewed tool changes.
176
+ export const CHATGPT_EXT_PUBLISHED_ENV = 'HERMOSO_CHATGPT_EXT_PUBLISHED';
177
+ export const chatgptExtRequested = ({ env = process.env, asked, app } = {}) =>
178
+ chatgptExtAsked(asked) || (!app && String(env?.[CHATGPT_EXT_PUBLISHED_ENV] ?? '').trim() === '1');
169
179
 
170
180
  // THE SIDEBAR ICON. The spec asks for a 20x20 monochrome SVG in currentColor. This is the bloom (four petals and a
171
181
  // centre, public/brand/bloom-mono-black.svg) redrawn in currentColor at the spec's stroke weight. A data: URI, so it
@@ -0,0 +1,127 @@
1
+ // ── A PAID TOOL CALL: PRICE IT FIRST, BUY IT ONCE (2026-11-11) ─────────────────────────────────────────────────────
2
+ //
3
+ // Three controls on every tool that spends (owner-approved 2026-11-11, after reading how the best agent surfaces do it):
4
+ // get_cost: true the exact price, with nothing run and nothing billed;
5
+ // expected_credits: N "run it at the price I was quoted": the server re-prices and REFUSES (unbilled) if it moved;
6
+ // idempotency_key: "…" chosen once and repeated unchanged on a retry: the same key never buys the call twice.
7
+ // Plus `context` on every tool: the user's goal in one short sentence, for analytics only.
8
+ //
9
+ // WHICH TOOLS: the ones that spend credits, derived from the same classification find_tools prints (tool-cost.mjs:
10
+ // the `create` and `research` groups run a model or Ad Spy research), minus SPEND_EXEMPT, the few tools filed in those
11
+ // groups that spend nothing. X's per-call tools are left out on purpose: post_to_x already has its own 24h
12
+ // idempotencyKey and a free dryRun that prices the exact post. tools/spend-guard-check.mjs derives the set from the
13
+ // registry and the handlers' own code, and fails on a paying tool that lacks the controls.
14
+ //
15
+ // HOW A PRICE IS MADE: the tool's OWN handler runs with quote mode on (client.mjs sends x-hermoso-quote: 1 on every
16
+ // request; server.js answers each spending point with what it would cost and stops it there). So the price is the run's
17
+ // own pricing, never a second table that could drift. Nothing is queued, held or charged.
18
+ //
19
+ // No `get_cost`, no `expected_credits` and no `idempotency_key` ⇒ the tool runs exactly as it always has.
20
+ //
21
+ // BYTE-TWINNED with cli/mcp/spend-guard.mjs (tools/mcp-parity.mjs sweeps every shared file). PURE apart from the
22
+ // injected `post`, and no lib/ import, because the CLI ships rooted at cli/.
23
+
24
+ import { createHash } from 'node:crypto';
25
+ import { toolCostClass } from './tool-cost.mjs';
26
+
27
+ // Filed in `create` / `research` but spend nothing: lookups, saved presets, Library and Gallery bookkeeping.
28
+ export const SPEND_EXEMPT = Object.freeze([
29
+ 'list_video_presets', 'save_video_preset', 'update_video_preset', 'delete_video_preset',
30
+ 'delete_library_item', 'submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery',
31
+ 'list_winning_ads', 'set_product_image', 'collect_post_metrics',
32
+ ]);
33
+ export const SPEND_CONTROL_ARGS = Object.freeze(['idempotency_key', 'get_cost', 'expected_credits']);
34
+
35
+ /** Does this tool spend credits (and so take get_cost / expected_credits / idempotency_key)? */
36
+ export function spendTool(name, group) {
37
+ const n = String(name || '');
38
+ if (SPEND_EXEMPT.includes(n)) return false;
39
+ const c = toolCostClass(n, group, null);
40
+ return c === 'model' || c === 'research';
41
+ }
42
+
43
+ // The parameter texts. Short on purpose: they ride every paying tool's schema.
44
+ export const SPEND_ARG_TEXT = Object.freeze({
45
+ get_cost: 'true: return the price only; nothing runs or bills',
46
+ expected_credits: 'the get_cost price; refused unbilled if the live price differs',
47
+ idempotency_key: 'chosen once, resent unchanged on retries: the same key never buys twice',
48
+ });
49
+ export const CONTEXT_ARG_TEXT = "optional: the user's underlying goal in one short sentence, for analytics only (no names, contact details or URLs)";
50
+
51
+ export const TOOL_IDEM_KEY_MAX = 128;
52
+ export function validKey(k) {
53
+ if (typeof k !== 'string') return null;
54
+ const s = k.trim();
55
+ return s && s.length <= TOOL_IDEM_KEY_MAX && !/[\u0000-\u001f\u007f]/.test(s) ? s : null;
56
+ }
57
+
58
+ function stableJson(v) {
59
+ if (Array.isArray(v)) return '[' + v.map(stableJson).join(',') + ']';
60
+ if (v && typeof v === 'object') return '{' + Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => JSON.stringify(k) + ':' + stableJson(v[k])).join(',') + '}';
61
+ return JSON.stringify(v === undefined ? null : v);
62
+ }
63
+ /** The arguments a key is bound to: everything except the call controls and `context`, order-free. */
64
+ export function argsHash(tool, args) {
65
+ const a = {};
66
+ for (const [k, v] of Object.entries(args && typeof args === 'object' ? args : {})) if (!SPEND_CONTROL_ARGS.includes(k) && k !== 'context') a[k] = v;
67
+ return createHash('sha256').update(String(tool || '') + '\n' + stableJson(a)).digest('hex').slice(0, 40);
68
+ }
69
+ /** The root of the job idempotency keys a keyed call's submits carry (server shape /^[A-Za-z0-9._:-]{8,120}$/; client.mjs appends .1, .2…). */
70
+ export function jobKeyRoot(toolKey, tool = '') {
71
+ return `ti.${createHash('sha256').update(String(tool) + '\n' + String(toolKey)).digest('hex').slice(0, 24)}`;
72
+ }
73
+
74
+ /** Pull the call controls off the arguments. `clean` is what the handler sees, so none of them ever reaches a body. */
75
+ export function takeSpendMeta(args) {
76
+ const a = args && typeof args === 'object' ? { ...args } : {};
77
+ const getCost = a.get_cost === true;
78
+ const expected = a.expected_credits == null || a.expected_credits === '' ? null : Number(a.expected_credits);
79
+ const rawKey = a.idempotency_key;
80
+ for (const k of SPEND_CONTROL_ARGS) delete a[k];
81
+ return { clean: a, getCost, expected: Number.isFinite(expected) ? expected : (expected === null ? null : NaN), key: rawKey == null || rawKey === '' ? null : validKey(rawKey), badKey: rawKey != null && rawKey !== '' && !validKey(rawKey) };
82
+ }
83
+
84
+ // ── the agent's `context`, redacted on THIS side before it leaves (lib/agent-context.mjs is the server's copy) ──
85
+ export const AGENT_CONTEXT_MAX = 200;
86
+ export function redactContext(s) {
87
+ if (typeof s !== 'string') return '';
88
+ let t = s.replace(/[\u0000-\u001f\u007f]+/g, ' ');
89
+ t = t.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]');
90
+ t = t.replace(/\b(?:https?:\/\/|www\.)\S+/gi, '[url]');
91
+ t = t.replace(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|ai|co|app|dev|shop|store|us|uk|ca|de|fr|es|it|nl|au|me|xyz)\b\S*/gi, '[url]');
92
+ t = t.replace(/(^|\s)@[A-Za-z0-9_.]{2,}/g, '$1[handle]');
93
+ t = t.replace(/\+?\d[\d\s().-]{6,}\d/g, '[number]');
94
+ t = t.replace(/\s+/g, ' ').trim();
95
+ return t.length > AGENT_CONTEXT_MAX ? t.slice(0, AGENT_CONTEXT_MAX - 1).trimEnd() + '…' : t;
96
+ }
97
+ // A header value is Latin-1 at the transport: anything else is percent-encoded (the server reads the decoded text).
98
+ export const contextHeaderValue = (t) => { try { return encodeURIComponent(t).slice(0, 900); } catch { return ''; } };
99
+
100
+ // ── the price ──
101
+ const round1 = (n) => Math.round(n * 10) / 10;
102
+ /** One price out of the legs a quote run collected. credits is null when any leg is billed at its actual cost. */
103
+ export function quoteFromLegs(legs) {
104
+ const L = (Array.isArray(legs) ? legs : []).filter(Boolean);
105
+ const refused = L.find((l) => l.refused);
106
+ if (refused && !L.some((l) => l.credits > 0)) return { credits: null, legs: L, refused: String(refused.refused) };
107
+ const unpriced = L.filter((l) => !(Number(l.credits) > 0));
108
+ const sum = L.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
109
+ const hold = L.reduce((t, l) => t + (Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
110
+ if (!L.length) return { credits: 0, legs: L, free: true };
111
+ return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: unpriced.map((l) => l.label || 'a step') } : {}) };
112
+ }
113
+ const legLine = (l) => `${l.label || 'a step'}: ${Number(l.priceCredits) > 0 ? `${l.priceCredits}` : Number(l.credits) > 0 ? `${l.credits}` : 'its actual cost'}`;
114
+ export function quoteText(tool, q) {
115
+ if (q.refused) return `NO PRICE (nothing ran, nothing was charged): ${tool} would not start: ${q.refused}`;
116
+ if (q.free) return `PRICE (nothing ran, nothing was charged): ${tool} with these arguments spends no credits.`;
117
+ if (q.credits == null) {
118
+ const known = q.legs.filter((l) => Number(l.credits) > 0);
119
+ return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${known.map(legLine).join(', ')} credits.` : ''} expected_credits cannot bind this call; run it without it.`;
120
+ }
121
+ return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
122
+ }
123
+ export function expectedMismatchText(tool, expected, q) {
124
+ if (q.credits == null) return `Refused, nothing ran and nothing was charged: expected_credits was ${expected}, but ${tool} has no fixed price here (${q.refused || `billed at the actual cost of ${(q.unpriced || []).join(' and ') || 'its model steps'}`}). Run it without expected_credits, or ask the user first.`;
125
+ return `Refused, nothing ran and nothing was charged: the live price of ${tool} is ${q.credits} credits, not the ${expected} you expected. Tell the user the new price; if they agree, call again with expected_credits: ${q.credits}.`;
126
+ }
127
+ export const priceMatches = (expected, q) => q && q.credits != null && Math.abs(Number(expected) - Number(q.credits)) < 0.05;
package/mcp/tool-cost.mjs CHANGED
@@ -46,6 +46,9 @@ export const CREATE_FREE_READS = Object.freeze([
46
46
  // The product catalog (2026-10-06): a store read or write, and a product page read with curl and parsed locally (no
47
47
  // model; POST /api/products answers creditsUsed: 0). Each says "Free" / "0 credits" in its own description.
48
48
  'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product',
49
+ // Story films (2026-11-11): the catalog, one card's recipe and starting a run (the outline) are store reads and writes;
50
+ // each says "Free" in its own description. advance_story_film runs the paid stages and stays a model tool.
51
+ 'list_story_cards', 'get_story_card', 'start_story_film',
49
52
  ]);
50
53
  // Reads inside `research`. `find_competitors` says "0 credits" in its own description (it is the discovery model,
51
54
  // billed to us, not a paid ad-data call); the watch tools only write and read a stored preference — the weekly
@@ -81,10 +81,29 @@ export function videoChoiceDraftCall(tool, d) {
81
81
  // then try to upsell the plans/credits?"). The agent says plainly that the account does not have enough credits and offers
82
82
  // credits and plans; the image and the light draft stay listed LAST, for a user who asks for something cheaper themselves,
83
83
  // never as the agent's own pick. A video-plus-static ask is priced whole (`needCredits`, `alsoImage`) and neither half is made.
84
+ // ── THE TWO PRICED DOORS, AS AN AGENT RELAYS THEM (2026-11-12) ──────────────────────────────────────────────────────
85
+ // A balance refusal carries `buy` (the server's buyDoorsFor): a monthly plan and a credit pack sized to the ask, each with
86
+ // its price, its credits and a checkout link that needs no sign-in, plus the same offer as ready-made `lines`. The old
87
+ // answer explained how buy_credits works and left the agent to make a second call before the user saw a price. Now the
88
+ // prices and the links are in the refusal itself. Never on a widget host (ChatGPT may not link to a checkout): the
89
+ // callers pass `buy` only when the host is not one. Pure, no imports: the twin rule.
90
+ export function buyDoorsOf(e, widgetHost) {
91
+ const b = e && e.buy;
92
+ return !widgetHost && b && typeof b === 'object' && b.pack && Array.isArray(b.lines) && b.lines.length ? b : null;
93
+ }
94
+ export function buyDoorsText(buy, tool) {
95
+ if (!buy) return '';
96
+ const lines = buy.lines.map((x) => String(x)).filter(Boolean);
97
+ const doors = lines.filter((l) => l.startsWith('•'));
98
+ return [buy.plan ? `Recommend the ${buy.plan.name} plan to the user, with its link; a one-time credit pack is the other option:` : 'Give the user this way to continue, with its link:', ...doors,
99
+ `Each link opens a secure checkout page for this account, no sign-in needed. Once they have paid, call ${tool || 'the same tool'} again with the same arguments. A card already on file: buy_credits({pack:"${buy.pack.id}"}) quotes it and charges on confirm.`].join('\n');
100
+ }
101
+ export const buyDoorsHints = (buy, tool) => (buy ? [{ do: `${buy.plan ? `recommend the ${buy.plan.name} plan from this message with its price and link, and mention the one-time pack as the other option` : 'show the user the pack from this message with its price and link'}; after they pay, call ${tool || 'the same tool'} again with the same arguments`, why: 'the account cannot cover this call; each link is a checkout for this account that needs no sign-in, and nothing was charged' }] : []);
84
102
  export function videoChoiceHints(tool, choice, opts = {}) {
85
103
  const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
86
104
  const out = opts.widgetHost
87
105
  ? [{ 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' }]
106
+ : opts.buy ? buyDoorsHints(opts.buy, tool)
88
107
  : [
89
108
  { do: 'buy_credits({})', why: `${c.short ?? '?'} credits short; a pack covers it, then the same call makes it as asked` },
90
109
  { do: 'upgrade_plan({})', why: 'a monthly plan (Starter, Pro, Studio) includes monthly credits and no markup on models' },
@@ -108,6 +127,7 @@ export function videoChoiceText(tool, choice, opts = {}) {
108
127
  // feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
109
128
  ...(opts.widgetHost
110
129
  ? [` 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.`]
130
+ : opts.buy ? [buyDoorsText(opts.buy, tool)]
111
131
  : [` 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
132
  ` 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.`]),
113
133
  ];
@@ -156,8 +176,28 @@ export function withWidgetResultNote(tool, r, widgetHost) {
156
176
  // buy_credits / upgrade_plan (WITHHELD_FROM_WIDGET_HOSTS), so its out-of-credits reply must not name them either, and
157
177
  // the server's own sentence ("Upgrade or top up to keep going.") is rewritten to say where the account is managed.
158
178
  // The link is the pricing page, which describes the options and starts nothing. Pure, no imports — the twin rule.
159
- export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing';
160
- export const CREDITS_LOW_NOTE = `The Hermoso account's credit balance is too low for this. Tell the user plainly; they manage their account and credits at ${ACCOUNT_INFO_URL}. Nothing can be bought from here.`;
179
+ // TAGGED src=chatgpt-credits (2026-11-12, lib/buy-page.mjs): the pricing page shows a visitor with this tag one button, "Buy credits
180
+ // or a plan", to the app's buy page. Still the informational page ChatGPT's rules allow, and the only link this host is ever
181
+ // given; the buy page itself is never linked from here. The tag rides only the refusal text, never a tool definition.
182
+ export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing?src=chatgpt-credits';
183
+ // …AND THE PLAN TIER THE ACCOUNT IS ON, AND NOTHING ELSE (2026-11-12, owner: "shouldnt we only show plans higher than the plan
184
+ // they are on?"). The pricing page is static and cannot see the account, so the link says which tier it is on (&from=starter)
185
+ // and the page hides the plans at or below it. A tier is not personal data; no id, email or balance ever rides the link. An
186
+ // unknown or free tier is the plain link (every plan is above Free). Pure; tools.mjs reads the tier once per refusal.
187
+ export const PLAN_TIERS = Object.freeze(['free', 'basic', 'starter', 'pro', 'studio']);
188
+ export function accountInfoUrl(tier) {
189
+ const t = String(tier || '').trim().toLowerCase();
190
+ return PLAN_TIERS.includes(t) && t !== 'free' ? `${ACCOUNT_INFO_URL}&from=${t}` : ACCOUNT_INFO_URL;
191
+ }
192
+ /** A refusal written for a widget host, with the tier put on its info link (text and hints). The text is otherwise untouched. */
193
+ export function withInfoTier(text, hints, tier) {
194
+ const u = accountInfoUrl(tier);
195
+ if (u === ACCOUNT_INFO_URL) return { text, hints };
196
+ const sw = (x) => String(x).split(ACCOUNT_INFO_URL).join(u);
197
+ return { text: sw(text), hints: (hints || []).map((h) => (h && typeof h === 'object' ? { ...h, ...(typeof h.do === 'string' ? { do: sw(h.do) } : {}), ...(typeof h.why === 'string' ? { why: sw(h.why) } : {}) } : h)) };
198
+ }
199
+ export const ACCOUNT_CREDITS_LINE = `The user can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again.`; // the neutral fact every widget-host refusal carries (owner, 2026-11-12): what to do and where, never a pitch
200
+ export const CREDITS_LOW_NOTE = `The Hermoso account does not have enough credits for this. Tell the user plainly. ${ACCOUNT_CREDITS_LINE} Nothing can be bought from here.`;
161
201
  const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\s+top\s+up|top\s+up|add\s+credits|buy\s+(?:more\s+)?credits|monthly\s+plans\s+have\s+no\s+markup\s+on\s+models)\b[^.!\n]*[.!]?/gi; // the monthly-plan upsell (2026-10-20) is a promotion too: a widget host (ChatGPT) never hears it
162
202
  // ── FREE CREDITS NOT CLAIMED YET (2026-10-25, ChatGPT funnel) ─────────────────────────────────────────────────────
163
203
  // An account whose welcome credits were never added (the one-time check that a person is behind it did not pass
@@ -165,8 +205,73 @@ const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\
165
205
  // said on every host, ChatGPT included. The server marks the refusal (meta.welcomeClaim = {credits, url}).
166
206
  export const welcomeClaimSentence = (w) => `This account has not received its ${Number(w?.credits) || 0} free credits yet. The user opens ${w?.url} once in a browser (one click, no sign-in and no card) and they arrive in a few seconds after a quick check that a person is there.`;
167
207
  export const welcomeClaimHint = (w) => ({ do: `ask the user to open ${w?.url} once in a browser (one click, no sign-in) to receive the ${Number(w?.credits) || 0} free credits; then run the same call again`, why: 'the account never received its free credits: they wait behind a one-time check that a person is behind the account, which runs in the browser' });
168
- export function neutralCreditsText(msg) {
169
- const base = String(msg ?? '').replace(UPSELL_SENTENCE_RE, '').replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
170
- return `${base}${base ? '\n' : ''}${CREDITS_LOW_NOTE}`;
208
+ export function neutralCreditsText(msg, nums = {}) {
209
+ const base = stripPlanPromo(String(msg ?? '').replace(UPSELL_SENTENCE_RE, '')).replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
210
+ // The numbers, when the server's sentence did not carry them ("You’re out of credits." does not): read from the refusal's
211
+ // structured block (e.buy.need / .balance), never parsed from prose. A count, not a price.
212
+ const need = Math.round(Number(nums?.need) || 0), bal = Number(nums?.balance);
213
+ const numLine = need > 0 && !/\bneeds?\b[^.\n]*\d/i.test(base) ? `This needs about ${need} credits${Number.isFinite(bal) && nums?.balance != null ? ` and the account has ${Math.max(0, Math.floor(bal))}` : ''}.` : '';
214
+ return [base, numLine, CREDITS_LOW_NOTE].filter(Boolean).join('\n');
215
+ }
216
+ // ── NO PLAN PITCH ON A WIDGET HOST, WHEREVER IT CAME FROM (2026-11-12) ────────────────────────────────────────────
217
+ // Measured on production: a ChatGPT out-of-credits refusal carried no price and no checkout link and STILL ended
218
+ // "Starter, Pro and Studio have no markup on models, so your credits go twice as far." — the server's withPlanUpsell
219
+ // sentence (server.js PLAN_UPSELL_LINE, or its "on one of those plans this needs about N" form), which UPSELL_SENTENCE_RE
220
+ // above did not know. OpenAI's app guidelines: apps "may not … promote upgrades". So the rule is a SENTENCE filter, not a
221
+ // list of known sentences: on a widget host any sentence (or list item) that pitches a plan, names a purchase tool, a
222
+ // price, a checkout or a top-up is dropped, and the neutral line (ACCOUNT_CREDITS_LINE) says what to do and where.
223
+ // Two strengths. PLAN_PROMO_RE is for text that is ABOUT credits or the account (a credits refusal, the capability map),
224
+ // where "$" or "upgrade" can only be selling. PLAN_PITCH_RE is narrow enough for any reply on a widget host (a budget
225
+ // error says "$5", ad copy says "go further"): only our own pitch phrases and the purchase tools' names.
226
+ export const PLAN_PROMO_RE = /\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b|\bupgrad(?:e|es|ed|ing)\b|no markup|twice as far|\bgo(?:es)? further\b|checkout|\btop[- ]?ups?\b|\$\s?\d|WHY HERMOSO|\bcredit packs?\b|\bper month\b|\/mo\b/i;
227
+ export const PLAN_PITCH_RE = /no markup on (?:image, video and voice )?models|credits? go(?:es)? (?:twice as far|further)|\bStarter, Pro and Studio\b|\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b/i;
228
+ // Split `s` at every `sep` that sits outside parentheses/brackets, so "(a, b; c)" is never cut in half.
229
+ function splitTop(s, sep) {
230
+ const out = []; let depth = 0, from = 0;
231
+ for (let i = 0; i < s.length; i++) {
232
+ const ch = s[i];
233
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
234
+ else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0) depth--;
235
+ else if (depth === 0) {
236
+ if (sep === 'sentence') { if (/[.!?]/.test(ch) && /\s/.test(s[i + 1] || '')) { out.push(s.slice(from, i + 1)); from = i + 1; } }
237
+ else if (s.startsWith(sep, i)) { out.push(s.slice(from, i)); from = i + sep.length; i += sep.length - 1; }
238
+ }
239
+ }
240
+ out.push(s.slice(from));
241
+ return out;
242
+ }
243
+ const PROMO_LEVELS = [['sentence', ' '], ['; ', '; '], [' · ', ' · ']];
244
+ export function stripPlanPromo(text, re = PLAN_PROMO_RE) {
245
+ const prune = (s, levels) => {
246
+ if (!re.test(s)) return s;
247
+ for (let k = 0; k < levels.length; k++) {
248
+ const [sep, join] = levels[k];
249
+ const parts = splitTop(s, sep);
250
+ if (parts.length < 2) continue;
251
+ return parts.map((p) => prune(sep === 'sentence' ? p.trim() : p, levels.slice(k + 1))).filter((p) => p.trim()).join(join);
252
+ }
253
+ return '';
254
+ };
255
+ const src = String(text ?? '');
256
+ if (!re.test(src)) return src;
257
+ return src.split('\n').map((line) => (re.test(line) ? { line: prune(line, PROMO_LEVELS), cut: true } : { line, cut: false }))
258
+ .filter((x) => !(x.cut && !x.line.trim())).map((x) => x.line).join('\n');
259
+ }
260
+ // A reply on a widget host, with every pitch sentence out of its text blocks (a JSON block is data and left whole) and
261
+ // out of its hints. The result object is copied, never edited in place.
262
+ export function withoutPlanPitch(r, re = PLAN_PITCH_RE) {
263
+ if (!r || typeof r !== 'object') return r;
264
+ let out = r;
265
+ if (Array.isArray(r.content) && r.content.some((b) => b && b.type === 'text' && typeof b.text === 'string' && re.test(b.text) && !/^\s*[[{]/.test(b.text))) {
266
+ out = { ...out, content: r.content.map((b) => (b && b.type === 'text' && typeof b.text === 'string' && !/^\s*[[{]/.test(b.text) ? { ...b, text: stripPlanPromo(b.text, re) } : b)) };
267
+ }
268
+ const h = out._meta && Array.isArray(out._meta[HINTS_KEY]) ? out._meta[HINTS_KEY] : null;
269
+ if (h && h.some((x) => PLAN_PROMO_RE.test(`${x.do} ${x.why}`))) {
270
+ const kept = h.filter((x) => !PLAN_PROMO_RE.test(`${x.do} ${x.why}`));
271
+ const meta = { ...out._meta };
272
+ if (kept.length) meta[HINTS_KEY] = kept; else delete meta[HINTS_KEY];
273
+ out = { ...out, _meta: meta };
274
+ }
275
+ return out;
171
276
  }
172
- export const creditsLowHints = () => [{ do: `tell the user the balance is too low for this; they manage credits at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }];
277
+ export const creditsLowHints = () => [{ do: `tell the user the account does not have enough credits for this; they can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again`, why: 'this host does not offer purchases, so nothing is bought from here' }];