hermoso 0.1.215 → 0.1.217

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/mcp/http.mjs CHANGED
@@ -20,7 +20,7 @@ import { mcpCtx, connectedProviders } from './client.mjs';
20
20
 
21
21
  // Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
22
22
  // `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
23
- export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null } = {}) {
23
+ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null } = {}) {
24
24
  if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
25
25
  if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
26
26
  console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
@@ -334,7 +334,16 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
334
334
  }
335
335
  // Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
336
336
  // the SDK answered — a refused tools/call is still a call the roster earned.
337
- for (const m of methodsOf(req.body)) { if (m === 'tools/list') entry.listed = true; else if (m === 'tools/call') entry.calls = (entry.calls || 0) + 1; }
337
+ for (const m of methodsOf(req.body)) {
338
+ if (m === 'tools/list') entry.listed = true;
339
+ else if (m === 'tools/call') {
340
+ entry.calls = (entry.calls || 0) + 1;
341
+ // THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
342
+ // key exists", and 37 sessions last week listed the roster and never called a tool while 803 grants went out.
343
+ // A session's first tools/call is what proves the connection is USED; the hook stamps the account's touch row.
344
+ if (entry.calls === 1 && typeof onFirstCall === 'function' && entry.user?.accountId) { try { onFirstCall({ accountId: entry.user.accountId, userId: entry.user.userId || null, client: entry.client || '', ua: entry.ua || '', src: entry.src || null }); } catch {} }
345
+ }
346
+ }
338
347
  // The caller's bearer rides into every /api call the tools make — spend bills THEIR account. `remote: true`
339
348
  // says what this store IS: a per-request tenant scope on a shared, multi-tenant process. client.mjs treats the
340
349
  // presence of this store as the signal to STOP falling back to the process's own HERMOSO_PROFILE / HERMOSO_OWNER,
package/mcp/tools.mjs CHANGED
@@ -2626,7 +2626,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2626
2626
  const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
2627
2627
  const off = [..._offGroup.entries()].filter(([name]) => qw.some((w) => name === w || name.includes(w))).slice(0, cap)
2628
2628
  .map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
2629
- if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable it with enable_tools, or call them by name through run_tool):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
2629
+ if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
2630
2630
  }
2631
2631
  if (!total) 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 });
2632
2632
  for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
@@ -4116,7 +4116,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4116
4116
  account: z.string().optional().describe('WHICH Instagram account when target is instagram and the brand has several — Page-linked and Instagram Login accounts alike; an @username or id from list_connector_accounts("instagram"). Several and none named is refused by name; omit when there is one.'),
4117
4117
  scheduleAt: z.string().optional().describe('FACEBOOK ONLY — schedule instead of posting now. ISO timestamp (2026-08-01T09:00:00Z) or unix seconds; must be 10 minutes to 30 days ahead. Facebook holds the post and publishes it at that time, so nothing has to stay running on our side. Instagram and Threads have NO scheduling in Meta’s API — passing this for them is refused rather than silently posted immediately.'),
4118
4118
  locationId: z.string().optional().describe('Threads only — a place id from search_threads_locations, to geotag the post to a physical location (restaurant, storefront)'),
4119
- trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
4119
+ trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand. Instagram then shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs well. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, a Facebook post or a Threads post is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for. Omit it for a normal Reel.'),
4120
4120
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
4121
4121
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('ACCESSIBILITY \u2014 the description screen readers announce, and what the platform otherwise auto-generates badly or not at all. Describe what is actually IN the picture, never the caption. ONE STRING describes the picture; on a CAROUSEL it describes EVERY slide. Pass an ARRAY of strings instead to describe each slide separately, aligned to the slide order \u2014 that is strictly better on a multi-slide post, because one sentence read out over six different pictures is wrong for five of them. More descriptions than pictures is refused rather than dropped. WHERE IT LANDS, per Meta\u2019s own docs: INSTAGRAM image posts and the IMAGE slides of an Instagram carousel (up to 1000 characters; dropped rather than sent on a Reel or a video slide, which Meta do not support); FACEBOOK Page photos including every photo of an album, via alt_text_custom (Meta publish no length for it, so nothing is truncated); THREADS on a single-image or single-video post ONLY \u2014 Meta document no way to attach alt text to a Threads CAROUSEL slide, so a Threads carousel publishes undescribed and the reply says so rather than risking the whole post on a guess. (Meta\u2019s AI disclosure defaults from PROVENANCE: a Hermoso render is declared is_ai_generated; media that came through upload_file or an external URL, i.e. the user\u2019s own photos or footage, is NOT. Pass `aiGenerated` to force it either way.)'),
4122
4122
  pageId: z.string().optional().describe('target Page id (from list_meta_pages); omit = first Page'),
