hermoso 0.1.241 → 0.1.242

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/bin/hermoso.mjs CHANGED
@@ -391,22 +391,36 @@ async function browserLogin(apiBase) {
391
391
  return await new Promise((resolve) => {
392
392
  let done = false;
393
393
  const finish = (val) => { if (done) return; done = true; try { server.close(); } catch {} resolve(val); };
394
+ // THE KEY NEVER RIDES A URL (2026-09-14). The app hands it back in the URL FRAGMENT, which a browser keeps out of
395
+ // history, logs and referers; this page reads the fragment and POSTs it to itself over the same loopback origin.
396
+ // An app that predates that still answers with ?key= on the query string, and that path is kept below.
397
+ const settle = (res, key, st) => {
398
+ const ok = st === state && /^hmk_[A-Za-z0-9_-]{20,}$/.test(key);
399
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
400
+ res.end(cliAuthPage(ok ? 'You’re signed in ✓' : 'Sign-in didn’t complete', ok ? 'Hermoso CLI is connected. You can close this tab and return to your terminal.' : 'Please close this tab and run the command again.'));
401
+ finish(ok ? key : null);
402
+ };
394
403
  const server = http.createServer((req, res) => {
395
404
  try {
396
405
  const u = new URL(req.url, 'http://127.0.0.1');
397
406
  if (u.pathname === '/favicon.ico') { res.writeHead(204); return res.end(); }
398
- const key = u.searchParams.get('key') || '';
399
- const st = u.searchParams.get('state') || '';
400
- const ok = st === state && /^hmk_[A-Za-z0-9_-]{20,}$/.test(key);
401
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
402
- res.end(cliAuthPage(ok ? 'You’re signed in ✓' : 'Sign-in didn’t complete', ok ? 'Hermoso CLI is connected. You can close this tab and return to your terminal.' : 'Please close this tab and run the command again.'));
403
- finish(ok ? key : null);
407
+ if (u.pathname !== '/callback') { res.writeHead(404); return res.end(); }
408
+ if (req.method === 'POST') {
409
+ let body = '';
410
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
411
+ req.on('end', () => { let j = {}; try { j = JSON.parse(body || '{}'); } catch {} settle(res, String(j.key || ''), String(j.state || '')); });
412
+ return;
413
+ }
414
+ if (u.searchParams.has('key') || u.searchParams.has('error')) return settle(res, u.searchParams.get('key') || '', u.searchParams.get('state') || ''); // legacy query form
415
+ // Fragment form: nothing on the query string, the key is in location.hash. Relay it with a same-origin POST.
416
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
417
+ res.end(cliAuthRelayPage());
404
418
  } catch { try { res.writeHead(500); res.end('error'); } catch {} finish(null); }
405
419
  });
406
420
  server.on('error', () => finish(null));
407
421
  server.listen(0, '127.0.0.1', () => {
408
422
  const port = server.address().port;
409
- const authUrl = `${apiBase}/?cliauth=1&port=${port}&state=${state}`;
423
+ const authUrl = `${apiBase}/?cliauth=1&recv=2&port=${port}&state=${state}`; // recv=2: hand the key back in the fragment
410
424
  console.log(`\nOpening your browser to sign in…\nIf it doesn’t open, paste this into your browser:\n ${authUrl}\n`);
411
425
  openBrowser(authUrl);
412
426
  });
@@ -421,6 +435,14 @@ function openBrowser(url) {
421
435
  try { const c = spawn(cmd, args, { stdio: 'ignore', detached: true }); c.on('error', () => {}); c.unref(); } catch {}
422
436
  }).catch(() => {});
423
437
  }
438
+ function cliAuthRelayPage() {
439
+ return `<!doctype html><meta charset=utf-8><title>Hermoso CLI</title><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#f6f4ef;color:#1d1e1c;display:grid;place-items:center;min-height:100vh;margin:0"><p id=m>Finishing sign-in…</p><script>
440
+ (function(){var h=new URLSearchParams(location.hash.slice(1));try{history.replaceState(null,'',location.pathname);}catch(e){}
441
+ fetch('/callback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({key:h.get('key')||'',state:h.get('state')||''})})
442
+ .then(function(r){return r.text()}).then(function(t){document.open();document.write(t);document.close();})
443
+ .catch(function(){document.getElementById('m').textContent='Sign-in did not complete. Return to your terminal and run: hermoso auth login';});})();
444
+ </script>`;
445
+ }
424
446
  function cliAuthPage(title, body) {
425
447
  return `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Hermoso CLI</title><body style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0"><div style="text-align:center;max-width:420px;padding:32px"><div style="font-family:Georgia,'Times New Roman',serif;font-size:24px;font-weight:700;letter-spacing:-.5px;margin-bottom:18px">hermoso<span style="color:#d9714e">.ai</span></div><h1 style="font-size:20px;margin:14px 0 8px;font-weight:600">${title}</h1><p style="color:#a7a29a;line-height:1.55;font-size:15px">${body}</p></div></body>`;
426
448
  }
package/mcp/http.mjs CHANGED
@@ -322,6 +322,12 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
322
322
  // the predicate can be corrected from evidence instead of from a hunch.
323
323
  if (entry.client) console.error(`[mcp-remote] client: ${entry.client}`);
324
324
  await server.connect(transport);
325
+ // A ONE-SHOT NUDGE FOR A HOST THAT SNAPSHOTS THE ROSTER (2026-09-14). ChatGPT serves its users the tool list
326
+ // OpenAI scanned at review time; whether it honours a tools/list_changed notification is UNVERIFIED (the
327
+ // published app cannot be observed from here). It costs one frame after the handshake, it is only sent to
328
+ // widget hosts, and if the host does honour it the stale-snapshot problem heals itself. The real belt is
329
+ // LEGACY_TOOL_NAMES in tools.mjs, which keeps every name a snapshot could hold answering.
330
+ if (isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
325
331
  // If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
326
332
  // objects are otherwise reachable only from this request's still-open response — close them explicitly
327
333
  // rather than leaving a session pinned by a dead socket.
package/mcp/tools.mjs CHANGED
@@ -1870,6 +1870,57 @@ export function parseToolScope(raw) {
1870
1870
  // a HOST rule, not a capability we removed: every other surface still offers both.
1871
1871
  export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
1872
1872
 
1873
+ // ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
1874
+ // ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
1875
+ // chatgpt-caches-the-connector). Their snapshot from August still names eight tools that no longer exist: seven
1876
+ // Reddit posting tools we withdrew (Reddit's Data API never approved us) and scrapecreators_fetch, renamed. A call
1877
+ // to any of them answered the SDK's bare "unknown tool", which the agent reads as "Hermoso is broken". So every name
1878
+ // that was ever published is kept here for as long as a host could hold it: a rename FORWARDS to its successor with
1879
+ // the same arguments; a withdrawn tool answers the same sentence the live roster would give for it. Neither is ever
1880
+ // listed — this table is reached only through tools/call, from a host whose list is stale, which is exactly the
1881
+ // evidence installHeldToolCalls records as `stale_roster`.
1882
+ //
1883
+ // The eight names are the diff of registerTool() calls between 2ba8e3a63 (2026-08-23, the last commit before the
1884
+ // August scan's descendants) and HEAD — tools/chatgpt-legacy-names-check.mjs re-derives the "gone from HEAD" half.
1885
+ export const LEGACY_TOOL_NAMES = Object.freeze({
1886
+ scrapecreators_fetch: { to: 'fetch_social_data' },
1887
+ post_to_reddit: { gone: 'reddit' }, edit_reddit_post: { gone: 'reddit' }, delete_reddit_post: { gone: 'reddit' },
1888
+ list_reddit_posts: { gone: 'reddit' }, list_reddit_comments: { gone: 'reddit' }, reply_to_reddit_comment: { gone: 'reddit' },
1889
+ reddit_post_stats: { gone: 'reddit' },
1890
+ });
1891
+ // Agent-facing, never user-facing: a host on an old snapshot cannot be refreshed from our side (only the host's own
1892
+ // publish does that), so the one useful thing to say is "carry on, nothing to tell the user".
1893
+ export const STALE_ROSTER_NOTE = 'Note for the assistant, not for the user: your Hermoso tool list is an older published snapshot. Hermoso has added tools since, including find_tools and call_tool, which reach every capability by name. Keep working with the tools you have and do not ask the user to reconnect; the list refreshes on its own when this host publishes the newer version.';
1894
+ export const withStaleNote = (out, ctx) => {
1895
+ if (!ctx?.widgetHost || !out || !Array.isArray(out.content)) return out;
1896
+ const last = [...out.content].reverse().find((c) => c && c.type === 'text' && typeof c.text === 'string');
1897
+ if (last) last.text = `${last.text}\n\n${STALE_ROSTER_NOTE}`; else out.content.push({ type: 'text', text: STALE_ROSTER_NOTE });
1898
+ return out;
1899
+ };
1900
+ export async function legacyToolAnswer(name, request, extra, ctx) {
1901
+ const spec = LEGACY_TOOL_NAMES[name];
1902
+ reportDeadEnd('stale_roster', name, `${name} no longer exists — the host's tool list is an older published snapshot`);
1903
+ if (spec.gone) {
1904
+ const text = spec.gone === 'reddit'
1905
+ ? `${name} is no longer offered: Hermoso does not publish to Reddit (Reddit's Data API has not approved it). Reddit ADS are available through the reddit_ads tools; organic Reddit posting is not. There is nothing the user can connect, so do not point them at Settings ▸ Connectors.`
1906
+ : `${name} is no longer offered by Hermoso.`;
1907
+ return withStaleNote({ content: [{ type: 'text', text }], isError: false }, ctx);
1908
+ }
1909
+ const h = ctx.handleOf[spec.to];
1910
+ const fn = h && (h.handler || h.callback);
1911
+ if (typeof fn !== 'function') return withStaleNote({ content: [{ type: 'text', text: `${name} was renamed to ${spec.to}, which is not part of this session's roster.` }], isError: true }, ctx);
1912
+ let input = request?.params?.arguments && typeof request.params.arguments === 'object' ? request.params.arguments : {};
1913
+ if (h.inputSchema && typeof h.inputSchema.safeParse === 'function') {
1914
+ const parsed = h.inputSchema.safeParse(input);
1915
+ if (!parsed.success) {
1916
+ const issues = (parsed.error?.issues || []).slice(0, 8).map((i) => `${(i.path || []).join('.') || '(root)'}: ${i.message}`).join('; ');
1917
+ return withStaleNote({ content: [{ type: 'text', text: `Arguments for ${name} (now ${spec.to}) did not validate — ${issues}.` }], isError: true }, ctx);
1918
+ }
1919
+ input = parsed.data;
1920
+ }
1921
+ return withStaleNote(h.inputSchema ? await fn(input, extra) : await fn(extra), ctx);
1922
+ }
1923
+
1873
1924
  // ── THE DIRECTORY ROSTER (2026-09-02) — `?tools=directory` ─────────────────────────────────────────────────────
1874
1925
  // Anthropic's Software Directory Policy prohibits "software that uses AI models to generate images, video, or
1875
1926
  // audio content" (design aids excepted) and software that "executes financial transactions on behalf of users".
@@ -2099,6 +2150,7 @@ export function installHeldToolCalls(mcp, ctx) {
2099
2150
  const wrapped = async (request, extra) => {
2100
2151
  const name = String(request?.params?.name || '');
2101
2152
  const h = name && ctx.handleOf[name];
2153
+ if (!h && LEGACY_TOOL_NAMES[name]) return legacyToolAnswer(name, request, extra, ctx); // a name only an old snapshot still holds
2102
2154
  if (h && h.enabled === false) {
2103
2155
  const why = holdReasonFor(name, ctx);
2104
2156
  if (why) { const t = holdReasonText(name, why, ctx); reportDeadEnd(why, name, t); return { content: [{ type: 'text', text: t }], isError: true }; }
@@ -2116,7 +2168,7 @@ export function installHeldToolCalls(mcp, ctx) {
2116
2168
  }
2117
2169
  input = parsed.data;
2118
2170
  }
2119
- return h.inputSchema ? await fn(input, extra) : await fn(extra);
2171
+ return withStaleNote(h.inputSchema ? await fn(input, extra) : await fn(extra), ctx); // the call proves the roster is stale; a widget host's agent is told so, its user is not
2120
2172
  }
2121
2173
  }
2122
2174
  return orig(request, extra);
@@ -2791,7 +2843,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2791
2843
  title: 'Delete a post from the connected Bluesky account',
2792
2844
  description: "PERMANENTLY delete one of the connected Bluesky account's OWN posts. IRREVERSIBLE — the AT Protocol removes the record from the account's repo, there is no trash and no undelete, and the post's likes, reposts, replies and quotes go with it. Call it WITHOUT confirm first: nothing is deleted, and it reports the post's REAL text and its live like / repost / reply / quote counts read back from Bluesky. Show the user that, get an unambiguous yes, then call again with confirm:true — plus, once the post has ANY engagement, confirmText echoing the post's own text (the first 40 characters is enough; any longer leading run works too). confirmText exists because confirming that you meant to delete SOMETHING does not prove you aimed at the right post, and a wrong id must not be confirmable blind. A brand-new post with nothing on it stays a ONE-call delete. Identify the post by its AT-URI or by just its RECORD KEY — the short id at the end of its bsky.app link, e.g. 3mtc4n3fibn2x. Deleting only ever works on the connected account's own posts; another account's URI is refused. 0 credits. Needs Bluesky connected (Settings ▸ Connectors ▸ Bluesky, or connect_connector).",
2793
2845
  inputSchema: {
2794
- uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, or just its record key (3mtc4n3fibn2x)"),
2846
+ uri: z.string().describe("the post's AT-URI (at://did:plc:…/app.bsky.feed.post/…) as post_to_bluesky returned it, the handle form of the same URI for the CONNECTED account only (at://<its handle>/app.bsky.feed.post/…), or just its record key (3mtc4n3fibn2x)"),
2795
2847
  confirm: z.boolean().optional().describe('REQUIRED true — deletion is permanent and cannot be undone'),
2796
2848
  confirmText: z.string().optional().describe("the post's own text as the unconfirmed call reported it — the first 40 characters is enough. Required once the post has any likes, reposts, replies or quotes. A post with no text asks for its cid instead."),
2797
2849
  },
@@ -16865,6 +16917,8 @@ function memoryNoteVerdict(text) {
16865
16917
  items: z.array(z.object({
16866
16918
  key: z.string().optional().describe('a stable id for this ad if you have one (an ad_archive_id, creativeId, …). Omit and one is derived from the link/media so re-saving is idempotent'),
16867
16919
  advertiser: z.string().optional().describe('the brand running the ad'),
16920
+ page_name: z.string().optional().describe('alias of advertiser: the field search_meta_ads returns, accepted as-is'),
16921
+ pageName: z.string().optional().describe('alias of advertiser'),
16868
16922
  title: z.string().optional().describe('headline / hook'),
16869
16923
  body: z.string().optional().describe('the ad copy'),
16870
16924
  image: z.string().optional().describe('image URL'),
@@ -16889,7 +16943,7 @@ function memoryNoteVerdict(text) {
16889
16943
  const key = swipeKeyOf(it);
16890
16944
  const ex = s.ads.find(x => x && x.key === key);
16891
16945
  if (ex) { if (ex.collectionId !== col.id) moved++; ex.collectionId = col.id; continue; }
16892
- s.ads.push({ key, collectionId: col.id, advertiser: String(it.advertiser || '').slice(0, 120), title: String(it.title || '').slice(0, 300), body: String(it.body || '').slice(0, 2000), image: String(it.image || ''), video: String(it.video || ''), link: String(it.link || it.url || ''), platform: String(it.platform || '').slice(0, 40), savedAt: Date.now() , ...(it.platform === 'creator' && it.creator && typeof it.creator === 'object' ? { creator: it.creator } : {}) });
16946
+ s.ads.push({ key, collectionId: col.id, advertiser: String(it.advertiser || it.page_name || it.pageName || '').slice(0, 120) /* search_meta_ads says page_name (2026-09-14) */, title: String(it.title || '').slice(0, 300), body: String(it.body || '').slice(0, 2000), image: String(it.image || ''), video: String(it.video || ''), link: String(it.link || it.url || ''), platform: String(it.platform || '').slice(0, 40), savedAt: Date.now() , ...(it.platform === 'creator' && it.creator && typeof it.creator === 'object' ? { creator: it.creator } : {}) });
16893
16947
  saved++;
16894
16948
  }
16895
16949
  s.activeId = col.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.241",
3
+ "version": "0.1.242",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI ad studio and marketing MCP server with 837 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",