hermoso 0.1.435 → 0.1.437

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **922 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **923 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **No markup on models.** Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
242
242
 
243
243
  Then ask your agent: *“Generate an image ad with Hermoso.”*
244
244
 
245
- ### What the 922 tools cover
245
+ ### What the 923 tools cover
246
246
 
247
247
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
248
248
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/client.mjs CHANGED
@@ -146,7 +146,7 @@ async function unwrap(res, { read = false } = {}) {
146
146
  // ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
147
147
  // throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
148
148
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
149
- throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}) }); // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
149
+ throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
150
150
  }
151
151
  // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
152
152
  // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
@@ -1256,7 +1256,7 @@ export const neutralNote = (t) => {
1256
1256
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1257
1257
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1258
1258
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1259
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['purge', 'agentidle', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
1259
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'purge', 'agentidle', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
1260
1260
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1261
1261
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1262
1262
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
package/mcp/tools.mjs CHANGED
@@ -575,6 +575,10 @@ const wrap = (fn) => {
575
575
  // so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
576
576
  if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, on a computer (not the Ads Manager app), create a NEW draft ad in Ads Manager, click Start authentication (or Fix errors) on the error it shows and complete it, or follow a red banner if one is shown; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
577
577
  if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
578
+ // A PUBLISH OUTCOME IS DATA (2026-10-25, fb_679fd3573ff0): "nothing was posted" and "could not confirm" both arrive
579
+ // as 502, so the hint follows the server's `remedy.posted`, never the status or the prose.
580
+ if (e?.remedy?.code === 'publish_not_sent') _hints.push({ do: 'call this tool again with the same arguments', why: 'nothing was posted: it failed before anything was sent to the platform, so a retry cannot double-post' });
581
+ else if (e?.remedy?.code === 'publish_unconfirmed' || e?.remedy?.code === 'publish_in_progress') _hints.push({ do: 'check the account for the post before anything else; do not retry with a new idempotency key', why: 'the publishing call did not answer cleanly, so the post may be live; allowDuplicate:true is the only way to post it again' });
578
582
  }
579
583
  // ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
580
584
  // `msg` above is a SENTENCE, built for a model to read. `/v1/tools` — the REST passthrough — has to answer
@@ -585,7 +589,9 @@ const wrap = (fn) => {
585
589
  // `_meta` is MCP's own sanctioned extension point (spec: any result MAY carry it), so every MCP client
586
590
  // sees an ordinary error result and ignores a key it does not recognise. `publishWrap` spreads the result,
587
591
  // so its ambiguous-publish advice keeps the marker rather than dropping it.
588
- return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '', ...(e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? { options: e.videoChoice.options } : {}) } } }, _hints); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
592
+ // `remedy` (2026-10-25): the server's structured next step, whole, plus its `code` — Meta's security hold (code +
593
+ // steps[]) or a publish outcome (posted:false | 'unknown'). /v1 makes `code` its error code and returns `remedy`.
594
+ return withHints({ content: [{ type: 'text', text: msg }], isError: true, _meta: { 'hermoso.ai/error': { status: Number(e?.status) || 0, connector: e?.connector ? String(e.connector) : '', ...(e?.videoChoice?.options && typeof e.videoChoice.options === 'object' ? { options: e.videoChoice.options } : {}), ...(e?.remedy && typeof e.remedy === 'object' && typeof e.remedy.code === 'string' ? { code: e.remedy.code, remedy: e.remedy } : {}) } } }, _hints); // `options` = the video choice's three ways forward, structured, so /v1 answers its 402 with them
589
595
  }
590
596
  };
591
597
  return outer;
