hermoso 0.1.531 → 0.1.532

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
@@ -67,7 +67,7 @@ Ask in your own words. The agent picks the tools:
67
67
  - "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
68
68
  - "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
69
69
 
70
- **1198 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
70
+ **1199 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
71
71
  catalog with exact per-render credit costs plus the full capability map.
72
72
 
73
73
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -304,7 +304,7 @@ block entirely if you signed in above; it is there for CI, where the process can
304
304
 
305
305
  Then ask your agent: *“Generate an image ad with Hermoso.”*
306
306
 
307
- ### What the 1198 tools cover
307
+ ### What the 1199 tools cover
308
308
 
309
309
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
310
310
  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) {
package/mcp/client.mjs CHANGED
@@ -124,6 +124,10 @@ 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
+ // A PRICE QUOTE AND THE AGENT'S STATED GOAL (2026-11-11, spend-guard.mjs): get_cost / expected_credits run the tool's own
128
+ // handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
129
+ // as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
130
+ { const st = toolCtx.getStore(); if (st?.quoteOnly) h['x-hermoso-quote'] = '1'; if (st?.contextHeader) h['x-hermoso-context'] = st.contextHeader; }
127
131
  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
132
  // THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
129
133
  // no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
@@ -149,11 +153,23 @@ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
149
153
  return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
150
154
  }
151
155
 
156
+ // 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
157
+ // have spent, with 409 {code:'quote_only', quote:{legs}}. The legs join the running call's tally and the call stops here;
158
+ // wrap() turns the tally into the price. `_quoteStop` is never reported as an error.
159
+ export const QUOTE_STOP_MESSAGE = 'Price quote only: nothing ran and nothing was charged.';
160
+ export const quoteStopError = () => Object.assign(new Error(QUOTE_STOP_MESSAGE), { _quoteStop: true, status: 409, _viaApi: true });
161
+ function quoteStopFrom(status, body) {
162
+ if (Number(status) !== 409 || body?.code !== 'quote_only') return;
163
+ const st = toolCtx.getStore();
164
+ if (st?.quoteOnly) for (const l of (Array.isArray(body?.quote?.legs) ? body.quote.legs : [])) (st.quoteLegs ||= []).push(l);
165
+ throw quoteStopError();
166
+ }
152
167
  // unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
153
168
  async function unwrap(res, { read = false } = {}) {
154
169
  let body = null;
155
170
  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
171
  if (!res.ok) {
172
+ quoteStopFrom(res.status, body); // a price quote that stopped at a spending point (throws; never data)
157
173
  const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
158
174
  // `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
159
175
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
@@ -522,7 +538,7 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
522
538
  // /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
523
539
  export async function apiSSE(p, body = {}) {
524
540
  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 }); }
541
+ 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
542
  // A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
527
543
  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
544
  const reader = res.body.getReader(); const dec = new TextDecoder();
@@ -549,7 +565,19 @@ export async function apiSSE(p, body = {}) {
549
565
  // Submit a render to the job queue and (optionally) poll it to completion. Returns the UNWRAPPED worker result
550
566
  // (the {image|video, model} object) on success — the job's `result` is itself a {data} envelope, so we peel it.
551
567
  export async function submitJob(type, input, { label = '' } = {}) {
552
- return apiPost('/api/jobs', { type, input, label }); // → publicJob {id, status, ...}
568
+ const st = toolCtx.getStore();
569
+ // A PRICE QUOTE (get_cost / expected_credits): the same submit as a dry run, priced by the job's own hold, then the call
570
+ // stops: nothing is queued. The server forces the dry run in quote mode too; this keeps the tally exact.
571
+ if (st?.quoteOnly) {
572
+ const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
573
+ const q = d?.quote || null;
574
+ (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) } : {}) });
575
+ throw quoteStopError();
576
+ }
577
+ // 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
578
+ // retry of a lost answer collapses onto the same job, and the claim learns the job id (pendingText names it).
579
+ const idem = st?.idemKey ? { idempotencyKey: `${st.idemJobKey}.${(st.idemJobs = (st.idemJobs || 0) + 1)}`, toolIdemKey: st.idemKey } : {};
580
+ return apiPost('/api/jobs', { type, input, label, ...idem }); // → publicJob {id, status, ...}
553
581
  }
554
582
  export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
555
583
  // 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
@@ -17,7 +17,7 @@ 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
19
  import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups } from './tools.mjs';
20
- import { chatgptExtAsked } from './openai-extensions.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
 
@@ -423,7 +425,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
423
425
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
424
426
  // A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
425
427
  // 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
428
+ 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
429
  // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
428
430
  // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
429
431
  // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
@@ -538,7 +540,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
538
540
  // ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
539
541
  const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
540
542
  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
543
+ 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
544
  const transport = new StreamableHTTPServerTransport({
543
545
  // CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
544
546
  sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
@@ -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/tools.mjs CHANGED
@@ -13,7 +13,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  // The UTF-16 well-formedness law — see wellFormedServer() below for why it is applied here, and
14
14
  // ./well-formed.mjs for why that specifier is the only one that can work in a byte-identical twin.
15
15
  import { wellFormedValue, wellFormedString } from './well-formed.mjs';
16
- import { UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote, unknownArgsDetail } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
16
+ import { CONTEXT_SYM, UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote, unknownArgsDetail } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
17
17
  // WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
18
18
  // re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
19
19
  // twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
@@ -31,6 +31,8 @@ function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolN
31
31
  // WHAT A TOOL COSTS AND WHETHER IT IS WORKING — the two facts find_tools puts on every row beside the parameters.
32
32
  // Same twin-safe specifier rule as the two imports above: no lib/, no repo, no relative escape.
33
33
  import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
34
+ // PRICE FIRST, BUY ONCE (2026-11-11): get_cost / expected_credits / idempotency_key on every paying tool, `context` on all.
35
+ import { spendTool, SPEND_ARG_TEXT, CONTEXT_ARG_TEXT, takeSpendMeta, argsHash, jobKeyRoot, redactContext, contextHeaderValue, quoteFromLegs, quoteText as spendQuoteText, expectedMismatchText, priceMatches } from './spend-guard.mjs';
34
36
  import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
35
37
  // THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
36
38
  import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind, withWidgetResultNote } from './tool-hints.mjs';
@@ -668,6 +670,104 @@ export async function withWeekPlanNote(tool, r, post = apiPost, brand = '') {
668
670
  return withHints(out, [...hintsOf(r), { do: 'ask the user the question in this reply', why: d.kind === 'finished' ? 'their planned week has gone out' : 'they just connected their first channel' }]);
669
671
  } catch { return r; }
670
672
  }
