hermoso 0.1.534 → 0.1.536

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
@@ -25,8 +25,9 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
25
25
  ## Set up in ChatGPT, Claude, Grok Bot or a coding agent
26
26
 
27
27
  **ChatGPT:** one click. **[Add to ChatGPT](https://chatgpt.com/plugins/plugin_asdk_app_6abde87d08048191b2ee7cda76721749)** opens Hermoso in ChatGPT's app
28
- directory. Press Add and sign in with your Hermoso account, no key needed. Then, in a new ChatGPT chat:
29
- `@Hermoso set up my brand from my website and make my first ad`
28
+ directory. Press Install plugin and sign in with your Hermoso account, no key needed. Then
29
+ **[open a new ChatGPT chat with your first message typed](https://chatgpt.com/?q=Hermoso%2C%20set%20up%20my%20brand%20from%20my%20website%20and%20make%20my%20first%20ad)**, or paste it yourself:
30
+ `Hermoso, set up my brand from my website and make my first ad`
30
31
 
31
32
  **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)**
32
33
  opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
@@ -0,0 +1,120 @@
1
+ // ── THE BRAND'S FONTS AND COLOURS, EDITABLE EVERYWHERE (2026-11-13) ─────────────────────────────────────────────
2
+ //
3
+ // A paying customer wrote in: his profile still held the fonts and palette the site scrape guessed at onboarding,
4
+ // and nothing let him change them. The Profile showed only the free-text "Visual style", and update_brand had no
5
+ // fonts or palette field. These are the two parsers every door now goes through: the MCP twins (so the CLI and
6
+ // /v1/tools), the Studio agent's update_brand, the web Profile (its own inline copy, pinned to these fixtures by
7
+ // tools/brand-look-check.mjs) and the admin brand-field route.
8
+ //
9
+ // It lives in mcp/ rather than lib/ for the reason roster-scope.mjs does: the npm package ships `cli/mcp/**` and
10
+ // nothing else, so `./brand-look.mjs` is the one path that resolves from BOTH tools.mjs twins. cli/mcp/brand-look.mjs
11
+ // is its byte twin (tools/mcp-parity.mjs sweeps every shared file).
12
+ //
13
+ // THE STORED SHAPES DO NOT CHANGE. `fonts` stays what the onboarding draft writes: an ORDERED array of family names,
14
+ // headline face first, body face second (templates/font-map.mjs reads it positionally). `palette` stays an array of
15
+ // hex strings. Both doors are accepted for fonts: the array as stored, or {headline, body}, which is merged onto the
16
+ // fonts already saved so changing one role leaves the other alone.
17
+ //
18
+ // NOTHING THE USER WROTE IS SILENTLY CHANGED. A value is trimmed and otherwise kept exactly as typed (case included);
19
+ // anything we cannot use is REFUSED with a sentence that names it, and nothing is saved. A short hex (#abc) or one
20
+ // without its # is refused rather than expanded, because every renderer reads the six-digit form only.
21
+
22
+ export const PALETTE_MAX = 8;
23
+ export const FONTS_MAX = 4;
24
+ export const FONT_NAME_MAX = 60;
25
+ export const HEX_RE = /^#[0-9a-fA-F]{6}$/;
26
+
27
+ const s = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
28
+ const show = (v) => JSON.stringify(s(v).slice(0, 40));
29
+
30
+ /** One colour → '' when usable, else the sentence saying why not. Pure. */
31
+ export function hexRefusal(v) {
32
+ if (typeof v !== 'string') return `Each colour must be written as text like "#3D4C41".`;
33
+ const t = v.trim();
34
+ if (!t) return 'A colour is empty. Remove it or write it like "#3D4C41".';
35
+ if (HEX_RE.test(t)) return '';
36
+ if (/^[0-9a-fA-F]{6}$/.test(t)) return `${show(t)} needs a # in front: "#${t}".`;
37
+ if (/^#?[0-9a-fA-F]{3}$/.test(t)) return `${show(t)} is a short colour code. Write all six digits, like "#AABBCC".`;
38
+ return `${show(t)} is not a colour code. Write it as # and six digits or letters A–F, like "#3D4C41".`;
39
+ }
40
+
41
+ /**
42
+ * The palette a caller asked for → { value } (the array to store) or { error }. An empty array clears it. Pure.
43
+ */
44
+ export function parseBrandPalette(input) {
45
+ if (input == null) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
46
+ const list = typeof input === 'string' ? input.split(/[\s,]+/).filter(Boolean) : input;
47
+ if (!Array.isArray(list)) return { error: 'palette must be a list of colour codes, like ["#3D4C41", "#EEE4DA"].' };
48
+ if (list.length > PALETTE_MAX) return { error: `A palette holds up to ${PALETTE_MAX} colours; this one has ${list.length}.` };
49
+ const out = [], seen = new Set();
50
+ for (const c of list) {
51
+ const why = hexRefusal(c);
52
+ if (why) return { error: why };
53
+ const t = c.trim();
54
+ if (seen.has(t.toLowerCase())) return { error: `${t} is in the palette twice.` };
55
+ seen.add(t.toLowerCase()); out.push(t);
56
+ }
57
+ return { value: out };
58
+ }
59
+
60
+ /** One font name → '' when usable, else the sentence saying why not. Any family name is accepted. Pure. */
61
+ export function fontNameRefusal(v) {
62
+ if (typeof v !== 'string') return 'Each font must be a family name written as text, like "Josefin Sans".';
63
+ const t = v.trim();
64
+ if (!t) return 'A font name is empty.';
65
+ if (t.length > FONT_NAME_MAX) return `${show(t)}… is longer than ${FONT_NAME_MAX} characters; write just the family name.`;
66
+ // Characters that would end a CSS family name or a prompt line. Letters in any alphabet, digits, spaces, hyphens,
67
+ // dots, ampersands and apostrophe-free punctuation stay; nothing is stripped, the name is refused instead.
68
+ if (/[\u0000-\u001f"'`<>{};\\]/.test(t)) return `${show(t)} contains a character a font name cannot have (quotes, < > { } ; or \\).`;
69
+ return '';
70
+ }
71
+
72
+ /**
73
+ * The fonts a caller asked for → { value } (the ordered array to store) or { error }.
74
+ * `input` is an array (headline first, body second, as stored) or { headline, body } merged onto `existing`;
75
+ * an empty string in the object form removes that role. An empty array clears the fonts. Pure.
76
+ */
77
+ export function parseBrandFonts(input, existing = []) {
78
+ const cur = (Array.isArray(existing) ? existing : []).filter((x) => typeof x === 'string' && x.trim());
79
+ if (Array.isArray(input)) {
80
+ if (input.length > FONTS_MAX) return { error: `Up to ${FONTS_MAX} fonts; the first is used for headlines and the second for body text.` };
81
+ const out = [];
82
+ for (const f of input) { const why = fontNameRefusal(f); if (why) return { error: why }; const t = f.trim(); if (out.some((o) => o.toLowerCase() === t.toLowerCase())) return { error: `${t} is listed twice.` }; out.push(t); }
83
+ return { value: out };
84
+ }
85
+ if (input && typeof input === 'object') {
86
+ const keys = Object.keys(input).filter((k) => input[k] !== undefined);
87
+ const bad = keys.filter((k) => k !== 'headline' && k !== 'body');
88
+ if (bad.length) return { error: `fonts takes headline and body (or a list); ${bad.map((k) => JSON.stringify(k)).join(', ')} is not one of them.` };
89
+ if (!keys.length) return { error: 'fonts needs a headline or a body font (or a list).' };
90
+ let head = cur[0] || '', body = cur[1] || '';
91
+ const rest = cur.slice(2);
92
+ for (const role of ['headline', 'body']) {
93
+ if (input[role] === undefined) continue;
94
+ const v = input[role] == null ? '' : input[role];
95
+ if (typeof v === 'string' && !v.trim()) { if (role === 'headline') head = ''; else body = ''; continue; }
96
+ const why = fontNameRefusal(v); if (why) return { error: why };
97
+ if (role === 'headline') head = v.trim(); else body = v.trim();
98
+ }
99
+ // A body font with no headline font keeps its place as the one face; the order is what renderers read.
100
+ const out = [head, body, ...rest].filter(Boolean);
101
+ const dedup = []; for (const f of out) if (!dedup.some((d) => d.toLowerCase() === f.toLowerCase())) dedup.push(f);
102
+ return { value: dedup };
103
+ }
104
+ if (typeof input === 'string') return { error: 'fonts takes a list (headline first, then body) or {headline, body}, not a single string.' };
105
+ return { error: 'fonts takes a list (headline first, then body) or {headline, body}.' };
106
+ }
107
+
108
+ /** The roles an ordered font list carries: the first is the headline face, the second the body face. Pure. */
109
+ export function brandFontRoles(fonts) {
110
+ const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
111
+ return { headline: f[0] || '', body: f[1] || f[0] || '', extra: f.slice(2) };
112
+ }
113
+
114
+ /** "Benne (headlines), Josefin Sans (body)" — how a reply, get_brand and the planner name the fonts. Pure. */
115
+ export function describeBrandFonts(fonts) {
116
+ const f = (Array.isArray(fonts) ? fonts : []).filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
117
+ if (!f.length) return '';
118
+ if (f.length === 1) return `${f[0]} (headlines and body)`;
119
+ return [`${f[0]} (headlines)`, `${f[1]} (body)`, ...f.slice(2)].join(', ');
120
+ }
package/mcp/client.mjs CHANGED
@@ -179,7 +179,7 @@ async function unwrap(res, { read = false } = {}) {
179
179
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
180
180
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
181
181
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
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
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?.rewardsSettled && typeof body.meta.rewardsSettled === 'object' ? { rewardsSettled: { credits: Number(body.meta.rewardsSettled.credits) || 0 } } : {}), ...(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
183
183
  }
184
184
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
185
185
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
@@ -1278,7 +1278,7 @@ export const neutralNote = (t) => {
1278
1278
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1279
1279
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1280
1280
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1281
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts']);
1281
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['shopifykeepalive', 'shopredact', 'fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'reviewask', 'emailfeedback', 'weeklyposts', 'winback']);
1282
1282
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1283
1283
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1284
1284
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -230,7 +230,7 @@ export const metaAlternativeNote = (name) => INSTAGRAM_LOGIN_TOOLS.has(String(na
230
230
  // The Shopify tools (list_shopify_products, publish_to_shopify_product, the marketing-activity tools) work only for an
231
231
  // account created by installing Hermoso from the Shopify App Store: the store is derived server-side from the verified
232
232
  // account and there is no "connect Shopify" tile. So they cannot ride TOOL_PROVIDER_RULES — every provider there must
233
- // be a CONNECTOR_INFO key (property 3), and "connect it under Settings ▸ Connectors" would be a dead end. Instead the
233
+ // be a CONNECTOR_INFO key (property 3), and "connect it under Connectors" would be a dead end. Instead the
234
234
  // same /api/connectors/providers read answers `shopifyStore`: true (this account has a live store), false (it does not,
235
235
  // or Shopify is off on this deployment), or absent when the server could not tell. ONLY a definite false holds them
236
236
  // back — an unreadable answer keeps them, like every other read of this set (property 1). Held, they are answered by
@@ -1,6 +1,6 @@
1
1
  // ── HINTS: THE NEXT STEP, NAMED (2026-09-17) ───────────────────────────────────────────────────────────────────
2
2
  //
3
- // This product already tells an agent what to do next, constantly and well — "connect it under Settings ▸
3
+ // This product already tells an agent what to do next, constantly and well — "connect it under
4
4
  // Connectors, then call again", "run buy_credits to top up", "call find_tools then call_tool", "reconnect with
5
5
  // ?tools=all". Every one of those is a SENTENCE inside a wall of other sentences, so reading it is a parsing job
6
6
  // an agent may or may not do. Monid rides a `Hints` block on its responses — server-suggested next command,