@@ -605,6 +611,13 @@ const publishWrap = (fn) => {
605
611
  const outer = async (args, extra) => {
606
612
  const r = await inner(args, extra);
607
613
  if (!r?.isError) return r;
614
+ // THE SERVER'S OWN VERDICT WINS (2026-10-25). A publish that failed before anything was sent answers "Nothing was
615
+ // posted … (timeout of 90000ms exceeded). It is safe to try again." — and the regex below matched its "timeout",
616
+ // so the same reply then said "the post may well be LIVE … DO NOT RETRY BLINDLY": two opposite verdicts in one
617
+ // answer, the ambiguity fb_679fd3573ff0 reported. When the server sent a publish `remedy`, its sentence already
618
+ // carries the right advice; the regex is only for a failure the server never answered (a dropped transport).
619
+ const _rc = r._meta?.['hermoso.ai/error']?.remedy?.code;
620
+ if (typeof _rc === 'string' && _rc.startsWith('publish_')) return r;
608
621
  const text = r.content?.[0]?.text || '';
609
622
  if (!PUBLISH_AMBIGUOUS_RE.test(text)) return r;
610
623
  return { ...r, content: [{ type: 'text', text: `${text}\n\nDO NOT ASSUME THIS FAILED, AND DO NOT RETRY BLINDLY. Publishing routinely outlives a transport, so the post may well be LIVE. Hermoso protects you: call this tool AGAIN with the identical arguments${args?.idempotencyKey ? ` and the same idempotencyKey ("${args.idempotencyKey}")` : ''} — an identical publish is recognised and returns the ORIGINAL post instead of posting twice. Tell the user it is being confirmed, not that it failed. For video, prefer async:true (post_to_meta), which returns a job id you poll with get_job and cannot time out at all.` }] };
@@ -616,6 +629,24 @@ const publishWrap = (fn) => {
616
629
  return outer;
617
630
  };
618
631
 
632
+ // A CHANGE OF WORDS IN A DESIGN IS RE-MADE, NOT EDITED (2026-10-22). A template ad (made here, in the app or over the API)
633
+ // keeps its design on its Library row; "change $10 to $15" makes that design again with the words swapped, about 2 credits,
634
+ // never a video-model edit of every frame. The server decides, free (POST /api/template-ad/remake-text); edit_video asks
635
+ // it first unless the caller chose the model path (a preview, or an approved still). null = not a design: edit as before.
636
+ async function designRemakeFirst(video, prompt) {
637
+ let t = null;
638
+ try { t = await apiPost('/api/template-ad/remake-text', { video, instruction: prompt }); } catch (e) { if (Number(e?.status) !== 404) throw e; } // an older server without the route: the edit, as before
639
+ const ch = t?.change ? `"${t.change.from}" to "${t.change.to}"` : 'those words';
640
+ if (t?.route === 'remake') {
641
+ const r = await awaitRenderJob({ id: t.jobId });
642
+ if (r?.stillRendering) return ok(`This video was laid out from a design, so the same ${t.templateLabel} is being made again with ${ch} (about ${t.credits} credits) instead of a video edit [job ${t.jobId}]. Call get_job with that id until it is done.`, { ...r, remade: true });
643
+ return okVideo(`Made the same ${t.templateLabel} again with ${ch} everywhere it appeared (${t.hits} place${t.hits === 1 ? '' : 's'}); the original video is kept and was not edited: ${r.url} [job ${t.jobId}]${switchNote(r)}`, { ...r, remade: true });
644
+ }
645
+ if (t?.route === 'not_found') return ok(`Nothing changed or charged: "${t.change.from}" is not in this design's words. Its words are: ${(t.words || []).map((w) => `"${w}"`).join(', ')}. Ask which words to change and call again with them exactly as they appear.`, t);
646
+ if (t?.route === 'no_design') return ok(`Nothing changed or charged yet: this video was laid out from a design that was not kept with it, so it cannot be re-made word for word. Offer both ways: (1) make it again from the brand with make_template_ad (the same template, the words with ${ch}), about 2 credits; (2) edit the words inside this video with the video model: edit_video with previewFirstFrame:true shows the change on one still and quotes the clip before anything paid runs.`, t);
647
+ return null;
648
+ }
649
+
619
650
  // run a job to completion, surfacing the served media URL. Under the HOSTED connector (Claude.ai/ChatGPT) the
620
651
  // client kills long tool calls before a 1-3 min render finishes — so cap the in-call wait there and return a
621
652
  // RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
@@ -1910,8 +1941,14 @@ export const INBOX_SOURCES = [
1910
1941
  // you cannot reply to a message id on either vendor. Meta wants the PAGE-SCOPED ID of the person (its
1911
1942
  // /conversation route already computes it as `replyTo`) and Bluesky wants the conversation (`convoId`). Both
1912
1943
  // routes already return exactly that field at the top level, so nothing new is computed here.
1913
- { source: 'meta_dm', provider: 'meta', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Meta direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' } },
1914
- { source: 'instagram_dm', provider: 'instagram', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Instagram direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' } }, // a DIRECT (Instagram Login) account's inbox — same routes, resolved through igDmCtx by `account` (2026-09-02)
1944
+ //
1945
+ // `readAll` (2026-10-25): WITHOUT a conversationId these two no longer print "pass conversationId" and list
1946
+ // nothing. They read every waiting conversation in one call per surface (/api/meta/dm-inbox, already folded to
1947
+ // one row per thread, so it is NOT folded again here) — a customer's busy Instagram inbox read as zero items
1948
+ // because of that note plus the next defect. `readAllQuery` splits the two so no thread is listed twice:
1949
+ // meta_dm reads Messenger, instagram_dm reads Instagram (direct login account, or the Page-linked one).
1950
+ { source: 'meta_dm', provider: 'meta', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Meta direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'MESSENGER' } },
1951
+ { source: 'instagram_dm', provider: 'instagram', read: '/api/meta/conversation', reply: '/api/meta/message', kind: 'dm', label: 'Instagram direct message', dmReplyId: 'replyTo', needs: { arg: 'conversationId', param: 'conversationId', from: 'list_meta_conversations' }, readAll: '/api/meta/dm-inbox', readAllQuery: { platform: 'INSTAGRAM' }, query: { platform: 'INSTAGRAM' } }, // `query` rides on the per-thread read too: without it a direct (Instagram Login) thread id was read on the Page path and refused, measured 2026-10-25. A DIRECT (Instagram Login) account's inbox — same routes, resolved through igDmCtx by `account` (2026-09-02)
1915
1952
  { source: 'meta_webhook', provider: 'meta', read: '/api/meta/webhooks/events', reply: null, kind: 'pushed', label: 'Pushed Meta event' },
1916
1953
  { source: 'threads_mention', provider: 'threads', read: '/api/threads/mentions', reply: '/api/threads/reply', kind: 'mention', label: 'Threads mention' },
1917
1954
  { source: 'youtube', provider: 'youtube', read: '/api/youtube/comments', reply: '/api/youtube/reply-comment', kind: 'comment', label: 'YouTube comment', needs: { arg: 'videoId', param: 'videoId', from: 'list_youtube_videos' } },
@@ -2055,9 +2092,13 @@ export function normalizeInboxItem(source, row = {}) {
2055
2092
  const nativeId = pick('id', 'commentId', 'name', 'reviewId', 'tweet_id', 'messageId', 'eventId');
2056
2093
  // A REVIEW WITH A STAR RATING AND NO WORDS IS STILL AN ITEM. Google reviews are frequently rating-only, and
2057
2094
  // dropping them would hide the thing a brand most wants to answer. So text OR a rating is enough to keep it.
2058
- const text = pick('text', 'message', 'comment', 'body', 'content', 'reviewReply.comment', 'snippet.textDisplay');
2095
+ // AND A DM WITH NO WORDS IS STILL AN ITEM (2026-10-25): a story mention, a shared reel, a photo or content Meta
2096
+ // withholds has no text, and this line used to drop every one of them — an Instagram inbox full of story
2097
+ // mentions read as "Nothing waiting." The read route's one-line `summary` stands in for the missing text.
2098
+ const text = pick('text', 'message', 'comment', 'body', 'content', 'reviewReply.comment', 'snippet.textDisplay') || pick('summary');
2059
2099
  const rating = row.starRating ?? row.rating ?? null;
2060
2100
  if (!nativeId || (!text && rating == null)) return null;
2101
+ const mk = src.kind === 'dm' && typeof row.kind === 'string' && typeof row.summary === 'string' ? row.kind : ''; // the Meta read shape only
2061
2102
  return {
2062
2103
  id: inboxId(source, nativeId),
2063
2104
  source,
@@ -2078,6 +2119,15 @@ export function normalizeInboxItem(source, row = {}) {
2078
2119
  || row.inbound === false || row.fromMe === true,
2079
2120
  canReply: !!src.reply,
2080
2121
  ...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
2122
+ // WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
2123
+ // share links, and `unavailable` + why when Meta withheld the content. Only set when the route sent them.
2124
+ ...(mk ? { messageKind: mk } : {}),
2125
+ ...(src.kind === 'dm' && typeof row.summary === 'string' && row.summary.trim() ? { summary: row.summary.trim() } : {}),
2126
+ ...(src.kind === 'dm' && row.unavailable === true ? { unavailable: true, unavailableReason: String(row.unavailableReason || '') } : {}),
2127
+ ...(src.kind === 'dm' && Array.isArray(row.attachments) && row.attachments.length ? { attachments: row.attachments } : {}),
2128
+ ...(src.kind === 'dm' && row.story && typeof row.story === 'object' ? { story: row.story } : {}),
2129
+ ...(src.kind === 'dm' && row.share && typeof row.share === 'object' ? { share: row.share } : {}),
2130
+ ...(src.kind === 'dm' && typeof row.conversationId === 'string' && row.conversationId ? { conversationId: row.conversationId } : {}),
2081
2131
  };
2082
2132
  }
2083
2133
 
@@ -2097,7 +2147,9 @@ export function inboxLine(it) {
2097
2147
  it.label,
2098
2148
  it.rating != null ? `${it.rating}★` : '',
2099
2149
  `${it.author}:`,
2100
- JSON.stringify(String(it.text || '').slice(0, 140)),
2150
+ // A DM that is not plain text (a story mention, a share, withheld content) reads as its summary, which already
2151
+ // carries the kind in words; plain text stays quoted as before.
2152
+ it.summary && it.messageKind && it.messageKind !== 'text' ? String(it.summary).slice(0, 220) : JSON.stringify(String(it.text || '').slice(0, 140)),
2101
2153
  `[${it.id}]`,
2102
2154
  ];
2103
2155
  return bits.filter(Boolean).join(' ');
@@ -2348,7 +2400,9 @@ export function parseToolScope(raw) {
2348
2400
  // coupon, customer) create records and move nothing; tools/chatgpt-no-money-movement-check.mjs makes a NEW Stripe
2349
2401
  // write fail until somebody classifies it.
2350
2402
  export const WIDGET_MONEY_MOVEMENT_TOOLS = Object.freeze(['refund_stripe_charge', 'create_stripe_subscription', 'cancel_stripe_subscription', 'create_stripe_payment_link']);
2351
- export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'delete_brand', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', ...WIDGET_MONEY_MOVEMENT_TOOLS]);
2403
+ export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'delete_brand', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS]);
2404
+ // remix_static is clone_static under its old name (kept for callers that cached it); on ChatGPT two tools for one
2405
+ // action is a selection hazard OpenAI's metadata guide calls out, so only clone_static is offered there (2026-10-24).
2352
2406
  // The withheld tools whose refusal is the generic-executor sentence rather than the commerce one.
2353
2407
  const WIDGET_GENERIC_PROXIES = new Set(['fetch_social_data']);
2354
2408
  const executorLike = (name) => EXECUTOR_TOOLS.has(name) || WIDGET_GENERIC_PROXIES.has(name);
@@ -4217,9 +4271,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
4217
4271
  + "Direct), Instagram, Bluesky, X and Telegram. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
4218
4272
  + "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
4219
4273
  + "skipped silently; a channel that FAILS to read is named in `notes` rather than dropped, so a short list is "
4220
- + "never mistaken for a quiet week. DMs are read one conversation at a time and fold to ONE item — the newest "
4221
- + "thing the other person said — so pass conversationId (from list_meta_conversations, list_bluesky_convos or list_x_dms) "
4222
- + "to include them. FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
4274
+ + "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
4275
+ + "Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, "
4276
+ + "image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need "
4277
+ + "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, "
4223
4278
  + "and every other source only re-reads what the per-channel tools already read.",
4224
4279
  inputSchema: {
4225
4280
  sources: z.array(z.string()).optional().describe(`Limit to these sources: ${INBOX_SOURCE_NAMES.join(', ')}. Omit for every connected channel.`),
@@ -4229,7 +4284,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4229
4284
  // and this schema did not carry it, so the note telling a caller to pass it named an argument the tool
4230
4285
  // refused — and the handler quietly read `postId` for anything that was not `videoId`. The check now
4231
4286
  // asserts INBOX_NEEDS_ARGS ⊆ these keys, so the two cannot drift again.
4232
- conversationId: z.string().optional().describe('Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). DMs are read per conversation, and on X reading one costs credits.'),
4287
+ conversationId: z.string().optional().describe('Restrict DMs to one conversation (from list_meta_conversations, list_bluesky_convos or list_x_dms). Required for Bluesky and X DMs; on X reading one costs credits.'),
4233
4288
  limit: z.number().optional().describe('Max items per source (default 25).'),
4234
4289
  unansweredOnly: z.boolean().optional().describe('Only items with no reply from the brand yet.'),
4235
4290
  },
@@ -4259,12 +4314,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
4259
4314
  // for. Saying so is very different from letting the vendor 400 and filing it as "could not be read": one
4260
4315
  // tells the caller what to pass next, the other reads as a broken channel.
4261
4316
  const given = argValues[src.needs.arg];
4262
- if (!given) { notes.push(`${src.label}s are read per ${src.kind === 'dm' ? 'conversation' : 'post'} — pass ${src.needs.arg} (from ${src.needs.from}) to include them.`); continue; }
4263
- q[src.needs.param] = given;
4317
+ // A SOURCE THAT CAN ALSO READ ACCOUNT-WIDE (`readAll`) DOES, rather than printing the note: the Meta and
4318
+ // Instagram DM inboxes list every waiting conversation in one call per surface (2026-10-25).
4319
+ if (!given && !src.readAll) { notes.push(`${src.label}s are read per ${src.kind === 'dm' ? 'conversation' : 'post'} — pass ${src.needs.arg} (from ${src.needs.from}) to include them.`); continue; }
4320
+ if (given) q[src.needs.param] = given;
4264
4321
  }
4322
+ const whole = !!(src.readAll && src.needs && !argValues[src.needs.arg]);
4323
+ Object.assign(q, src.query || {}, whole ? (src.readAllQuery || {}) : {});
4265
4324
  try {
4266
- const d = await apiGet(src.read, q);
4325
+ const d = await apiGet(whole ? src.readAll : src.read, q);
4267
4326
  const rows = Array.isArray(d) ? d : (d.comments || d.replies || d.mentions || d.reviews || d.messages || d.events || d.items || d.data || []);
4327
+ if (whole) { for (const r of rows) { const it = normalizeInboxItem(src.source, r); if (it) items.push(it); } continue; } // already one row per conversation
4268
4328
  // A DM FOLDS TO ONE ITEM AND CARRIES THE ID YOU ANSWER TO — see foldConversationRows. Deriving this from
4269
4329
  // `kind` rather than naming the two sources means a third DM channel inherits it by declaring its kind.
4270
4330
  // FOLDED ONLY WHERE THE REPLY TARGET IS ON THE ENVELOPE. `dmReplyId` is precisely the declaration "this
@@ -4273,7 +4333,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4273
4333
  // conversation, so folding them would collapse every chat into one — keying the fold on `kind` would do
4274
4334
  // exactly that. The trigger is therefore the field the fold consumes, not the category.
4275
4335
  const use = src.dmReplyId ? foldConversationRows(rows, d?.[src.dmReplyId]) : rows;
4276
- for (const r of use) { const it = normalizeInboxItem(src.source, r); if (it) items.push(it); }
4336
+ 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); }
4277
4337
  } catch (e) {
4278
4338
  // NAMED, never swallowed. A connector that is simply not linked is not worth a line; anything else is.
4279
4339
  const msg = String(e?.message || e);
@@ -4342,7 +4402,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4342
4402
  server.registerTool('hermoso_capabilities', {
4343
4403
  // The title a directory renders. It has to say CALL THIS FIRST without saying "call this first", because the
4344
4404
  // listing shows it out of context, beside 680 siblings, to somebody who has never used Hermoso.
4345
- title: 'Start here: what Hermoso can do and what it costs',
4405
+ title: 'Hermoso models, options and credit costs',
4346
4406
  description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their credit costs (estimates: a render is charged its actual cost), aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar flags. Call it when you need a specific model id, a credit cost, or a capability you are not sure of. It is NOT a prerequisite for rendering: generate_image, generate_video and render_ad all run with `model` omitted and route to the server’s own default. Read-only, free.',
4347
4407
  inputSchema: {}, outputSchema: {
4348
4408
  image: z.boolean().optional().describe('TRUE when image generation is available on this account. A BOOLEAN, never a provider name — /api/generate/status deliberately reports availability only and never leaks the underlying provider, so there is no label here to report. The model ids live in options.image.models[].'),
@@ -4602,7 +4662,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4602
4662
  const cfg = await apiGet('/api/billing/config');
4603
4663
  const plans = (cfg.plans || []).filter(p => p.priceUsd > 0 && !p.legacy && !p.cohort).map(p => ({ id: p.id, name: p.name, priceUsd: p.priceUsd, credits: p.credits, noMarkup: !!p.noMarkup, makes: p.makes || null }));
4604
4664
  if (!plan) {
4605
- const lines = plans.map(p => `• ${p.id} — ${p.name}: $${p.priceUsd}/mo · ${p.credits.toLocaleString()} credits/mo${p.makes?.videoAds ? ` (about ${p.makes.videoAds} ${p.makes.videoSeconds}s video ads or ${p.makes.images} images)` : ''}${p.noMarkup ? ' · no markup on models' : ''}`).join('\n') || '(no plans configured)';
4665
+ const lines = plans.map(p => `• ${p.id} — ${p.name}: $${p.priceUsd}/mo · ${p.credits.toLocaleString()} credits/mo${p.makes?.videoAds ? ` (about ${p.makes.videoAds} ${p.makes.videoSeconds}-second videos or ${p.makes.images} images a month)` : ''}${p.noMarkup ? ' · no markup on models' : ''}`).join('\n') || '(no plans configured)';
4606
4666
  return ok(`Subscription plans (monthly):\n${lines}\n${cfg.memberPricing?.note || ''}\n\nCall upgrade_plan again with plan="<id>" (admin only). Downgrades + changes for existing subscribers are made in the app.`, { plans });
4607
4667
  }
4608
4668
  const d = await apiPost('/api/billing/plan-link', { planId: plan, period, ...(confirm && quote_token ? { confirm: true, quoteToken: quote_token } : {}) });
@@ -5703,7 +5763,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5703
5763
  // no need for one edge case just for Facebook."
5704
5764
  server.registerTool('schedule_post', {
5705
5765
  title: 'Schedule a post for later',
5706
- description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills a few credits per call). Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. google_business is in the schema but HELD BACK (Google’s API allowlist) and is refused at enqueue. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
5766
+ description: 'Use this when the user wants to schedule a social media post for later, on one channel or several at once. Scheduling and publishing cost no credits on any Hermoso plan (X alone bills a few credits per call). Queue a post for a future time on one or more connected channels at once: facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, bluesky, telegram — TEN channels, every one live. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (what “just queue it” means). imageUrl/videoUrl take a Hermoso render URL or an upload_file URL. `captions` gives a channel its own wording; the rest use `message`. PINTEREST AND YOUTUBE SHOW A TITLE: `title` (max 100 chars), derived from the caption when omitted; YOUTUBE also takes `description`, `tags`, `thumbnailUrl`. Every PER-CHANNEL SETTING is a parameter below, carried straight to the real publisher — TikTok’s `brandedContent` / `yourBrand` disclosures (set them whenever the post is commercial), Google Business `topicType` / `event` / `offer` / `actionType`, an X `thread` / `poll`, Instagram `collaborators`, and the rest. Channels are attempted INDEPENDENTLY: one failing never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses: Pinterest needs `boardId` (list_pinterest_boards) or is refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) or it goes to the person’s own profile; several Facebook Pages need `pageId` (list_meta_pages), several Google Business listings `locationId` (list_business_locations) — resolve those FIRST and let the user pick, or the post is refused when it fires. A scheduled post GOES LIVE PUBLICLY by default on every channel, never quietly downgraded. Only if the user asks, set `visibility` (or `visibilityByChannel`): ‘unlisted’ (YouTube) · ‘private’ (YouTube, or TikTok SELF_ONLY) · ‘draft’ (TikTok, or an unpublished Facebook Page post). A visibility a channel cannot do is REFUSED now, never posted weaker later.',
5707
5767
  inputSchema: {
5708
5768
  ...HOOK_ATTR,
5709
5769
  channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business', 'bluesky', 'telegram'])).describe('one or more channels to post to at that time'),
@@ -6377,7 +6437,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
6377
6437
  }));
6378
6438
  server.registerTool('x_mentions', {
6379
6439
  title: 'Read who is mentioning you on X',
6380
- description: 'Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.',
6440
+ description: 'Read the posts mentioning the connected X account — who is talking to the brand, in their own words, newest first. Use it to find what deserves a reply (reply with post_to_x + replyToId) and to mine real objections and customer language for ad copy. COSTS CREDITS PER MENTION RETURNED and per distinct author X returns with them, plus one account lookup — keep maxResults small (default 10) and tell the user the cost before pulling a big page. Needs X connected.',
6381
6441
  inputSchema: {
6382
6442
  maxResults: z.number().optional().describe('how many mentions to pull, 5-100 (default 10) — every one is billed'),
6383
6443
  sinceId: z.string().optional().describe('only return mentions newer than this post id'),
@@ -7309,7 +7369,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7309
7369
  }));
7310
7370
  server.registerTool('search_x', {
7311
7371
  title: 'Search X (Twitter)',
7312
- description: 'Keyword search across the PUBLIC X index \u2014 the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X\u2019s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `\"exact phrase\"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.',
7372
+ description: 'Keyword search across the PUBLIC X index \u2014 the last 7 days. This is the X half of ad-spy research, alongside search_tiktok / search_instagram / search_reddit / search_threads / search_youtube. X\u2019s own search OPERATORS work and are the point: `from:nike`, `-is:retweet`, `has:media`, `has:links`, `lang:en`, `\"exact phrase\"`, `(a OR b)`. Every post comes back with impressions, likes, reposts, replies, quotes and bookmarks, so you can rank what actually landed rather than what merely exists. ONLY THE LAST 7 DAYS: the full archive needs a tier we do not hold, and an older window is refused by name rather than silently truncated. Costs a small number of credits (X bills per post read and per distinct author returned with them, and this bills the ACTUAL count returned, never the number asked for). Needs X connected.',
7313
7373
  inputSchema: {
7314
7374
  query: z.string().describe('the search, X operators included \u2014 e.g. `(protein OR creatine) -is:retweet has:media lang:en`'),
7315
7375
  maxResults: z.number().optional().describe('10\u2013100, default 25'),
@@ -8141,7 +8201,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8141
8201
  }));
8142
8202
  server.registerTool('read_meta_conversation', {
8143
8203
  title: 'Read one Messenger / Instagram DM thread',
8144
- description: 'The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.',
8204
+ description: 'The messages in one DM thread, oldest to newest, each marked inbound or outbound — plus WHETHER A REPLY IS ALLOWED RIGHT NOW. Meta only permits a reply within 24 hours of the person acting, and the window is computed from their LAST MESSAGE rather than from the thread’s updated time (which includes our own replies and would report the window open for a day after WE spoke). Read this before drafting: "can I answer this?" is the question a person actually has, and finding out by being refused is the worst way to learn it. It also hands back `replyTo`, the id a reply goes to, so nobody has to guess which participant is the customer. EVERY MESSAGE SAYS WHAT IT IS: `kind` (text, story_mention, story_reply, share, image, video, audio, file, sticker, gif, template, unsupported, empty), a one-line `summary` to read out, and `attachments` / `story` / `share` / `inReplyTo` / `reactions`; content Meta withholds (unsupported, or a "use the latest app" placeholder) is `unavailable: true` with `unavailableReason`, never blank text. ON INSTAGRAM Meta serves only the 20 most recent messages — a truncated thread is unreachable history, not a short conversation, and the reply says so. Read-only, 0 credits.',
8145
8205
  inputSchema: {
8146
8206
  conversationId: z.string().describe('from list_meta_conversations'),
8147
8207
  platform: z.enum(['MESSENGER', 'INSTAGRAM']).optional().describe('so the Instagram 20-message ceiling can be stated when it applies'),
@@ -8149,13 +8209,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
8149
8209
  account: z.string().optional().describe('which Instagram account — an @handle or id from list_connector_accounts("instagram"). The way a DIRECT (Instagram Login) account is reached; omit for the Page-linked one.'),
8150
8210
  pageId: z.string().optional(),
8151
8211
  },
8152
- outputSchema: { conversationId: z.string().optional(), count: z.number().optional(), messages: z.array(z.any()).optional(), replyTo: z.string().optional(), windowState: z.string().optional(), hoursLeft: z.number().nullable().optional(), window: z.string().optional(), historyNote: z.string().optional() },
8212
+ outputSchema: { conversationId: z.string().optional(), count: z.number().optional(), messages: z.array(z.any()).optional(), kinds: z.record(z.string(), z.number()).optional(), unavailableCount: z.number().optional(), kindsNote: z.string().optional(), replyTo: z.string().optional(), windowState: z.string().optional(), hoursLeft: z.number().nullable().optional(), window: z.string().optional(), historyNote: z.string().optional() },
8153
8213
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
8154
8214
  }, wrap(async (a) => {
8155
8215
  const d = await apiGet('/api/meta/conversation', a);
8156
- const lines = (d.messages || []).map(m => `${m.inbound ? '←' : '→'} ${m.from}: ${String(m.text || '(no text)').replace(/\s+/g, ' ').slice(0, 200)} [${String(m.at || '').slice(0, 16).replace('T', ' ')}]`);
8216
+ // THE SUMMARY, NOT THE TEXT (2026-10-25): a story mention, a share or withheld content has no text, and
8217
+ // "(no text)" told a screen-reader user nothing about what had arrived. The kind rides in brackets.
8218
+ const lines = (d.messages || []).map(m => `${m.inbound ? '←' : '→'} ${m.from}${m.kind && m.kind !== 'text' ? ` [${m.kind}${m.unavailable ? ', unavailable' : ''}]` : ''}: ${String(m.summary || m.text || '').replace(/\s+/g, ' ').slice(0, 300)} [${String(m.at || '').slice(0, 16).replace('T', ' ')}]`);
8157
8219
  const head = d.windowState === 'open' ? `You CAN reply (${d.hoursLeft}h left in Meta’s 24-hour window).` : d.windowState === 'closed' ? '⚠ You CANNOT reply — the 24-hour window is closed.' : '⚠ Could not tell whether the 24-hour window is open.';
8158
- return ok(`${head}\n${lines.join('\n') || '(no messages)'}\n${d.replyTo ? `\nreplyTo: ${d.replyTo}` : ''}${d.window ? `\n\n${d.window}` : ''}${d.historyNote ? `\n\n${d.historyNote}` : ''}`, d);
8220
+ return ok(`${head}\n${lines.join('\n') || '(no messages)'}\n${d.kindsNote ? `\n${d.kindsNote}\n` : ''}${d.replyTo ? `\nreplyTo: ${d.replyTo}` : ''}${d.window ? `\n\n${d.window}` : ''}${d.historyNote ? `\n\n${d.historyNote}` : ''}`, d);
8159
8221
  }));
8160
8222
  server.registerTool('reply_to_meta_message', {
8161
8223
  title: 'Reply to a Messenger / Instagram DM',
@@ -20885,79 +20947,102 @@ function memoryNoteVerdict(text) {
20885
20947
  return { content: [{ type: 'text', text }, ...blocks], structuredContent: { ...d, ...(widget ? adSpyBlock('ads', rows.length, cards) : {}) } };
20886
20948
  }));
20887
20949
 
20888
- // ── COMPETITOR WATCH — the STANDING research order, not a one-off pull (2026-08-18). `update_settings` could
20889
- // already turn the weekly digest EMAIL on and off, and nothing headless could say WHO to watch or read WHAT the
20890
- // watch had found: a switch with no wiring behind it. Both tools are thin wrappers over the routes the web Ad
20891
- // Spy ▸ Watching tab already calls (POST /api/watch/set, GET /api/watch/latest), so there is one set of rules.
20950
+ // ── MARKET RADAR (the competitor watch, 2026-08-18; modules 2026-10-23) — the STANDING research order, not a one-off
20951
+ // pull. Thin wrappers over the routes the web Ad Spy ▸ Watching tab calls (POST /api/watch/set, GET /api/watch/latest),
20952
+ // so there is one set of rules: lib/market-radar.mjs decides the modules, the quote and what counts as a winner.
20892
20953
  const WATCH_PLATFORM_LABEL = { meta: 'Meta', google: 'Google', linkedin: 'LinkedIn' }; // the worker stores facebook results under 'meta'
20893
20954
  const watchWhen = (ms) => (+ms > 0 ? new Date(+ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : 'never');
20955
+ const RADAR_KEYS = ['competitors', 'topics', 'modules', 'cadence', 'email', 'auto', 'seed', 'runNow', 'dryRun'];
20956
+ const radarOn = (m) => Object.entries(m || {}).filter(([, v]) => v).map(([k]) => k).join(', ') || 'none';
20957
+ const radarCost = (q) => (q && q.credits ? `A check holds up to ${q.credits} credits before it starts${q.later ? ` (about ${q.later} once each brand's Meta page is found)` : ''}, and is skipped with nothing spent when the balance is short.` : 'Nothing to check, so a check costs nothing.');
20894
20958
  server.registerTool('set_competitor_watch', {
20895
- title: 'Set the competitor watch',
20896
- description: 'Set (or STOP) this workspace\'s standing COMPETITOR WATCH — the weekly job that re-checks each named brand\'s ad libraries and reports what is NEW since last time. The same watch the web app\'s Ad Spy > Watching tab manages, and the same one the weekly digest email is sent from (turn that email on/off with update_settings({watchEmail})). '
20897
- + 'This REPLACES the whole watched list, it does not add to it — pass every brand you want watched, every time. Max 5 brands (the server trims past that). Pass an EMPTY list to stop the watch entirely, which also clears the findings. '
20898
- + 'Give a `domain` wherever you know one: Google Ads Transparency is looked up BY DOMAIN and is skipped for a brand without one, and the domain is what resolves the right Meta page for a brand with an ambiguous name. '
20899
- + 'The run itself spends credits against the ad libraries (roughly 3 per brand on Meta, 1 each on Google and LinkedIn) and is hard-capped per run server-side, so an oversized watch is trimmed rather than allowed to run away. Setting the list is free; only a run spends. '
20900
- + 'runNow:true runs it once IMMEDIATELY (a background job — it spends now) and then keeps the weekly cadence; leave it off and the first check is a week out. '
20901
- + 'The country and the platform mix are NOT settable here — a re-set inherits whatever the pending run already carried (US / Meta for a watch that has never been configured otherwise). Read the findings back with list_watch_findings.',
20902
- inputSchema: {
20959
+ title: 'Set the market radar',
20960
+ description: 'Set, change or stop a brand\'s MARKET RADAR (the standing competitor watch; Ad Spy > Watching in the web app; each brand has its own), run weekly or daily: newAds (what watched brands launched), winners (their ad passing 30 days running), trends + topPosts (formats and most-viewed posts for niche topics), ideas (3 to make). '
20961
+ + 'Only what you pass changes. competitors / topics REPLACE their lists (max 8 brands, 5 topics); both empty stops it and clears findings. auto:true picks 3-5 competitors, 2-3 similar brands and 2-3 topics from the saved brand or `seed` (a few credits). dryRun:true quotes one check, saves nothing. '
20962
+ + 'Saving is free; each check holds its quote first and is skipped when the balance is short. runNow is refused while a check runs or just after one finished. Read it with list_watch_findings.',
20963
+ inputSchema: { ...STORE_BRAND,
20903
20964
  competitors: z.array(z.object({
20904
- name: z.string().describe('the brand name, as it advertises'),
20905
- domain: z.string().optional().describe('its domain, e.g. ridge.com — required for Google Ads Transparency, and what disambiguates a common brand name on Meta'),
20906
- }).passthrough()).describe('the brands to watch — the COMPLETE list, replacing whatever was set before. Empty array = stop watching.'),
20907
- runNow: z.boolean().optional().describe('true to run one check immediately (spends credits now) instead of waiting a week for the first one'),
20965
+ name: z.string(),
20966
+ domain: z.string().optional().describe('e.g. ridge.com: Google is read by domain, and it finds the right Meta page'),
20967
+ kind: z.enum(['competitor', 'similar']).optional(),
20968
+ }).passthrough()).optional().describe('the COMPLETE brand list'),
20969
+ topics: z.array(z.string()).optional().describe('niche search phrases, e.g. "protein breakfast"'),
20970
+ modules: z.object({ newAds: z.boolean().optional(), winners: z.boolean().optional(), trends: z.boolean().optional(), topPosts: z.boolean().optional(), ideas: z.boolean().optional() }).optional().describe('on/off; omitted keep theirs'),
20971
+ cadence: z.enum(['weekly', 'daily']).optional(),
20972
+ email: z.boolean().optional().describe('the weekly email'),
20973
+ auto: z.boolean().optional(),
20974
+ seed: z.string().optional().describe('for auto: a website, brand or niche'),
20975
+ runNow: z.boolean().optional().describe('check now (spends now)'),
20976
+ force: z.boolean().optional().describe('with runNow: check again right after a check, allowed once what is watched changed'),
20977
+ dryRun: z.boolean().optional(),
20908
20978
  },
20909
20979
  outputSchema: {
20910
20980
  watching: z.array(z.string()).optional().describe('the brand names now under watch (empty = the watch is off)'),
20981
+ competitors: z.array(z.any()).optional().describe('{name, domain, kind}'),
20982
+ topics: z.array(z.string()).optional(),
20983
+ modules: z.any().optional().describe('{newAds, winners, trends, topPosts, ideas} on/off'),
20984
+ cadence: z.string().optional(),
20985
+ email: z.boolean().optional(),
20986
+ quote: z.any().optional().describe('{credits, lines[{module, credits}], later?}: what one check holds'),
20911
20987
  nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
20988
+ jobId: z.string().optional().describe('with runNow: the check\'s job (get_job follows it)'),
20912
20989
  },
20913
20990
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
20914
- }, wrap(async (a) => {
20915
- // An OMITTED list is refused; an EMPTY one is the documented way to stop. Defaulting the omission to [] would
20916
- // turn "I forgot the argument" into "delete this workspace's whole watch", silently.
20917
- if (!Array.isArray(a.competitors)) return { content: [{ type: 'text', text: 'Pass `competitors` — the complete list of brands to watch, or [] to stop the watch.' }], isError: true };
20918
- const comps = a.competitors.filter(c => c && String(c.name || '').trim())
20919
- .map(c => ({ name: String(c.name).trim(), domain: String(c.domain || '').trim() }));
20920
- const d = await apiPost('/api/watch/set', { competitors: comps, ...(a.runNow === true ? { runNow: true } : {}) });
20921
- const on = (d.watching || []);
20922
- if (!on.length) return ok('Competitor watch STOPPED — nothing is being watched and the findings are cleared.', d);
20923
- // The server trims to 5 and drops nameless rows; report what it ACTUALLY stored, never what was asked for.
20924
- const dropped = comps.length - on.length;
20925
- // …and the no-domain note names only the brands that were KEPT — naming a trimmed one would tell the caller to
20926
- // fix a row that is not on the watch at all.
20927
- const noDomain = comps.filter(c => !c.domain && on.includes(c.name)).map(c => c.name);
20928
- return ok(`Watching ${on.length} brand(s): ${on.join(', ')}.${dropped > 0 ? ` (${dropped} not kept — the watch holds at most 5.)` : ''}`
20929
- + ` Next check ${a.runNow === true ? 'is running now' : watchWhen(d.nextRunAt)}, then weekly. Read what it finds with list_watch_findings.`
20930
- + `${noDomain.length ? ` Note: no domain for ${noDomain.join(', ')} — Google Ads Transparency is looked up by domain and will be skipped for them.` : ''}`, d);
20931
- }));
20991
+ }, wrap(inBrand(async (a) => {
20992
+ // An EMPTY call is refused: defaulting it to anything would turn "I forgot the argument" into a silent change.
20993
+ if (!RADAR_KEYS.some(k => a[k] !== undefined)) return { content: [{ type: 'text', text: 'Pass what to change: competitors, topics, modules, cadence, email, auto (one-click setup), runNow or dryRun. competitors:[] and topics:[] together stop the radar.' }], isError: true };
20994
+ const body = {};
20995
+ for (const k of RADAR_KEYS) if (a[k] !== undefined) body[k] = a[k];
20996
+ if (a.force === true) body.force = true;
20997
+ if (Array.isArray(a.competitors)) body.competitors = a.competitors.filter(c => c && String(c.name || '').trim()).map(c => ({ name: String(c.name).trim(), domain: String(c.domain || '').trim(), ...(c.kind ? { kind: c.kind } : {}) }));
20998
+ const d = await apiPost('/api/watch/set', body);
20999
+ const on = d.watching || [], topics = d.topics || [];
21000
+ const setup = `Brands: ${on.join(', ') || '(none)'}. Topics: ${topics.join(', ') || '(none)'}. Modules on: ${radarOn(d.modules)}. Cadence: ${d.cadence || 'weekly'}. Weekly email: ${d.email === false ? 'off' : 'on'}.`;
21001
+ if (d.dryRun) return ok(`QUOTE ONLY, nothing saved. ${setup} ${radarCost(d.quote)}${a.auto ? ' Save it with set_competitor_watch({competitors, topics}) after editing.' : ''}`, d);
21002
+ if (!on.length && !topics.length) return ok('Market radar STOPPED: nothing is watched and the findings are cleared.', d);
21003
+ // The server trims and de-duplicates; report what it ACTUALLY stored, never what was asked for.
21004
+ const noDomain = (d.competitors || []).filter(c => !c.domain).map(c => c.name);
21005
+ return ok(`${setup} ${radarCost(d.quote)} Next check ${a.runNow === true ? `is running now${d.jobId ? ` (get_job ${d.jobId} follows it)` : ''}` : watchWhen(d.nextRunAt)}. Read what it finds with list_watch_findings.`
21006
+ + `${noDomain.length ? ` No domain for ${noDomain.join(', ')}: Google is read by domain and is skipped for them.` : ''}`, d);
21007
+ })));
20932
21008
 
20933
21009
  server.registerTool('list_watch_findings', {
20934
21010
  _meta: openaiMeta(AD_SPY_URI, 'Reading the competitor watch…', 'Competitor watch read'),
20935
- title: 'Read the competitor watch',
20936
- description: 'Read what the standing COMPETITOR WATCH has found — the new ads each watched brand has launched since the last check, plus the watch\'s own state (who is watched, when it last ran, when it runs next, and whether the last run actually succeeded). The same board the web app\'s Ad Spy > Watching tab renders. '
20937
- + 'Use it to answer "what are our competitors running that\'s new?", to feed a teardown, or to save something worth keeping with save_to_swipefile. '
20938
- + 'Findings marked `seed:true` are NOT new launches — the first check of a brand has nothing to diff against, so it seeds the board with what that brand is running right now; only later runs surface genuine changes. '
20939
- + 'Read-only and free — it returns the stored results of past runs and never triggers a check (set_competitor_watch({runNow:true}) is what runs one).',
20940
- inputSchema: {
20941
- competitor: z.string().optional().describe('only findings for this watched brand (exact name as returned in `watching`) — omit for all of them'),
20942
- limit: z.number().optional().describe('max findings to return (default 25, max 75 — the server keeps at most 75, and at most 15 per brand)'),
21011
+ title: 'Read the market radar',
21012
+ description: 'Read what a brand\'s MARKET RADAR (set_competitor_watch) has found: each watched brand\'s new ads, the winners (ads past 30 days running, with a ready brief to make your own version), the trending formats and top posts for its topics, and the ideas to make, plus its state (lastRun, nextRunAt, lastRunOk, paused) and the quote for its next check. '
21013
+ + 'Findings marked `seed:true` are what a brand was already running at its first check, not new launches. Read-only and free; it never runs a check.',
21014
+ inputSchema: { ...STORE_BRAND,
21015
+ competitor: z.string().optional().describe('only this watched brand\'s ads (exact name as returned)'),
21016
+ limit: z.number().optional().describe('max ads (default 25, max 75)'),
20943
21017
  },
20944
21018
  outputSchema: {
20945
21019
  watching: z.boolean().optional().describe('whether a watch is currently set'),
20946
- competitors: z.array(z.any()).optional().describe('the brands under watch ({name, domain})'),
21020
+ competitors: z.array(z.any()).optional().describe('the brands under watch ({name, domain, kind})'),
21021
+ topics: z.array(z.string()).optional(),
21022
+ modules: z.any().optional(),
21023
+ cadence: z.string().optional(),
20947
21024
  platforms: z.array(z.string()).optional().describe('the ad libraries this watch checks'),
20948
21025
  lastRun: z.number().optional().describe('epoch ms of the last check (0 = never run)'),
20949
- lastRunOk: z.boolean().optional().describe('false when the last check failed every library call (usually an exhausted credit balance) — a stale board, not an empty market'),
21026
+ lastRunOk: z.boolean().optional().describe('false when the last check failed or was skipped — a stale board, not an empty market'),
20950
21027
  lastRunNote: z.string().optional().describe('what went wrong on the last run, when something did'),
21028
+ paused: z.any().optional().describe('{need, balance} when the radar is paused for credits'),
20951
21029
  nextRunAt: z.number().optional().describe('epoch ms of the next scheduled check (0 = nothing scheduled)'),
21030
+ quote: z.any().optional().describe('what the next check holds'),
20952
21031
  findings: z.array(z.any()).optional().describe('the found ads ({competitor, platform, foundAt, seed, advertiser, body, media, ad})'),
20953
21032
  total: z.number().optional().describe('how many findings the board holds before the filter/limit'),
21033
+ winners: z.array(z.any()).optional().describe('{competitor, platform, days, link, body, brief}'),
21034
+ trends: z.array(z.any()).optional().describe('{name, what, count, examples[]}'),
21035
+ topPosts: z.array(z.any()).optional().describe('{topic, platform, desc, author, link, plays}'),
21036
+ ideas: z.array(z.any()).optional().describe('{title, why, kind, brief}'),
21037
+ run: z.any().optional().describe('{jobId, status, startedAt, stage} while a check runs'),
21038
+ lastResult: z.any().optional().describe('the last check: counts, summary, email'),
20954
21039
  },
20955
21040
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
20956
- }, wrap(async (a) => {
21041
+ }, wrap(inBrand(async (a) => {
20957
21042
  const d = await apiGet('/api/watch/latest');
20958
21043
  // ABSENT IS NOT EMPTY ([[failed-read-is-not-empty]]): the route answers null when this workspace has never had a
20959
21044
  // watch at all, which is a different sentence from "the watch ran and found nothing".
20960
- if (!d) return ok('No competitor watch has ever been set for this workspace — set_competitor_watch names the brands to follow.', { watching: false, findings: [], total: 0 });
21045
+ if (!d) return ok('No market radar has ever been set for this brand. set_competitor_watch({auto:true}) sets one up.', { watching: false, findings: [], total: 0 });
20961
21046
  const want = String(a.competitor || '').trim().toLowerCase();
20962
21047
  const all = Array.isArray(d.newAds) ? d.newAds : [];
20963
21048
  const rows = want ? all.filter(n => String(n.competitor || '').toLowerCase() === want) : all;
@@ -20977,9 +21062,16 @@ function memoryNoteVerdict(text) {
20977
21062
  ad,
20978
21063
  };
20979
21064
  });
20980
- const state = `Watching: ${names.join(', ') || '(nobody)'}${d.platforms?.length ? ` · libraries: ${d.platforms.join(', ')}` : ''}`
20981
- + ` · last check ${watchWhen(d.lastRun)}${d.lastRunOk === false ? ` (FAILED${d.lastRunNote ? `: ${d.lastRunNote}` : ''} — this board is stale, not a quiet market)` : ''}`
20982
- + ` · next check ${watchWhen(d.nextRunAt)}.`;
21065
+ const adBody = (ad) => { const s = (ad || {}).snapshot || {}; return trunc((typeof s.body === 'string' ? s.body : s.body?.text) || ad?.headline || ad?.description || ''); };
21066
+ const winners = (d.winners || []).map(w => ({ competitor: w.competitor || '', platform: w.platform || '', days: w.days || 30, link: w.link || '', at: w.at || 0, body: adBody(w.ad),
21067
+ brief: `Make my own version of this ${w.competitor || 'competitor'} ad, which has been running ${w.days || 30}+ days${w.link ? ` (${w.link})` : ''}. Keep what makes it work, make it about my product and in my voice.` }));
21068
+ const trends = (d.trends || []).map(t => ({ name: t.name, what: t.what, count: t.count, at: t.at || 0, examples: (t.examples || []).map(p => ({ link: p.link, author: p.author, plays: p.plays })) }));
21069
+ const topPosts = (d.topPosts || []).map(p => ({ topic: p.topic, platform: p.platform, desc: trunc(p.desc), author: p.author, link: p.link, plays: p.plays, at: p.at || 0 }));
21070
+ const ideas = (d.ideas || []).map(i => ({ title: i.title, why: i.why, kind: i.kind, brief: i.brief, at: i.at || 0 }));
21071
+ const state = `Watching: ${names.join(', ') || '(nobody)'}${(d.topics || []).length ? ` · topics: ${d.topics.join(', ')}` : ''} · modules on: ${radarOn(d.modules)} · ${d.cadence || 'weekly'}`
21072
+ + ` · last check ${watchWhen(d.lastRun)}${d.lastRunOk === false ? ` (DID NOT COMPLETE${d.lastRunNote ? `: ${d.lastRunNote}` : ''} — this board is stale, not a quiet market)` : ''}`
21073
+ + ` · next check ${watchWhen(d.nextRunAt)}. ${radarCost(d.quote)}`
21074
+ + `${d.run ? ` A check is running now (${d.run.stage || d.run.status}).` : ''}${d.lastResult?.summary ? ` Last check: ${d.lastResult.summary}` : ''}`;
20983
21075
  // The watch board is an ad grid too — same card, so "what's new from our competitors?" SHOWS the creative.
20984
21076
  // Each finding already carries its own platform label, and `ad` holds the still the card wants as its thumb.
20985
21077
  const wcards = adSpyCards(findings.map((f) => ({
@@ -20988,16 +21080,22 @@ function memoryNoteVerdict(text) {
20988
21080
  thumb: f.ad?.imageUrl || f.ad?.image || f.ad?.snapshot?.images?.[0]?.resized_image_url || f.ad?.snapshot?.cards?.[0]?.resized_image_url || f.ad?.snapshot?.videos?.[0]?.video_preview_image_url || '',
20989
21081
  dates: [f.ad?.start_date_string, f.ad?.end_date_string].filter(Boolean).join(' → '),
20990
21082
  })));
20991
- const out = { watching: !!d.watching, competitors: d.competitors || [], platforms: d.platforms || [], lastRun: d.lastRun || 0, lastRunOk: d.lastRunOk !== false, lastRunNote: d.lastRunNote || '', nextRunAt: d.nextRunAt || 0, findings, total: rows.length, ...(hostRendersWidgets() ? adSpyBlock('new ads', rows.length, wcards) : {}) };
20992
- if (!findings.length) return ok(`${state}\nNo findings recorded yet${want ? ` for "${a.competitor}"` : ''}${d.lastRun ? '' : ' — the first check has not run'}.`, out);
21083
+ const out = { watching: !!d.watching, competitors: d.competitors || [], topics: d.topics || [], modules: d.modules || {}, cadence: d.cadence || 'weekly', platforms: d.platforms || [], lastRun: d.lastRun || 0, lastRunOk: d.lastRunOk !== false, lastRunNote: d.lastRunNote || '', paused: d.paused || null, nextRunAt: d.nextRunAt || 0, quote: d.quote || null, run: d.run || null, lastResult: d.lastResult || null, findings, total: rows.length, winners, trends, topPosts, ideas, ...(hostRendersWidgets() ? adSpyBlock('new ads', rows.length, wcards) : {}) };
20993
21084
  const line = (f) => ` • ${f.seed ? '[already running] ' : '[NEW] '}${f.competitor} · ${WATCH_PLATFORM_LABEL[f.platform] || f.platform} — ${f.advertiser}${f.body ? `: ${f.body}` : ''}${f.media ? ` ${f.media}` : ''}`;
20994
- return ok(`${state}\n${rows.length} finding(s)${want ? ` for "${a.competitor}"` : ''}${findings.length < rows.length ? ` — showing ${findings.length}` : ''}:\n` + findings.map(line).join('\n'), out);
20995
- }));
21085
+ const sections = [
21086
+ winners.length ? `Winners (running 30+ days, want your version?):\n` + winners.slice(0, 8).map(w => ` • ${w.competitor} · ${w.days} days${w.body ? `: ${w.body}` : ''}${w.link ? ` ${w.link}` : ''}`).join('\n') : '',
21087
+ trends.length ? `Trending formats:\n` + trends.slice(0, 5).map(t => ` • ${t.name} (${t.count} posts): ${t.what}`).join('\n') : '',
21088
+ topPosts.length ? `Top posts:\n` + topPosts.slice(0, 6).map(p => ` • ${p.plays || 0} views · ${p.author || ''}${p.desc ? `: ${p.desc}` : ''} ${p.link || ''}`).join('\n') : '',
21089
+ ideas.length ? `Ideas to make (each brief is ready for render_ad / plan_ad):\n` + ideas.slice(0, 3).map(i => ` • ${i.title}: ${i.brief}`).join('\n') : '',
21090
+ ].filter(Boolean).join('\n');
21091
+ if (!findings.length) return ok(`${state}\nNo new ads recorded yet${want ? ` for "${a.competitor}"` : ''}${d.lastRun ? '' : ' — the first check has not run'}.${sections ? '\n' + sections : ''}`, out);
21092
+ return ok(`${state}\n${rows.length} ad finding(s)${want ? ` for "${a.competitor}"` : ''}${findings.length < rows.length ? ` — showing ${findings.length}` : ''}:\n` + findings.map(line).join('\n') + (sections ? '\n' + sections : ''), out);
21093
+ })));
20996
21094
 
20997
21095
  server.registerTool('research_ads', {
20998
21096
  _meta: openaiMeta(AD_SPY_URI, 'Researching ads…', 'Ad research ready'),
20999
21097
  title: 'Research ads',
21000
- description: 'Use this when the user wants to see the ads a brand or competitor is running, or ad examples for a market. Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.',
21098
+ description: 'Use this when the user asks an open-ended ad question across several brands or platforms, such as comparisons, which angles are working in a market, or ad examples for a category. Do not use for one named brand\u2019s current ads: pull_competitor_ads covers that. Open-ended ad research that needs JUDGMENT across platforms — comparisons, "what angle is working", "who else is doing this", anything where the right sources are not known up front. It is an agentic loop (several rounds of library pulls plus a written synthesis) and typically takes 30-60 seconds, so it is the WRONG tool for a question that names its own answer. For one named brand\u2019s live ads use pull_competitor_ads; for one keyword or one advertiser on Meta use search_meta_ads \u2014 both are a single call and return in a few seconds. Spends credits — an agentic loop, so a handful rather than the one-call cost of a targeted search.',
21001
21099
  inputSchema: {
21002
21100
  query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
21003
21101
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object to tailor the research to; omit to use the workspace’s saved brand'),
@@ -21210,7 +21308,7 @@ function memoryNoteVerdict(text) {
21210
21308
  queries: z.number().optional().describe('query variants per platform, 1–4 (default 3); each is a paid search call'),
21211
21309
  minAvgViews: z.number().optional(),
21212
21310
  minEngagement: z.number().optional().describe('interactions per view, 0–1 (0.05 = 5%)'),
21213
- enrich: z.boolean().optional().describe('read follower counts for the top 6 (default true, ~1 credit each)'),
21311
+ enrich: z.boolean().optional().describe('followers + public contact for the top 3 on TikTok and on Instagram (default true, 1 credit each)'),
21214
21312
  marketplace: z.boolean().optional().describe('also search Instagram’s creator marketplace (Meta’s own creator directory, free) for the same niche; needs the Meta connector'),
21215
21313
  },
21216
21314
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
@@ -21430,10 +21528,10 @@ function memoryNoteVerdict(text) {
21430
21528
  title: 'Draft profile',
21431
21529
  description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle, into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
21432
21530
  inputSchema: {
21433
- domain: z.string().optional().describe('a website to scrape'),
21531
+ domain: z.string().optional().describe('a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names'),
21434
21532
  description: z.string().optional().describe('a free-text brand description (no website)'),
21435
- socialHandle: z.string().optional().describe('a social handle to draft from (influencers/creators) — pair with platform'),
21436
- platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
21533
+ socialHandle: z.string().optional().describe('a social handle (creators); needs platform, never guessed'),
21534
+ platform: z.string().optional().describe('socialHandle platform: instagram/tiktok/youtube/twitter/threads'),
21437
21535
  save: z.boolean().optional().describe('save onto the active profile (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to REPLACE the saved profile (the drafted fields overwrite the saved ones), false to never save'),
21438
21536
  },
21439
21537
  outputSchema: {
@@ -21532,6 +21630,26 @@ function memoryNoteVerdict(text) {
21532
21630
  return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
21533
21631
  }));
21534
21632
 
21633
+ // DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
21634
+ // resolves what to delete against THIS brand's Library only, deletes the stored file where it is ours, and reads
21635
+ // the Library back (POST /api/library/delete, lib/library-delete.mjs).
21636
+ server.registerTool('delete_library_item', {
21637
+ title: 'Delete from Library',
21638
+ description: "PERMANENTLY delete images/videos from this brand's Library: the entry and the stored file. Pass ids (L0… from list_library) or urls. Without confirm it lists what would be deleted; ask the user, then call again with confirm:true. Free.",
21639
+ inputSchema: {
21640
+ ids: z.array(z.string()).optional().describe('Library ids from list_library'),
21641
+ urls: z.array(z.string()).optional().describe('file links'),
21642
+ confirm: z.boolean().optional().describe('true deletes'),
21643
+ },
21644
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
21645
+ }, wrap(async ({ ids, urls, confirm }) => {
21646
+ const d = await apiPost('/api/library/delete', { ids: ids || [], urls: urls || [], confirm: confirm === true });
21647
+ const items = (d?.items || d?.removed || []).map((x) => ` ${x.id} [${x.kind || '?'}] ${abs(x.url)}`).join('\n');
21648
+ const kept = (d?.files || []).filter((f) => !f.fileDeleted).map((f) => ` ${f.id}: ${f.note}`).join('\n');
21649
+ const nf = (d?.notFound || []).length ? `\nNot in this brand's Library: ${d.notFound.join(', ')}` : '';
21650
+ return ok(`${d?.message || ''}${items ? '\n' + items : ''}${kept ? '\n' + kept : ''}${nf}`, { deleted: !!d?.deleted, needsConfirm: !!d?.needsConfirm });
21651
+ }));
21652
+
21535
21653
  server.registerTool('fetch_asset', {
21536
21654
  title: 'Fetch asset',
21537
21655
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
@@ -21740,6 +21858,7 @@ function memoryNoteVerdict(text) {
21740
21858
  }, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId, engine, faceRoute }) => {
21741
21859
  const prompt = String(instruction || '').trim();
21742
21860
  if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
21861
+ if (previewFirstFrame !== true && !previewStill) { const _rm = await designRemakeFirst(video, prompt); if (_rm) return _rm; } // words in a design: re-made
21743
21862
  const _ref = reference ? await toRef(reference) : undefined;
21744
21863
  if (previewFirstFrame === true) {
21745
21864
  const d = await apiPost('/api/video/edit/preview', { video, instruction: prompt, ...(lighting ? { lighting } : {}), ...(_ref ? { reference: _ref } : {}), ...(literal === true ? { literal: true } : {}), ...(Number.isFinite(previewAt) ? { previewAt } : {}) });
@@ -22322,9 +22441,10 @@ function memoryNoteVerdict(text) {
22322
22441
  // Brand tab; nothing is billed, so nothing is booked.
22323
22442
  server.registerTool('fetch_app_screens', {
22324
22443
  title: 'Fetch App Store screens',
22325
- description: "Pull an APP brand's REAL App Store screenshots into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. Pass appName (defaults to the saved brand's name). FREE — a keyless App Store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
22444
+ description: "Pull an APP brand's REAL store screenshots (Google Play or App Store) into the workspace brand, so a screen-hungry native format can use them. Use when the brand has 0–1 app screens on file and you want make_template_ad(template:'app-ui-tour'), or the user asks to 'pull my app's screenshots'. A store link or package id goes in storeUrl and is read exactly; else pass appName (defaults to the saved brand's name). FREE — a keyless store lookup. It needs a CONFIDENT match: an ambiguous or unknown app returns 0 screens and saves nothing, which you should relay plainly rather than retrying with guesses. On success the screens are saved to the brand (durable URLs) and are immediately usable.",
22326
22445
  inputSchema: {
22327
22446
  appName: z.string().optional().describe("the app's name to look up on the App Store — defaults to the saved brand's name"),
22447
+ storeUrl: z.string().optional().describe('a Google Play / App Store link or package id'),
22328
22448
  brandId: z.string().optional().describe('a profile id/name from list_brands to save the screens onto; omit to use the active profile'),
22329
22449
  },
22330
22450
  outputSchema: {
@@ -22334,14 +22454,15 @@ function memoryNoteVerdict(text) {
22334
22454
  saved: z.boolean().optional().describe('true when the screens were written onto the brand profile'),
22335
22455
  },
22336
22456
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
22337
- }, wrap(async ({ appName, brandId }) => {
22457
+ }, wrap(async ({ appName, storeUrl, brandId }) => {
22338
22458
  const brand = await activeBrand(brandId);
22339
22459
  const name = String(appName || brand?.name || '').trim();
22340
- if (!name) return { content: [{ type: 'text', text: "I need the app's name — this workspace has no saved brand to fall back on. Pass appName." }], isError: true };
22341
- const d = await apiGet(`/api/brand/app-screens?name=${encodeURIComponent(name)}&domain=${encodeURIComponent(brand?.domain || '')}`);
22460
+ const store = String(storeUrl || '').trim();
22461
+ if (!name && !store) return { content: [{ type: 'text', text: "I need the app's name or store link — this workspace has no saved brand to fall back on. Pass appName or storeUrl." }], isError: true };
22462
+ const d = await apiGet(`/api/brand/app-screens?name=${encodeURIComponent(name)}&domain=${encodeURIComponent(brand?.domain || '')}${store ? `&store=${encodeURIComponent(store)}` : ''}`);
22342
22463
  const found = d?.data || d || {};
22343
22464
  const screens = (found.screens || []).map(s => abs(s && (s.image || s.url) ? (s.image || s.url) : s)).filter(Boolean);
22344
- if (!screens.length) return ok(`No confident App Store match for “${name}”, so nothing was saved. Say that plainly — ask the user for the exact app name, or pick a format that needs no app screens (value-prop, myth-vs-fact, chatgpt-chat).`, { screens: [], appName: '', saved: false });
22465
+ if (!screens.length) return ok(`${found.note || `No confident App Store match for “${name}”, so nothing was added.`} Nothing was saved. Say that plainly — ask the user for the app's store link or exact name, or pick a format that needs no app screens (value-prop, myth-vs-fact, chatgpt-chat).`, { screens: [], appName: '', saved: false });
22345
22466
  // Typed single-field write (never a blind blob PUT): merge `appScreens` onto the saved brand and leave every other
22346
22467
  // field exactly as it was — the same rule update_brand follows.
22347
22468
  let saved = false;
@@ -22350,7 +22471,7 @@ function memoryNoteVerdict(text) {
22350
22471
  await writeStore('heist.brand.v1', { ...b, appScreens: found });
22351
22472
  saved = true;
22352
22473
  } catch { saved = false; }
22353
- return ok(`Pulled ${screens.length} App Store screen(s) for “${found.appName || name}”${saved ? ' and saved them to the brand' : ' (they could NOT be saved to the brand — pass them as reference URLs instead)'}.\n${screens.join('\n')}\n${saved ? "You can render the app UI tour now — make_template_ad(template:'app-ui-tour') with one punchy caption per screen (≤42 chars)." : ''}`, { screens, appName: found.appName || name, appStoreUrl: found.appStoreUrl || '', saved });
22474
+ return ok(`Pulled ${screens.length} screen(s) from ${found.store || 'the App Store'} for “${found.appName || name}”${saved ? ' and saved them to the brand' : ' (they could NOT be saved to the brand — pass them as reference URLs instead)'}.\n${screens.join('\n')}\n${saved ? "You can render the app UI tour now — make_template_ad(template:'app-ui-tour') with one punchy caption per screen (≤42 chars)." : ''}`, { screens, appName: found.appName || name, appStoreUrl: found.appStoreUrl || '', saved });
22354
22475
  }));
22355
22476
  // ══ POST PERFORMANCE (2026-08-04) ══════════════════════════════════════════════════════════════════════════════
22356
22477
  // Every per-post measurement tool below already existed and every one needs a postId the caller must ALREADY hold.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.435",
3
+ "version": "0.1.437",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI marketing agent and AI ad generator, run from your own AI agent. 922 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. 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 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. 923 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. 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 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"
@@ -12,7 +12,7 @@
12
12
  "prepublishOnly": "node ../tools/publish-preflight.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@modelcontextprotocol/sdk": "^1.29.0"
15
+ "@modelcontextprotocol/sdk": "^1.32.1"
16
16
  },
17
17
  "keywords": [
18
18
  "ad-generator",