673
+ // ── PRICE FIRST, BUY ONCE (2026-11-11, spend-guard.mjs) ─────────────────────────────────────────────────────────
674
+ // Which registered tools take the three call controls: filled at the ONE registration seam (buildTools' proxy) from
675
+ // the group marker each tool was written under, so the next tool inherits it with nothing to remember.
676
+ const SPEND_TOOL_NAMES = new Set();
677
+ const SPEND_ARGS = Object.freeze({
678
+ get_cost: z.boolean().optional().describe(SPEND_ARG_TEXT.get_cost),
679
+ expected_credits: z.number().optional().describe(SPEND_ARG_TEXT.expected_credits),
680
+ idempotency_key: z.string().optional().describe(SPEND_ARG_TEXT.idempotency_key), // ≤128 chars, checked in takeSpendMeta (a refusal, not a schema bound: fewer roster bytes)
681
+ });
682
+ export const withSpendArgs = (name, def, group) => {
683
+ if (!spendTool(name, group)) return def;
684
+ SPEND_TOOL_NAMES.add(name);
685
+ const s = def && def.inputSchema;
686
+ if (!s || typeof s !== 'object' || s instanceof z.ZodType) return def;
687
+ const add = Object.fromEntries(Object.entries(SPEND_ARGS).filter(([k]) => !Object.prototype.hasOwnProperty.call(s, k)));
688
+ return Object.keys(add).length ? { ...def, inputSchema: { ...s, ...add } } : def;
689
+ };
690
+ export const isSpendToolName = (n) => SPEND_TOOL_NAMES.has(String(n || ''));
691
+ // get_instructions({topic:'paid-calls'}): the one place the controls are explained at length, so the schemas stay short.
692
+ export const PAID_CALLS_GUIDE = [
693
+ 'PAID CALLS: every tool that spends credits takes three optional controls. With none of them the call runs exactly as before.',
694
+ '• get_cost: true: the call runs its own pricing and stops where it would spend. Nothing runs, nothing is queued and nothing is charged; the answer is the price in credits (and, for a render, what it sets aside while it runs). A step billed at its actual cost (an AI writing step, an ad-data lookup) has no fixed price, and the answer says so.',
695
+ '• expected_credits: N: run the call only at that price. The server prices it again first; if the live price differs (or has no fixed figure) the call is refused, unbilled, with the new price. Ask the user before resending at a new price.',
696
+ '• idempotency_key: a string up to 128 characters you choose ONCE per purchase and resend UNCHANGED on every retry of it (a timeout, a dropped connection, an error you are unsure of). For 24 hours the same key returns the ORIGINAL result instead of running and billing again; while the first call still runs it names the job to poll; the same key with different arguments is refused. Never invent a new key for a retry: a new key buys it a second time. A first call that failed having charged nothing frees its key, so the corrected retry runs.',
697
+ 'The flow: get_cost → tell the user the price → on their yes, call with expected_credits (the quoted number) and an idempotency_key.',
698
+ 'call_tool also takes `revision` (from find_tools): a tool that changed since is refused unrun with its current parameters, so rebuild the arguments and call again.',
699
+ '`context` (any tool, or on call_tool): the user\'s underlying goal in one short sentence, for analytics only. Never names, contact details or URLs; it is redacted anyway and never used to generate anything.',
700
+ ].join('\n');
701
+ // `context` from wherever it arrived (a declared arg, or the META carrier the arg guard / call_tool / v1 put on the
702
+ // parsed arguments), removed from what the handler sees, redacted.
703
+ export function takeAgentContext(args) {
704
+ if (!args || typeof args !== 'object') return { args, text: '' };
705
+ const raw = typeof args.context === 'string' ? args.context : (typeof args[CONTEXT_SYM] === 'string' ? args[CONTEXT_SYM] : '');
706
+ if (!raw) return { args, text: '' };
707
+ return { args: withoutArgKeys(args, ['context']), text: redactContext(raw) };
708
+ }
709
+ // A copy of the arguments without `keys`, keeping the non-enumerable markers (the arg guard's notes) the layers read.
710
+ export function withoutArgKeys(args, keys) {
711
+ if (!args || typeof args !== 'object' || !keys.some((k) => Object.prototype.hasOwnProperty.call(args, k))) return args;
712
+ const c = { ...args };
713
+ for (const k of keys) delete c[k];
714
+ for (const sym of Object.getOwnPropertySymbols(args)) { try { Object.defineProperty(c, sym, { value: args[sym], enumerable: false, configurable: true }); } catch {} }
715
+ return c;
716
+ }
717
+ const spendRefusal = (text, data = {}) => ({ content: [{ type: 'text', text }], structuredContent: data, isError: true });
718
+ // THE PRICE: the tool's own handler, every request it makes marked as a quote (client.mjs), each spending point stopped
719
+ // and priced by the server. Whatever the handler returns after a stop is discarded; only the tally is the answer.
720
+ async function quoteToolRun(tool, args, extra, st, fn) {
721
+ const qs = { tool, quoteOnly: true, quoteLegs: [], ...(st.account ? { account: st.account } : {}), ...(st.contextHeader ? { contextHeader: st.contextHeader } : {}) };
722
+ let r = null, err = null;
723
+ try { r = await toolCtx.run(qs, () => fn(args, extra)); } catch (e) { err = e; }
724
+ if (!qs.quoteLegs.length) {
725
+ if (err && !err._quoteStop) throw err; // refused before any spend (bad arguments, not connected…): the real call's own answer
726
+ if (r && r.isError) return { refusedResult: r };
727
+ }
728
+ return { quote: quoteFromLegs(qs.quoteLegs) };
729
+ }
730
+ export async function spendPreflight(tool, sm, args, extra, st, fn) {
731
+ if (sm.badKey) return spendRefusal('idempotency_key must be 1 to 128 printable characters. Nothing ran and nothing was charged.', { error: 'invalid_idempotency_key' });
732
+ if (Number.isNaN(sm.expected)) return spendRefusal('expected_credits must be a number (the credits get_cost quoted). Nothing ran and nothing was charged.', { error: 'invalid_expected_credits' });
733
+ if (!sm.getCost && sm.expected == null) return null;
734
+ const { quote: q, refusedResult } = await quoteToolRun(tool, args, extra, st, fn);
735
+ if (refusedResult) return refusedResult;
736
+ if (sm.getCost) return ok(spendQuoteText(tool, q), { getCost: true, credits: q.credits, ...(q.holdCredits ? { holdCredits: q.holdCredits } : {}), quote: q });
737
+ if (!priceMatches(sm.expected, q)) return spendRefusal(expectedMismatchText(tool, sm.expected, q), { error: 'price_changed', expected: sm.expected, credits: q.credits, quote: q });
738
+ return null; // the live price is the expected one: run it
739
+ }
740
+ // THE KEY: claimed on the server (scoped to the caller's account) before the handler runs; a replay, a still-running
741
+ // first call or a reused key with other arguments answers here and nothing runs.
742
+ export async function claimToolKey(tool, key, args, st) {
743
+ const hash = argsHash(tool, args);
744
+ let d;
745
+ try { d = await toolCtx.run({ tool }, () => apiPost('/api/tool-idempotency/claim', { tool, key, hash })); }
746
+ catch (e) { return { answer: spendRefusal(`The idempotency_key could not be checked (${String(e?.message || e).slice(0, 160)}), so nothing ran and nothing was charged. Call again with the same key.`, { error: 'idempotency_unavailable' }) }; }
747
+ if (d?.state === 'replay') {
748
+ const r = d.result && typeof d.result === 'object' ? d.result : { content: [] };
749
+ const content = [{ type: 'text', text: d.text }, ...(Array.isArray(r.content) ? r.content : [])];
750
+ return { answer: { ...r, content, structuredContent: { ...(r.structuredContent || {}), idempotentReplay: true, idempotencyKey: key } } };
751
+ }
752
+ if (d?.state === 'pending') return { answer: { content: [{ type: 'text', text: d.text }], structuredContent: { idempotencyPending: true, idempotencyKey: key, ...(Array.isArray(d.jobIds) && d.jobIds.length ? { jobId: d.jobIds[d.jobIds.length - 1], jobIds: d.jobIds } : {}) } } };
753
+ if (d?.state === 'conflict') return { answer: spendRefusal(d.text, { error: 'idempotency_key_reused', idempotencyKey: key }) };
754
+ if (d?.state !== 'claimed') return { answer: spendRefusal('The idempotency_key could not be claimed, so nothing ran and nothing was charged. Call again with the same key.', { error: 'idempotency_unavailable' }) };
755
+ st.idemKey = key; st.idemJobKey = jobKeyRoot(key, tool);
756
+ return { tool, key, hash };
757
+ }
758
+ // A finished call is SETTLED under its key (a retry gets this answer); a call that failed having charged nothing is
759
+ // offered for RELEASE, and the server releases it only when no job it queued is still running or was charged (so the
760
+ // corrected retry really runs). One that charged and then failed keeps its answer: the key bought it.
761
+ export async function finishToolKey(claim, st, r, err) {
762
+ const charged = Math.round(((st.apiCharged || 0) + (st.jobCharged || 0)) * 10) / 10;
763
+ const failed = !!err || !!r?.isError;
764
+ try {
765
+ if (!failed || charged > 0) {
766
+ const result = r || { content: [{ type: 'text', text: `The first call with this key failed after it started: ${String(err?.message || err).slice(0, 400)}` }], isError: true };
767
+ await toolCtx.run({ tool: claim.tool }, () => apiPost('/api/tool-idempotency/settle', { tool: claim.tool, key: claim.key, hash: claim.hash, result, charged }));
768
+ } else await toolCtx.run({ tool: claim.tool }, () => apiPost('/api/tool-idempotency/release', { tool: claim.tool, key: claim.key, hash: claim.hash }));
769
+ } catch { /* bookkeeping: the answer below is the call's own, and an unsettled claim ages out as stale */ }
770
+ }
671
771
  const wrap = (fn) => {
672
772
  // A NAMED closure, so registerTools' proxy can stamp the tool name onto the very function it registers and this
673
773
  // body can read it back. Reading a name off `fn` instead would be wrong: `fn` is the inner handler and the thing
@@ -690,7 +790,21 @@ const wrap = (fn) => {
690
790
  if (isRemote() && _tool !== 'upload_file') { const _lm = findClientLocalMedia(args, { served: null }); if (_lm) throw clientLocalMediaError(_lm); }
691
791
  const _st = { tool: _tool };
692
792
  if (typeof args?.account === 'string' && args.account.trim() && accountScopedTool(_tool)) _st.account = args.account.trim(); // see MULTI_ACCOUNT_CHANNELS
693
- const r = await toolCtx.run(_st, () => fn(args, extra));
793
+ // PRICE FIRST, BUY ONCE (spend-guard.mjs): the call controls never reach the handler; with none of them sent this
794
+ // is exactly the call it always was. `context` is lifted off every tool's arguments and rides as a header.
795
+ { const _cx = takeAgentContext(args); args = _cx.args; if (_cx.text) _st.contextHeader = contextHeaderValue(_cx.text); }
796
+ const _sm = isSpendToolName(_tool) ? takeSpendMeta(args) : null;
797
+ if (_sm) {
798
+ args = withoutArgKeys(args, ['get_cost', 'expected_credits', 'idempotency_key']);
799
+ const _early = await spendPreflight(_tool, _sm, args, extra, _st, fn);
800
+ if (_early) { try { recordToolOutcome(_tool, { ok: !_early.isError, ms: Date.now() - _t0 }); } catch {} return _early; }
801
+ }
802
+ const _claim = _sm?.key ? await claimToolKey(_tool, _sm.key, args, _st) : null;
803
+ if (_claim?.answer) return _claim.answer;
804
+ let r;
805
+ try { r = await toolCtx.run(_st, () => fn(args, extra)); }
806
+ catch (e) { if (_claim) await finishToolKey(_claim, _st, null, e); throw e; }
807
+ if (_claim) await finishToolKey(_claim, _st, r, null);
694
808
  try { recordToolOutcome(_tool, { ok: !r?.isError, ms: Date.now() - _t0 }); } catch {}
695
809
  // the charge, once, on every paid reply that did not already say it (chargeNote above)
696
810
  try {
@@ -2219,6 +2333,16 @@ export const INBOX_SOURCES = [
2219
2333
  // conversation id in its PATH, so `:id` is filled in by reply_to_inbox_item; that route sends to the customer only
2220
2334
  // with confirm:true, which reply_to_inbox_item passes because its own contract is "the user already said yes".
2221
2335
  { source: 'intercom', provider: 'intercom', read: '/api/intercom/conversations', reply: '/api/intercom/conversations/:id/reply', kind: 'support', label: 'Intercom conversation', query: { view: 'open' } },
2336
+ // TIKTOK AND LINKEDIN COMMENTS, added 2026-11-11 (connector gap audit C1/G1). Both channels had working per-post
2337
+ // readers and repliers (list/reply_to_tiktok_comment, list/reply_to_linkedin_comment) and NEITHER was a source here,
2338
+ // so the inbox that calls itself unified could not see the busiest comment stream a TikTok-first brand has. Both
2339
+ // vendors read comments PER POST, so each declares `needs` like YouTube does — with its OWN argument rather than
2340
+ // sharing `videoId`, because a YouTube id handed to TikTok (or the reverse) would read as a broken channel.
2341
+ // EACH REPLY NEEDS TWO IDS, so the native id carries both, joined by `|` (a character neither vendor's ids use):
2342
+ // TikTok wants the post id beside the comment id, LinkedIn wants the post urn beside the comment urn
2343
+ // (`parentComment`). normalizeInboxItem builds it from the read route's own answer; reply_to_inbox_item splits it.
2344
+ { source: 'tiktok', provider: 'tiktok_ads', read: '/api/tiktok-account/comments', reply: '/api/tiktok-account/comment-reply', kind: 'comment', label: 'TikTok comment', needs: { arg: 'tiktokVideoId', param: 'videoId', from: 'list_tiktok_videos' } },
2345
+ { source: 'linkedin', provider: 'linkedin', read: '/api/linkedin/comments', reply: '/api/linkedin/comment', kind: 'comment', label: 'LinkedIn comment', needs: { arg: 'linkedinPostUrn', param: 'postUrn', from: 'list_published_posts (the urn post_to_linkedin_page returned)' } },
2222
2346
  ];
2223
2347
 
2224
2348
  export const INBOX_SOURCE_NAMES = INBOX_SOURCES.map((s) => s.source);
@@ -2338,7 +2462,10 @@ export function normalizeInboxItem(source, row = {}) {
2338
2462
  // the author line, which is exactly the leak the Bluesky assertion already watches for. Skipping non-primitives
2339
2463
  // means a key can be listed once and read on whichever vendor actually spells it that way.
2340
2464
  const pick = (...keys) => { for (const k of keys) { const v = k.split('.').reduce((o, p) => (o == null ? o : o[p]), row); if (v != null && typeof v !== 'object' && String(v).trim()) return String(v).trim(); } return ''; };
2341
- const nativeId = pick('id', 'commentId', 'name', 'reviewId', 'tweet_id', 'messageId', 'eventId');
2465
+ // A TWO-PART NATIVE ID for the sources whose reply needs the post too (TikTok, LinkedIn): `<post>|<comment>`.
2466
+ // The read loop puts the envelope's post id on each row when the row lacks it, so both parts come from the vendor.
2467
+ const twoPart = source === 'tiktok' ? [pick('videoId'), pick('commentId')] : source === 'linkedin' ? [pick('postUrn'), pick('commentUrn')] : null;
2468
+ const nativeId = twoPart ? (twoPart[0] && twoPart[1] ? `${twoPart[0]}|${twoPart[1]}` : '') : pick('id', 'commentId', 'name', 'reviewId', 'tweet_id', 'messageId', 'eventId');
2342
2469
  // A REVIEW WITH A STAR RATING AND NO WORDS IS STILL AN ITEM. Google reviews are frequently rating-only, and
2343
2470
  // dropping them would hide the thing a brand most wants to answer. So text OR a rating is enough to keep it.
2344
2471
  // AND A DM WITH NO WORDS IS STILL AN ITEM (2026-10-25): a story mention, a shared reel, a photo or content Meta
@@ -2363,10 +2490,11 @@ export function normalizeInboxItem(source, row = {}) {
2363
2490
  // `from` and `sender*` are the DM spellings: Meta's conversation route hands back `from` as a display string
2364
2491
  // (its `from.name` sibling is the COMMENT shape) and Bluesky's hands back a handle, falling back to the DID
2365
2492
  // when the profile is not in the payload. A DID is ugly and it is still the truth about who wrote.
2366
- author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : [])) || 'unknown',
2493
+ author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : []), ...(source === 'tiktok' ? ['displayName'] : []), ...(source === 'linkedin' ? ['actor'] : [])) || 'unknown',
2367
2494
  text: text || icText,
2368
2495
  rating: rating == null ? undefined : rating,
2369
- at: (source === 'intercom' ? pick('updatedAt', 'createdAt') : pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt')) || '',
2496
+ // TikTok stamps `create_time` in UNIX SECONDS, which Date.parse cannot read, so it is converted here rather than sorted last.
2497
+ at: (source === 'tiktok' && Number(row.createTime) > 0 ? new Date(Number(row.createTime) * 1000).toISOString() : '') || (source === 'intercom' ? pick('updatedAt', 'createdAt') : pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt')) || '',
2370
2498
  permalink: pick('permalink', 'permalink_url', 'url', 'link') || '',
2371
2499
  // A MESSAGE THE BRAND ITSELF SENT NEEDS NO REPLY. `inbound === false` / `fromMe === true` is the DM way of
2372
2500
  // saying what `reviewReply.comment` says on a review, and without it `unansweredOnly` would put the brand's
@@ -2374,7 +2502,9 @@ export function normalizeInboxItem(source, row = {}) {
2374
2502
  answered: row.answered === true || !!pick('reviewReply.comment') || row.hasReplied === true
2375
2503
  || row.inbound === false || row.fromMe === true
2376
2504
  // Intercom's waiting_since is set while the CUSTOMER waits on the team and cleared once a teammate replies.
2377
- || (source === 'intercom' && !row.waitingSince),
2505
+ || (source === 'intercom' && !row.waitingSince)
2506
+ // A comment the BRAND wrote is not waiting for the brand: TikTok marks its own as `owner`, LinkedIn's actor is the Page.
2507
+ || (source === 'tiktok' && row.ownerComment === true) || (source === 'linkedin' && row.isOurs === true),
2378
2508
  canReply: !!src.reply,
2379
2509
  ...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
2380
2510
  // WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
@@ -2780,6 +2910,7 @@ export const WIDGET_WITHHELD_WHY = Object.freeze({
2780
2910
  create_reddit_ads_lead_form: 'Reddit retired onsite lead forms, so there is nothing it can create',
2781
2911
  save_location: 'a place is saved from the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places and any @name in a prompt uses one',
2782
2912
  delete_location: 'a place is removed in the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places',
2913
+ get_instructions: 'every tool description on this host is already the full one (SLIM_TOOL_DESCRIPTIONS is never applied here), so there is nothing to fetch',
2783
2914
  });
2784
2915
  export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS, ...Object.keys(WIDGET_WITHHELD_WHY)]);
2785
2916
  // remix_static is clone_static under its old name (kept for callers that cached it); on ChatGPT two tools for one
@@ -3685,11 +3816,48 @@ export async function ingestFileParam(tool, args, upload = apiUploadUrl) {
3685
3816
  rest[target] = u;
3686
3817
  return rest;
3687
3818
  }
3819
+ // ── THE HEAVIEST DESCRIPTIONS, SLIMMED; THE FULL TEXT ON DEMAND (2026-11-11) ──────────────────────────────────────────
3820
+ // Sixteen of the longest descriptions in the default roster (measured on the registry stub, 2026-11-11) were each
3821
+ // ~1.7-2.2K characters, re-sent on every turn by every host that does not defer schemas. Each is replaced HERE, per host,
3822
+ // by a subset of its own sentences, copied verbatim (the gates that prevent real mistakes stay: who is in frame, the
3823
+ // publish confirm, what spends credits, the never-guess-the-account rules), plus a one-line pointer. The ORIGINAL text is
3824
+ // unchanged at its registration and served whole by get_instructions({topic:'<tool>'}), so no rule is lost, only moved.
3825
+ // NEVER ON A WIDGET HOST (ChatGPT): its descriptions are OpenAI's reviewed snapshot, and a pointer to a tool it does not
3826
+ // list is exactly the wording OpenAI's scan holds (chatgpt-tool-scan-holds), so ChatGPT keeps every full description.
3827
+ export const SLIM_TOOL_DESCRIPTIONS = Object.freeze({
3828
+ make_thumbnail: 'Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any "thumbnail", "video cover" or MrBeast-style packaging ask INSTEAD of generate_image. Default: 4 variants at 1K (YouTube shows 1280x720 at most), each billed; 2K or 4K only when the user asks. dryRun:true prices it free. The headline overlay is free. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. ALWAYS check the returned postRenderCheck against the image before presenting it.',
3829
+ schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
3830
+ post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Settings > Connectors > X).',
3831
+ clip_video: 'Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. SUBTITLES ARE BURNED IN BY DEFAULT (white sentence case timed by the spoken words, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync.',
3832
+ post_to_bluesky: 'Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Returns the post\'s public bsky.app URL. Connect at Settings > Connectors > Bluesky.',
3833
+ make_explainer: 'AI HOST EPISODE: format \'host_episode\' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.',
3834
+ multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and any burned-in captions are removed. ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. No ad: list_winning_ads. New words: rewrite_ad_line.',
3835
+ set_post_refill: 'Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled).',
3836
+ post_to_linkedin_page: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
3837
+ post_to_telegram: 'Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand\'s own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel\'s @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. 0 credits. Connect at Settings > Connectors > Telegram.',
3838
+ generate_image: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product, or an ANIMATED GIF (format gif) or SVG file (format svg). It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand’s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. model = a catalog id from hermoso_capabilities (omit for the default). Seconds. Spends credits.',
3839
+ make_motion_film: 'MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. Pick it for \'animated video\', \'motion graphics\', \'kinetic text\', \'text animation\', \'logo animation\', \'logo intro\', \'animated explainer\' asks. STEPS: without confirm it is a FREE quote that gives both prices (length, the music\'s tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. Only the user\'s or the brand\'s words appear: never invent a claim, number or review.',
3840
+ post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
3841
+ hook_variants: 'HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS, N complete versions to A/B test. Pass the video\'s FILE URL (a render, job result, list_library or upload_file). 1-5 versions, default 3. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each planned version bills like fix_beat for the hook\'s seconds. dryRun:true pays for that read; pass its `plan` back to render exactly those. Returns ONE JOB PER VERSION; call get_job on each until done, never describe a version before its URL arrives.',
3842
+ post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings > Connectors > X).',
3843
+ render_ad: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand’s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
3844
+ });
3845
+ export const slimPointer = (name) => ` Full rules: get_instructions({topic:'${name}'}).`;
3846
+ const _slimmed = new WeakMap();
3847
+ // The canonical (full) description of each slimmed tool, recorded the first time the def passes through, for get_instructions.
3848
+ export const FULL_TOOL_DESCRIPTIONS = new Map();
3849
+ function slimDef(name, def) {
3850
+ if (!def || typeof def !== 'object' || !Object.prototype.hasOwnProperty.call(SLIM_TOOL_DESCRIPTIONS, name)) return def;
3851
+ if (typeof def.description === 'string') FULL_TOOL_DESCRIPTIONS.set(name, def.description); // the latest canon's (a stdio and a hosted canon can differ)
3852
+ let o = _slimmed.get(def);
3853
+ if (!o) { o = { ...def, description: SLIM_TOOL_DESCRIPTIONS[name] + slimPointer(name) }; _slimmed.set(def, o); }
3854
+ return o;
3855
+ }
3688
3856
  const _widgetDescribed = new WeakMap();