@@ -4228,7 +4228,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4228
4228
  communityId: z.string().optional().describe('X — publish into an X COMMUNITY instead of the main timeline: the number in the community’s own URL (x.com/i/communities/<id>). The connected account must be a MEMBER of it; X answers a non-member and a non-existent id with the same refusal and does not separate them.'),
4229
4229
  paidPartnership: z.boolean().optional().describe('X — label the post a PAID PARTNERSHIP, the same disclosure Hermoso already ships for TikTok. OPT-IN ONLY: set it when the post is sponsored, gifted or otherwise paid for, and never assume it on the user’s behalf.'),
4230
4230
  collaborators: z.array(z.string()).optional().describe('INSTAGRAM \u2014 a COLLAB post: up to 3 Instagram usernames invited to CO-AUTHOR it, so it appears on their profile too once they accept, with both handles in the header and the engagement shared. Handles only ("hermosoai"); a leading @ is fine. Instagram must be one of the `channels` \u2014 asking for collaborators on a schedule Instagram is not on is REFUSED now rather than discovered when it fires, and the other channels in a mixed schedule simply publish without co-authors. THE INVITE IS SENT WHEN THE POST FIRES, not when you schedule it, and it is PENDING until the other account accepts in their notifications; check with instagram_collaborators afterwards rather than telling the user it is live on both profiles.'),
4231
- trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first, so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
4231
+ trialReel: z.enum(['MANUAL', 'SS_PERFORMANCE']).optional().describe('INSTAGRAM TRIAL REEL \u2014 publish this Reel to NON-FOLLOWERS ONLY at first (Instagram allows trials only on accounts above its follower threshold — about 1,000 followers; an ineligible account is refused by name and nothing is posted), so a hook can be tested on a cold audience without spending it on the people who already follow the brand; Instagram shows it to followers only if it graduates. MANUAL = the creator graduates it by hand in the Instagram app; SS_PERFORMANCE = Instagram graduates it automatically if it performs. REELS ONLY and INSTAGRAM ONLY: an image, a carousel, or a Facebook/Threads channel is REFUSED BY NAME rather than quietly published as an ordinary post \u2014 a trial that silently goes to every follower is the exact opposite of what was asked for, so Instagram must be one of the `channels` and the item must carry a video. Omit it for a normal Reel.'),
4232
4232
  aiGenerated: z.boolean().optional().describe('INSTAGRAM / FACEBOOK REEL \u2014 Meta\u2019s is_ai_generated self-disclosure. OMIT IT and Hermoso decides from provenance: a Hermoso render is declared, media that came through upload_file or from an external URL (the user\u2019s own photographs or footage) is NOT \u2014 a real photo must never carry Instagram\u2019s \u201cAI info\u201d label. Pass true or false only to override: false strips the label from something Hermoso would otherwise declare, true declares a render the user uploaded themselves.'),
4233
4233
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
4234
4234
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
@@ -17524,7 +17524,9 @@ function memoryNoteVerdict(text) {
17524
17524
  },
17525
17525
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
17526
17526
  }, wrap(async ({ query, limit }) => {
17527
- const d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query });
17527
+ let d;
17528
+ try { d = await apiGet('/api/sc/run', { __path: '/v2/instagram/reels/search', query }); }
17529
+ catch (e) { if (e?.status === 404) d = { reels: [] }; else throw e; } // a vendor 404 is "no reels match", not a failure (2026-09-07)
17528
17530
  const all = (d.reels || d.items || []).map((r) => {
17529
17531
  const o = r.owner || r.user || {};
17530
17532
  return qp({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.215",
3
+ "version": "0.1.217",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 801 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",