3689
3857
  export function defForHost(name, def, widgetHost) {
3690
3858
  if (widgetHost) def = withFileParam(name, def);
3691
3859
  explicitHints(def);
3692
- let out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def), name) : stripOutputSchema(def);
3860
+ let out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def), name) : slimDef(name, stripOutputSchema(def));
3693
3861
  if (widgetHost && out && Object.prototype.hasOwnProperty.call(WIDGET_HOST_DESCRIPTIONS, name)) {
3694
3862
  let o = _widgetDescribed.get(out); if (!o) { o = { ...out, description: WIDGET_HOST_DESCRIPTIONS[name] }; _widgetDescribed.set(out, o); } out = o;
3695
3863
  }
@@ -4402,6 +4570,8 @@ export function guardUnknownArgs(server) {
4402
4570
  }
4403
4571
  }
4404
4572
  const out = await orig.call(this, tool, args, toolName);
4573
+ // `context` (a META argument, arg-guard.mjs): the parse above stripped it unless the tool declares it; carried here.
4574
+ if (out && typeof out === 'object' && typeof args?.context === 'string' && args.context && !Object.prototype.hasOwnProperty.call(out, 'context')) { try { Object.defineProperty(out, CONTEXT_SYM, { value: args.context, enumerable: false, configurable: true }); } catch {} }
4405
4575
  if (unknown.length && mode !== 'off' && out && typeof out === 'object') { try { Object.defineProperty(out, UNKNOWN_ARGS_SYM, { value: unknownArgsNote(toolName, unknown, declared), enumerable: false, configurable: true }); Object.defineProperty(out, UNKNOWN_ARGS_DETAIL_SYM, { value: unknownArgsDetail(unknown, declared), enumerable: false, configurable: true }); } catch {} }
4406
4576
  return out;
4407
4577
  };
@@ -4581,6 +4751,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4581
4751
  try { if (handler) handler._hermosoTool = name; } catch {}
4582
4752
  try { noteBrandKeys(name, def); } catch {} // which arg names the Hermoso brand, for the after-reply notes
4583
4753
  def = withAccountArg(name, def); // a multi-account channel's tool names WHICH account (see MULTI_ACCOUNT_CHANNELS)
4754
+ def = withSpendArgs(name, def, group); // a paying tool takes get_cost / expected_credits / idempotency_key (spend-guard.mjs)
4584
4755
  // TITLE IS MIRRORED INTO `annotations`, and it is deliberately a DUPLICATE rather than a move.
4585
4756
  // The MCP spec ranks the TOP-LEVEL `title` above `annotations.title`, and the SDK emits it top-level, so
4586
4757
  // that is the correct home and it stays. But Anthropic's directory requirements list `title` AMONG the
@@ -4713,6 +4884,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
4713
4884
  }));
4714
4885
  } catch { return {}; }
4715
4886
  };
4887
+ // A TOOL'S REVISION (2026-11-11): 10 hex characters of its description plus its argument schema, so a call_tool that
4888
+ // names an older revision is refused with the current shape (see makeCallToolHandler). Cached per handle.
4889
+ const _revCache = new WeakMap();
4890
+ const toolRevision = (h) => {
4891
+ if (!h) return '';
4892
+ if (_revCache.has(h)) return _revCache.get(h);
4893
+ let js = '';
4894
+ try { js = h.inputSchema ? JSON.stringify(z.toJSONSchema(h.inputSchema)) : ''; } catch { js = ''; }
4895
+ const rev = createHash('sha256').update(String(h.description || '') + '\n' + js).digest('hex').slice(0, 10);
4896
+ try { _revCache.set(h, rev); } catch {}
4897
+ return rev;
4898
+ };
4716
4899
  // ── QUERY SELF-HEAL (2026-09-03) ───────────────────────────────────────────────────────────────────────────────
4717
4900
  // The ledger's `no_match` dead ends are what users ask for IN THEIR OWN WORDS, and the first real one was a user
4718
4901
  // saying "instant forms" for what our catalog calls create_meta_lead_form. A search that only matches literal
@@ -5014,8 +5197,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
5014
5197
  const _oaFit = _otherAppRows.filter((r) => r.score >= _bestHere && (r.full || r.score >= 1.4 * _bestHere)).sort((x, y) => y.score - x.score);
5015
5198
  const _homes = [...new Set(_oaFit.filter((r) => r.score >= 0.8 * _oaFit[0]?.score).slice(0, 3).map((r) => APP_HOME_TEXT(r.name, ctx)))];
5016
5199
  if (!related && !quiet && !_homes.length && /[a-z0-9]{2}/i.test(String(q || ''))) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
5017
- for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
5018
- const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
5200
+ for (const r of top) { r.params = compactParams(ctx.handleOf[r.name]); r.revision = toolRevision(ctx.handleOf[r.name]); }
5201
+ const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)} · revision: ${r.revision}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
5019
5202
  const looser = top.length > total ? top.length - total : 0;
5020
5203
  const text = related
5021
5204
  ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing the top ${cap} — narrow the query)` : ''}${looser ? `${total ? '; the other' : ''} ${looser} shown ${looser === 1 ? 'is a looser match' : 'are looser matches'}, ranked below` : ''}. ${ctx.widgetHost ? 'Call any of them directly by name; one marked not_connected needs that connector first.' : 'Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first.'} COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
@@ -5079,9 +5262,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
5079
5262
  const bare = raw.replace(/^mcp__[A-Za-z0-9_-]*?__(?=[a-z])/, '').replace(/^hermoso[:./]/i, '');
5080
5263
  return handleOf[bare] ? bare : raw;
5081
5264
  };
5082
- const makeCallToolHandler = (ctx) => async ({ name, args } = {}, extra) => {
5265
+ const makeCallToolHandler = (ctx) => async ({ name, args, revision, context } = {}, extra) => {
5083
5266
  const n = bareToolName(String(name || '').trim(), ctx.handleOf);
5084
5267
  const h = ctx.handleOf[n];
5268
+ // `context` may ride on call_tool itself or inside args (a META argument, arg-guard.mjs); it is never an argument of the tool.
5269
+ const _ctxText = typeof context === 'string' && context ? context : (args && typeof args === 'object' && typeof args.context === 'string' ? args.context : '');
5270
+ if (args && typeof args === 'object' && Object.prototype.hasOwnProperty.call(args, 'context')) { args = { ...args }; delete args.context; }
5085
5271
  // A RETIRED NAME ANSWERS THE SAME SENTENCE HERE AS IT DOES ON A DIRECT CALL (2026-09-17). LEGACY_TOOL_NAMES was
5086
5272
  // built for `tools/call` from a host holding an old snapshot and wired ONLY into installHeldToolCalls — so an
5087
5273
  // agent that reached the same name through call_tool got "does not exist" plus the six wrong suggestions above,
@@ -5117,6 +5303,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
5117
5303
  // A WIDGET HOST RUNS ONLY WHAT IT LISTS (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Reached
5118
5304
  // only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
5119
5305
  if (ctx.widgetHost && h.enabled === false) return { content: [{ type: 'text', text: `${n} is not in your Hermoso tool list on this host, so call_tool will not run it. Use the tools in your list.` }], isError: true };
5306
+ // A STALE SCHEMA IS REFUSED, NOT HALF-RUN (2026-11-11, a describe → invoke revision handshake):
5307
+ // find_tools gives each tool a `revision`; a call that names an old one gets the CURRENT parameters and revision
5308
+ // back and runs nothing, so the agent re-reads instead of sending arguments the tool no longer means.
5309
+ if (typeof revision === 'string' && revision.trim() && revision.trim() !== toolRevision(h)) {
5310
+ const params = Object.entries(compactParams(h)).map(([k, v]) => `${k}: ${v}`).join(' | ');
5311
+ return { content: [{ type: 'text', text: `${n} has changed since revision ${revision.trim()}: nothing was run. Current revision ${toolRevision(h)}. Current params: ${params || '(none)'}. Rebuild the arguments from these and call again with revision "${toolRevision(h)}".` }], structuredContent: { error: 'stale_revision', name: n, revision: toolRevision(h), params: compactParams(h) }, isError: true };
5312
+ }
5120
5313
  let input = args && typeof args === 'object' ? args : {};
5121
5314
  const extraKeys = undeclaredArgs(h, input);
5122
5315
  if (extraKeys.length) {
@@ -5136,8 +5329,23 @@ function buildTools(rawServer, opts = {}, sink = null) {
5136
5329
  // rename in the dependency shows up here as a loud "not a function" rather than a silently skipped call.
5137
5330
  const fn = h.handler || h.callback;
5138
5331
  if (typeof fn !== 'function') return { content: [{ type: 'text', text: `${n} is registered but has no runnable handler on this server build.` }], isError: true };
5332
+ if (_ctxText && input && typeof input === 'object') { try { Object.defineProperty(input, CONTEXT_SYM, { value: _ctxText, enumerable: false, configurable: true }); } catch {} }
5139
5333
  return h.inputSchema ? await fn(input, extra) : await fn(extra);
5140
5334
  };
5335
+ // THE FULL TEXT, ON DEMAND (2026-11-11): see SLIM_TOOL_DESCRIPTIONS. A slimmed tool's whole original description, any
5336
+ // tool's canonical description, or the guide to the paid-call controls. Free, read-only, no request leaves this process.
5337
+ server.registerTool('get_instructions', {
5338
+ title: 'Read a tool\'s full instructions',
5339
+ description: 'Return the FULL instructions for a Hermoso tool whose description ends "Full rules: get_instructions(…)", or for any tool by name, or topic "paid-calls" for get_cost, expected_credits, idempotency_key, revision and context. Read it before a call that depends on rules the short description leaves out. Free.',
5340
+ inputSchema: { topic: z.string().describe('a tool name (e.g. make_thumbnail) or "paid-calls"') },
5341
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
5342
+ }, wrap(async (a) => {
5343
+ const t = String(a.topic || '').trim().replace(/^mcp__[A-Za-z0-9_-]*?__(?=[a-z])/, '');
5344
+ if (/^(paid[-_ ]?calls?|get_cost|expected_credits|idempotency(_key)?|costs?|revision|context)$/i.test(t)) return ok(PAID_CALLS_GUIDE, { topic: 'paid-calls' });
5345
+ const full = FULL_TOOL_DESCRIPTIONS.get(t) || (Array.isArray(TOOL_CANON) ? TOOL_CANON.find((e) => e.name === t)?.def?.description : '') || '';
5346
+ if (full) return ok(`${t} — full instructions:\n${full}`, { topic: t, slimmed: Object.prototype.hasOwnProperty.call(SLIM_TOOL_DESCRIPTIONS, t) });
5347
+ return { content: [{ type: 'text', text: `No instructions for "${t}". Pass a tool name exactly as listed (find_tools finds one) or "paid-calls". Slimmed tools: ${Object.keys(SLIM_TOOL_DESCRIPTIONS).join(', ')}.` }], isError: true };
5348
+ }));
5141
5349
  server.registerTool('find_tools', {
5142
5350
  title: 'Find a Hermoso tool by name or task',
5143
5351
  description: 'Search EVERY Hermoso tool — your starting list is deliberately short, and everything else in the product is here — by name, task or group. Each row gives the tool\'s PARAMETERS in one line, its CREDIT COST (free means free on every plan; a tool that runs a model quotes the live per-model figure) and its recent HEALTH on this server (failure rate and typical duration, or "no recent calls", which means unseen and not broken). Use it the moment the user asks for something you do not see a tool for (a campaign, an ad set, a lead form, a click-to-WhatsApp ad, a report, keywords, audiences): a tool missing from your list is NEVER proof the feature is missing. Then run the tool with call_tool. A tool that is failing or needs a connector this workspace has not made is ranked last and marked, never hidden — pass onlyHealthy:true if you want those left out. Free, read-only.',
@@ -5155,6 +5363,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
5155
5363
  inputSchema: {
5156
5364
  name: z.string().describe('the tool name exactly as find_tools returned it, e.g. create_meta_lead_form'),
5157
5365
  args: z.record(z.any()).optional().describe('the tool\'s arguments as an object, exactly as its own schema takes them'),
5366
+ revision: z.string().optional().describe('the revision find_tools gave for this tool; a changed tool is refused unrun, with its current params'),
5367
+ context: z.string().optional().describe(CONTEXT_ARG_TEXT + '; every tool also accepts it directly'),
5158
5368
  },
5159
5369
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
5160
5370
  }, sessionBound(makeCallToolHandler, ctx));
@@ -5683,7 +5893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5683
5893
  server.registerTool('list_inbox', {
5684
5894
  title: 'One inbox for comments, replies, mentions, reviews and DMs',
5685
5895
  description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
5686
- + "Instagram comments, Threads replies and mentions, YouTube comments, Google Business reviews, "
5896
+ + "Instagram comments, Threads replies and mentions, YouTube comments, TikTok comments, LinkedIn Page comments, Google Business reviews, "
5687
5897
  + "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
5688
5898
  + "Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
5689
5899
  + "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
@@ -5691,12 +5901,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
5691
5901
  + "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
5692
5902
  + "Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, "
5693
5903
  + "image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need "
5694
- + "conversationId (list_bluesky_convos / list_x_dms). FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
5904
+ + "conversationId (list_bluesky_convos / list_x_dms). TikTok and LinkedIn comments are read per post: pass tiktokVideoId / linkedinPostUrn. FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
5695
5905
  + "and every other source only re-reads what the per-channel tools already read.",
5696
5906
  inputSchema: {
5697
5907
  sources: z.array(z.string()).optional().describe(`Limit to these sources: ${INBOX_SOURCE_NAMES.join(', ')}. Omit for every connected channel.`),
5698
5908
  postId: z.string().optional().describe('Restrict Facebook/Instagram to one post or media id (Meta reads comments per post).'),
5699
5909
  videoId: z.string().optional().describe('Restrict YouTube to one video.'),
5910
+ tiktokVideoId: z.string().optional().describe('Include the comments on this TikTok post (its id, from list_tiktok_videos). TikTok reads comments per post.'),
5911
+ linkedinPostUrn: z.string().optional().describe('Include the comments on this LinkedIn Page post (its urn, as post_to_linkedin_page returned). LinkedIn reads comments per post.'),
5700
5912
  // DECLARED BECAUSE A SOURCE ASKS FOR IT. Before 2026-08-25 `meta_dm` declared `needs.arg: conversationId`
5701
5913
  // and this schema did not carry it, so the note telling a caller to pass it named an argument the tool
5702
5914
  // refused — and the handler quietly read `postId` for anything that was not `videoId`. The check now
@@ -5712,7 +5924,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5712
5924
  notes: z.array(z.string()).describe('sources that could not be read, and why — never silently dropped'),
5713
5925
  },
5714
5926
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
5715
- }, wrap(async ({ sources, postId, videoId, conversationId, pageId, limit, unansweredOnly }) => {
5927
+ }, wrap(async ({ sources, postId, videoId, tiktokVideoId, linkedinPostUrn, conversationId, pageId, limit, unansweredOnly }) => {
5716
5928
  const want = Array.isArray(sources) && sources.length
5717
5929
  ? sources.map((x) => String(x).trim().toLowerCase())
5718
5930
  : INBOX_SOURCE_NAMES;
@@ -5724,9 +5936,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5724
5936
  // argument that was not `videoId` collapsed onto `postId` — which meant a source declaring a THIRD argument
5725
5937
  // silently read the wrong caller's id and could never be satisfied by the note it printed. A map cannot do
5726
5938
  // that: an arg this tool does not accept is simply absent, and absent is what produces the helpful note.
5727
- const argValues = { postId, videoId, conversationId };
5939
+ const argValues = { postId, videoId, tiktokVideoId, linkedinPostUrn, conversationId };
5728
5940
  for (const src of INBOX_SOURCES.filter((x) => want.includes(x.source))) {
5729
- const q = { limit: per };
5941
+ const q = src.source === 'tiktok' ? { maxCount: per } : { limit: per }; // TikTok's page size is max_count
5730
5942
  if (src.needs) {
5731
5943
  // These sources read PER POST (or, for a DM, per conversation), so without an id there is nothing to ask
5732
5944
  // for. Saying so is very different from letting the vendor 400 and filing it as "could not be read": one
@@ -5760,7 +5972,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
5760
5972
  // conversation, so folding them would collapse every chat into one — keying the fold on `kind` would do
5761
5973
  // exactly that. The trigger is therefore the field the fold consumes, not the category.
5762
5974
  const use = src.dmReplyId ? foldConversationRows(rows, d?.[src.dmReplyId]) : rows;
5763
- for (const r of use) { const it = normalizeInboxItem(src.source, src.dmReplyId && typeof d?.conversationId === 'string' && !r.conversationId ? { ...r, conversationId: d.conversationId } : r); if (it) items.push(it); }
5975
+ // The per-post comment sources carry their post on the ENVELOPE; it is the first half of their two-part id.
5976
+ const withPost = (r) => (src.source === 'tiktok' && !r.videoId && d?.videoId ? { ...r, videoId: d.videoId } : src.source === 'linkedin' && !r.postUrn && d?.postUrn ? { ...r, postUrn: d.postUrn } : r);
5977
+ for (const r0 of use) { const r = withPost(r0); const it = normalizeInboxItem(src.source, src.dmReplyId && typeof d?.conversationId === 'string' && !r.conversationId ? { ...r, conversationId: d.conversationId } : r); if (it) items.push(it); }
5764
5978
  } catch (e) {
5765
5979
  // AN ID THAT DOES NOT RESOLVE ENDS THE CALL AS AN ERROR (2026-10-26, customer report: an unconnected pageId
5766
5980
  // answered "0 items"). The caller named a Page; reading nothing from it is not a quiet inbox, it is a wrong id.
@@ -5803,6 +6017,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
5803
6017
  else if (t.source === 'threads' || t.source === 'threads_mention') payload.replyToId = t.nativeId;
5804
6018
  else if (t.source === 'youtube') payload.parentId = t.nativeId;
5805
6019
  else if (t.source === 'google_business') payload.reviewName = t.nativeId;
6020
+ // TWO-PART ids (see normalizeInboxItem): TikTok wants the post beside the comment, LinkedIn the post urn beside the
6021
+ // comment urn it replies under. A malformed id is refused here rather than sent with half of what the vendor needs.
6022
+ else if (t.source === 'tiktok' || t.source === 'linkedin') {
6023
+ const cut = String(t.nativeId).lastIndexOf('|');
6024
+ const post = cut > 0 ? t.nativeId.slice(0, cut) : '', cmt = cut > 0 ? t.nativeId.slice(cut + 1) : '';
6025
+ if (!post || !cmt) return { content: [{ type: 'text', text: `"${String(id).slice(0, 80)}" is missing its post or comment half. Re-read list_inbox for a current id.` }], isError: true };
6026
+ if (t.source === 'tiktok') { payload.videoId = post; payload.commentId = cmt; } else { payload.postUrn = post; payload.parentComment = cmt; }
6027
+ }
5806
6028
  // Bluesky's native id is the post's AT-URI. The route resolves the two strong refs (root AND parent, each a
5807
6029
  // uri+cid pair) from it with one getPosts call — carrying four values on the wire would mean four ways for a
5808
6030
  // caller to detach a reply from its thread, and the cid is a content hash nobody but the server can keep fresh.
@@ -9918,7 +10140,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9918
10140
  locales: z.array(z.number()).optional().describe('Meta language ids from find_meta_audiences(type:"adlocale")'),
9919
10141
  publisherPlatforms: z.array(z.enum(['facebook', 'instagram', 'audience_network', 'messenger', 'threads'])).optional().describe('placement platforms: facebook, instagram, audience_network, messenger or threads'),
9920
10142
  facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, search, instream_video, right_hand_column, …'),
9921
- instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, explore, profile_feed, …'),
10143
+ instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, profile_feed, profile_reels, ig_search, explore_home. "explore_home" needs "stream" beside it; the old "explore" placement is retired by Meta and refused'),
9922
10144
  messengerPositions: z.array(z.string()).optional().describe('Messenger placements, e.g. sponsored_messages'), audienceNetworkPositions: z.array(z.string()).optional().describe('Audience Network placement names, as Meta defines them'),
9923
10145
  threadsPositions: z.array(z.enum(['threads_stream'])).optional().describe('Threads feed. Threads needs "instagram" in publisherPlatforms too (and "stream" if you list instagramPositions); the ad runs under the Threads profile linked to the Instagram account, found automatically'),
9924
10146
  devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional().describe('mobile or desktop'), userOs: z.array(z.enum(['iOS', 'Android'])).optional().describe('iOS or Android'),
@@ -23552,6 +23774,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23552
23774
  frames: z.number().int().min(2).max(16).optional().describe('format gif only: frames in the loop, 2 to 16 (default 9)'),
23553
23775
  frameMs: z.number().int().min(20).max(2000).optional().describe('format gif only: milliseconds per frame, 20 to 2000, rounded to 10 (default 200; 9 frames x 200 = a 1.8 s loop)'),
23554
23776
  complexity: z.enum(['low', 'medium', 'high']).optional().describe('only with model gpt-image-2.6 / 2.6-flare (the default refuses it), when the user asks: how much it plans before drawing (default high). No separate charge'),
23777
+ background: z.enum(['transparent']).optional().describe('transparent = a PNG with NO background (alpha channel): a cut-out product, icon, sticker or logo to place on anything. Runs on gpt-image-2.6 (default) or gpt-image-2.6-flare, drawn from the prompt alone (no refImages, mask or markup; the saved brand\'s photos stay off it), priced like a plain image of that size. The reply\'s `background` says what the file really has. To cut the background out of a photo the user already has, point them to the Remove background tool in the Hermoso app'),
23555
23778
  imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
23556
23779
  fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check; a re-print costs more, and dryRun:true returns the up-to price). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
23557
23780
  logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: the scene leaves a blank patch on the object and the real file is printed onto it (perspective, light). Never drawn by the model. auto (default): from the prompt'),
@@ -23563,13 +23786,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
23563
23786
  productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
23564
23787
  logoPlaced: z.any().optional().describe('how the real logo file went on: {applied, mode: in_scene|fix|overlay, object?, fellBack?, notALogo?, asked?}; notALogo = the saved logo is a photo, so nothing was added (ask for the logo file only when the logo was asked for; asked:false means nobody asked for one, so say nothing about it)'),
23565
23788
  format: z.string().optional().describe('gif or svg when one was asked for'),
23789
+ background: z.string().optional().describe('transparent or opaque, read off the delivered file, when a transparent background was asked for'),
23566
23790
  preview: z.string().optional().describe('an SVG\'s PNG preview, for places that cannot show a vector'),
23567
23791
  frames: z.number().optional().describe('an animated GIF\'s frame count, read off the delivered file'),
23568
23792
  labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
23569
23793
  },
23570
23794
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
23571
23795
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
23572
- }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job, format, frames, frameMs, complexity }) => {
23796
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job, format, frames, frameMs, complexity, background }) => {
23573
23797
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
23574
23798
  const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
23575
23799
  // `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
@@ -23579,8 +23803,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
23579
23803
  // NO MODEL AND NO JOB = AN IMAGE AD (2026-11-03): this tool is what an agent reaches for to make an image ad, so an
23580
23804
  // unnamed render takes the static_ad job's engine (judged in the job-engine registry) rather than the catalog's
23581
23805
  // general first row. A masked edit keeps its own rule (the server routes a mask to the one engine that takes it).
23582
- if (!job && !model && !maskRef && format !== 'gif' && format !== 'svg') job = 'static_ad'; // a GIF / SVG picks its own engine (GPT Image 2.6)
23583
- const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}), ...(format ? { format } : {}), ...(frames != null ? { frames } : {}), ...(frameMs != null ? { frameMs } : {}), ...(complexity ? { complexity } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
23806
+ if (!job && !model && !maskRef && format !== 'gif' && format !== 'svg' && background !== 'transparent') job = 'static_ad'; // a GIF / SVG / transparent PNG picks its own engine (GPT Image 2.6)
23807
+ const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}), ...(format ? { format } : {}), ...(frames != null ? { frames } : {}), ...(frameMs != null ? { frameMs } : {}), ...(complexity ? { complexity } : {}), ...(background ? { background } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
23584
23808
  // A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
23585
23809
  // `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
23586
23810
  // handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
@@ -27289,8 +27513,9 @@ function memoryNoteVerdict(text) {
27289
27513
  return ok(`${d?.message || ''}${items ? '\n' + items : ''}${kept ? '\n' + kept : ''}${nf}`, { deleted: !!d?.deleted, needsConfirm: !!d?.needsConfirm });
27290
27514
  }));
27291
27515
 
27292
- // THE PUBLIC GALLERY (2026-11-10, lib/gallery.mjs): opt-in per item, reviewed by the Hermoso team before it is public,
27293
- // withdrawable at once. Held on demand (ON_DEMAND_TOOLS); each reply names the next tool.
27516
+ // THE PUBLIC GALLERY (v2 2026-11-11, lib/gallery.mjs): one Publish action, no form. Title, format and niche are read off
27517
+ // the creation; the brand name shows. Reviewed by the Hermoso team before it is public, unpublished at once. Held on
27518
+ // demand (ON_DEMAND_TOOLS); each reply names the next tool.
27294
27519
  const galleryUrlOf = async (id, url) => {
27295
27520
  if (url) return String(url);
27296
27521
  const m = /^L(\d+)$/i.exec(String(id || '').trim()); if (!m) return '';
@@ -27298,25 +27523,20 @@ function memoryNoteVerdict(text) {
27298
27523
  return (Array.isArray(list) ? list.filter((x) => x && x.url) : [])[Number(m[1])]?.url || '';
27299
27524
  };
27300
27525
  server.registerTool('submit_to_gallery', {
27301
- title: 'Submit to the Hermoso gallery',
27302
- description: "Submit ONE image or video this brand made in Hermoso to the public Hermoso gallery. Only when the user asks. They must confirm they have the rights to it, including any people and logos in it (rights:true); their brand name shows only with showBrand:true. Nothing is public until the Hermoso team approves it. preview:true returns what would be public and saves nothing. Free.",
27526
+ title: 'Publish to the Hermoso gallery',
27527
+ description: "Publish ONE image or video this brand made in Hermoso to the public Hermoso gallery, only when the user asks for that item. Nothing to fill in: the title, format and niche come from how it was made, and the brand name is shown. Nothing is public until the Hermoso team approves it; withdraw_from_gallery unpublishes it any time. Uploaded files are refused. preview:true returns what would be public and saves nothing. Free.",
27303
27528
  inputSchema: {
27304
27529
  id: z.string().optional().describe('Library id (L0…) from list_library'),
27305
27530
  url: z.string().optional().describe('or the file link'),
27306
- rights: z.boolean().optional().describe('true only after the user confirmed the rights'),
27307
- showBrand: z.boolean().optional().describe('show the brand name (default false)'),
27308
- title: z.string().optional(),
27309
- format: z.enum(['ugc_video', 'product_video', 'talking_avatar', 'motion', 'product_photo', 'static_ad', 'carousel', 'other']).optional(),
27310
- niche: z.enum(['beauty', 'fashion', 'food', 'fitness', 'health', 'tech', 'home', 'pets', 'finance', 'education', 'travel', 'local', 'other']).optional(),
27311
27531
  preview: z.boolean().optional(),
27312
27532
  },
27313
27533
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
27314
- }, wrap(async ({ id, url, rights, showBrand, title, format, niche, preview }) => {
27534
+ }, wrap(async ({ id, url, preview }) => {
27315
27535
  const u = await galleryUrlOf(id, url);
27316
- if (!u) return ok('Pass a Library id (L0… from list_library) or the file link. Nothing was submitted.', { submitted: false });
27317
- const d = await apiPost('/api/gallery/submit', { url: u, rights: rights === true, showBrand: showBrand === true, ...(title ? { title } : {}), ...(format ? { format } : {}), ...(niche ? { niche } : {}), ...(preview ? { preview: true } : {}) });
27318
- if (d?.preview) { const p = d.public || {}; return ok(`Would be public:\n Title: ${p.title}\n Brand: ${p.brandShown || 'hidden'}\n${(p.recipe || []).map((r) => ` ${r.k}: ${r.v}`).join('\n')}\nNothing was saved. Ask the user to confirm the rights, then call again with rights:true.`, { preview: true, public: p }); }
27319
- return ok(`${d?.message || 'Submitted.'} Status: ${d?.status}${d?.publicUrl ? ` · ${d.publicUrl}` : ''} (id ${d?.id}). list_gallery_submissions shows it; withdraw_from_gallery takes it down.`, { submitted: true, id: d?.id, status: d?.status, publicUrl: d?.publicUrl || null });
27536
+ if (!u) return ok('Pass a Library id (L0… from list_library) or the file link. Nothing was published.', { submitted: false });
27537
+ const d = await apiPost('/api/gallery/submit', { url: u, via: 'agent', ...(preview ? { preview: true } : {}) });
27538
+ if (d?.preview) { const p = d.public || {}; return ok(`Would be public:\n Title: ${p.title}\n Brand: ${p.brandShown || 'none on the brand'}\n${(p.recipe || []).map((r) => ` ${r.k}: ${r.v}`).join('\n')}\nNothing was saved. Call again without preview to publish it.`, { preview: true, public: p }); }
27539
+ return ok(`${d?.message || 'Sent for review.'} Status: ${d?.status}${d?.publicUrl ? ` · ${d.publicUrl}` : ''} (id ${d?.id}). list_gallery_submissions shows it; withdraw_from_gallery unpublishes it.`, { submitted: true, id: d?.id, status: d?.status, publicUrl: d?.publicUrl || null });
27320
27540
  }));
27321
27541
  server.registerTool('list_gallery_submissions', {
27322
27542
  title: 'List gallery submissions',
@@ -27327,17 +27547,17 @@ function memoryNoteVerdict(text) {
27327
27547
  const d = await apiGet('/api/gallery/mine', all ? { all: '1' } : {});
27328
27548
  const rows = d?.submissions || [];
27329
27549
  if (!rows.length) return ok('Nothing from this brand is in the gallery or waiting for review. submit_to_gallery adds one when the user asks.', { submissions: [] });
27330
- return ok(rows.map((r) => `${r.id} · ${r.status}${r.publicUrl ? ` · ${r.publicUrl}` : ''} · ${r.title} · ${abs(r.url)}`).join('\n') + '\nwithdraw_from_gallery takes one down.', { submissions: rows });
27550
+ return ok(rows.map((r) => `${r.id} · ${r.status}${r.publicUrl ? ` · ${r.publicUrl}` : ''} · ${r.title} · ${abs(r.url)}`).join('\n') + '\nwithdraw_from_gallery unpublishes one.', { submissions: rows });
27331
27551
  }));
27332
27552
  server.registerTool('withdraw_from_gallery', {
27333
27553
  title: 'Withdraw from the gallery',
27334
- description: 'Take a submission off the public Hermoso gallery at once (its page stops working), whether pending or approved. Pass the submission id from list_gallery_submissions, or the file link. Free.',
27554
+ description: 'Unpublish: take an item off the public Hermoso gallery at once (its page stops working), whether pending or approved. Pass the submission id from list_gallery_submissions, or the file link. Free.',
27335
27555
  inputSchema: { id: z.string().optional().describe('submission id (g_…)'), url: z.string().optional().describe('or the file link') },
27336
27556
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
27337
27557
  }, wrap(async ({ id, url }) => {
27338
27558
  if (!id && !url) return ok('Pass the submission id (list_gallery_submissions) or the file link. Nothing was changed.', { withdrawn: [] });
27339
27559
  const d = await apiPost('/api/gallery/withdraw', { ...(id ? { id } : {}), ...(url ? { url } : {}) });
27340
- return ok(d?.message || 'Withdrawn.', { withdrawn: d?.withdrawn || [] });
27560
+ return ok(d?.message || 'Unpublished.', { withdrawn: d?.withdrawn || [] });
27341
27561
  }));
27342
27562
 
27343
27563
  server.registerTool('fetch_asset', {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.531",
3
+ "version": "0.1.532",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1198 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
5
+ "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1199 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"