hermoso 0.1.495 → 0.1.496

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.
@@ -276,6 +276,8 @@ h3{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--mute
276
276
  .quick{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:12px 0 4px}
277
277
  .chip{font-size:13px;background:var(--card)}
278
278
  .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
279
+ .ccard{display:flex;flex-direction:column;gap:6px;margin:14px 0 4px;border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:12px}
280
+ .ccard .row{margin-top:4px}
279
281
  .gcard{text-align:left;border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:12px;cursor:var(--cur)}
280
282
  .gcard b{display:block;margin-bottom:2px}.gcard span{color:var(--muted);font-size:12px}
281
283
  .field{margin:12px 0}
@@ -360,7 +362,7 @@ const APP_SCRIPT = String.raw`<script>
360
362
  var TOOL_VIEW = { open_hermoso_home: 'home', open_library: 'library', open_ad_brief: 'create', set_up_hermoso: 'setup', show_ad_concepts: 'concepts', show_ad_results: 'results' };
361
363
  var CARD_VIEWS = ['concepts', 'results'];
362
364
  var PANEL_VIEWS = ['home', 'library', 'jobs', 'calendar', 'ads', 'create', 'setup'];
363
- var SECTIONS_FOR = { home: ['brand', 'brands', 'library', 'jobs', 'prefs'], library: ['library', 'jobs', 'brands', 'calendar'], jobs: ['jobs', 'brands'], calendar: ['calendar', 'brands', 'prefs'], ads: ['ads', 'brands'], create: ['brand', 'brands', 'library', 'creators', 'products', 'prefs'], setup: ['brand', 'brands', 'connectors'] };
365
+ var SECTIONS_FOR = { home: ['brand', 'brands', 'library', 'jobs', 'prefs', 'connectors'], library: ['library', 'jobs', 'brands', 'calendar'], jobs: ['jobs', 'brands'], calendar: ['calendar', 'brands', 'prefs'], ads: ['ads', 'brands'], create: ['brand', 'brands', 'library', 'creators', 'products', 'prefs'], setup: ['brand', 'brands', 'connectors'] };
364
366
  var EDITABLE = ['queued', 'scheduled'];
365
367
  var state = {
366
368
  view: 'home', surface: 'panel', data: null, host: {}, caps: {}, selected: [], focus: null, busy: false, pollTimer: null, pollStarted: 0, routed: false, ready: false,
@@ -659,6 +661,7 @@ const APP_SCRIPT = String.raw`<script>
659
661
  + '<button class="chip" data-act="create" data-format="video">Make a video ad</button>'
660
662
  + '<button class="chip" data-act="msg" data-text="Help me schedule a post from my Hermoso Library.">Schedule a post</button>'
661
663
  + '</div></section>';
664
+ html += connectCardHtml(d);
662
665
  html += '<h3>Get started</h3><div class="cards">'
663
666
  + gcard('tab', 'library', 'Library', 'Your images and videos')
664
667
  + gcard('tab', 'create', 'Create', 'Pick options, see concepts, then render')
@@ -672,6 +675,14 @@ const APP_SCRIPT = String.raw`<script>
672
675
  if (d.notes && d.notes.brand) html += sectionNote('brand');
673
676
  return html;
674
677
  }
678
+ // NOTHING CONNECTED YET: posting from here starts with one browser sign-in. Absent while the read is pending or failed,
679
+ // because an unread list is not an empty one. The links leave through openLink, like every other link in this app.
680
+ function connectCardHtml(d){
681
+ var c = d.connectCard; if (!c || !c.channels || !c.channels.length) return '';
682
+ return '<div class="ccard" data-connect-card="1"><b>Post what you make from this chat</b><span class="s">Nothing is connected yet. Sign in once on the platform, then ask here to post or schedule.</span><div class="row">'
683
+ + c.channels.map(function(x){ return '<button class="btn primary" data-act="open-link" data-url="' + esc(x.url) + '">' + esc(x.name) + '</button>'; }).join('')
684
+ + '<button class="btn" data-act="setup" data-step="connections">All channels</button></div></div>';
685
+ }
675
686
  function gcard(act, target, title, sub){
676
687
  return '<button class="gcard" data-act="' + act + '" ' + (act === 'setup' ? 'data-step' : 'data-view') + '="' + target + '"><b>' + esc(title) + '</b><span>' + esc(sub) + '</span></button>';
677
688
  }
@@ -1142,6 +1153,7 @@ const APP_SCRIPT = String.raw`<script>
1142
1153
  if (a === 's-draft') return draftBrand();
1143
1154
  if (a === 's-step') { state.setup.step = el.getAttribute('data-step'); if (state.setup.step === 'connections') load(['connectors']); return render(); }
1144
1155
  if (a === 's-check') return load(['connectors']);
1156
+ if (a === 'open-link') { var ou = el.getAttribute('data-url'); if (ou) openLink(ou); return; }
1145
1157
  if (a === 's-connect') { var pv = el.getAttribute('data-p'); var c = (d.connections || []).filter(function(x){ return x.provider === pv; })[0]; if (c && c.connectUrl) { openLink(c.connectUrl).then(function(){ startWatch(pv); render(); }); } return; }
1146
1158
  // concepts
1147
1159
  if (a === 'c-pick') { state.concepts.open = i; return render(); }
@@ -1287,6 +1299,17 @@ const CHANNELS_OF = { meta: ['facebook', 'instagram'], instagram: ['instagram'],
1287
1299
  const CHANNEL_LABEL = { facebook: 'Facebook', instagram: 'Instagram', threads: 'Threads', tiktok: 'TikTok', youtube: 'YouTube', linkedin: 'LinkedIn', x: 'X', pinterest: 'Pinterest', google_business: 'Google Business Profile', bluesky: 'Bluesky', telegram: 'Telegram' };
1288
1300
  export const ALL_CHANNELS = Object.freeze(Object.keys(CHANNEL_LABEL));
1289
1301
 
1302
+ // THE HOME SCREEN'S CARD FOR AN ACCOUNT WITH NOTHING TO POST TO (2026-11-02): the most connected channels on offer, first
1303
+ // to last by real connection counts (the server's `popular`), each with its one-click link. A connected posting channel
1304
+ // answers null: there is nothing to ask for. `via` names this surface on the admin Funnel tab.
1305
+ export const HOME_CONNECT_VIA = 'chatgpt-home';
1306
+ export function homeConnectCard(connections = [], popular = []) {
1307
+ const rows = (Array.isArray(connections) ? connections : []).filter((c) => c && CHANNELS_OF[c.provider] && c.provider !== 'google_business');
1308
+ if (!rows.length || rows.some((c) => c.connected)) return null;
1309
+ const rank = (p) => { const i = (Array.isArray(popular) ? popular : []).indexOf(p); return i < 0 ? 999 : i; };
1310
+ return { channels: [...rows].sort((a, b) => rank(a.provider) - rank(b.provider)).slice(0, 3).map((c) => ({ provider: c.provider, name: c.name, url: `${c.connectUrl}&via=${HOME_CONNECT_VIA}` })) };
1311
+ }
1312
+
1290
1313
  export function libraryPayload(reads = {}, { view = 'library', origins = [], proxyBase = '', abs = (u) => u } = {}) {
1291
1314
  const appOrigin = (() => { try { return new URL(proxyBase).origin; } catch { return ''; } })();
1292
1315
  const own = (u) => { try { return origins.includes(new URL(u).origin); } catch { return false; } };
@@ -1376,6 +1399,7 @@ export function libraryPayload(reads = {}, { view = 'library', origins = [], pro
1376
1399
  // The channels a post can be scheduled to right now: schedule_post's ids, from the connections that are on.
1377
1400
  // Google Business Profile is left out because schedule_post refuses it at enqueue (held back by Google).
1378
1401
  out.channels = [...new Set(out.connections.filter((c) => c.connected).flatMap((c) => CHANNELS_OF[c.provider] || []))].filter((id) => id !== 'google_business').map((id) => ({ id, label: CHANNEL_LABEL[id] }));
1402
+ if (view === 'home') out.connectCard = homeConnectCard(out.connections, d.popular);
1379
1403
  }
1380
1404
  }
1381
1405
  if (reads.ads) {
@@ -1821,7 +1845,7 @@ export function libraryToolDefs() {
1821
1845
  // proxyBase (our /api/img), abs (absolute asset URLs), call(name, args, extra), which runs an EXISTING tool's handler
1822
1846
  // for this session, and prefs {read, write} for the per-brand defaults this app adds.
1823
1847
  export function registerLibraryApp(server, deps) {
1824
- const { resourceMeta, origins = [], proxyBase = '', abs = (u) => u, call = async () => null, prefs = null } = deps || {};
1848
+ const { resourceMeta, origins = [], proxyBase = '', abs = (u) => u, call = async () => null, prefs = null, markHomeConnectCard = null } = deps || {};
1825
1849
  const meta = libraryResourceMeta(resourceMeta, origins);
1826
1850
  const description = meta['openai/widgetDescription'];
1827
1851
  server.registerResource('hermoso-library-app', LIBRARY_APP_URI, { description, mimeType: LIBRARY_APP_MIME, _meta: meta },
@@ -1850,7 +1874,11 @@ export function registerLibraryApp(server, deps) {
1850
1874
  const handles = {};
1851
1875
  const reg = (name, fn) => { handles[name] = server.registerTool(name, defs[name], fn); };
1852
1876
 
1853
- reg(EXT_HOME_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'library', 'jobs', 'prefs'], 'home'));
1877
+ reg(EXT_HOME_TOOL, async (args, extra) => {
1878
+ const out = await answer(args, extra, ['brand', 'brands', 'library', 'jobs', 'prefs', 'connectors'], 'home');
1879
+ if (out?.structuredContent?.connectCard && typeof markHomeConnectCard === 'function') { try { Promise.resolve(markHomeConnectCard()).catch(() => {}); } catch {} } // counted on the admin Funnel tab; never holds the screen up
1880
+ return out;
1881
+ });
1854
1882
  reg(EXT_ENTRY_TOOL, async (args, extra) => answer(args, extra, ['library', 'jobs', 'brands', 'calendar'], PANEL_VIEWS.includes(args?.view) ? args.view : 'library'));
1855
1883
  reg(EXT_BRIEF_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'library', 'creators', 'products', 'prefs'], 'create', { surface: 'card', payload: { brief: { format: args?.format === 'video' ? 'video' : args?.format === 'image' ? 'image' : '', product: clip(args?.product, 600) } } }));
1856
1884
  reg(EXT_SETUP_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'connectors'], 'setup', { surface: 'card', payload: { step: args?.step === 'connections' ? 'connections' : 'brand' } }));
@@ -109,6 +109,25 @@ export function videoChoiceText(tool, choice, opts = {}) {
109
109
  }
110
110
 
111
111
 
112
+ // ── A FINISHED RENDER, READ FROM THE REPLY (2026-11-02) ─────────────────────────────────────────────────────────────
113
+ // Which replies may carry the "post this" next step (lib/connect-prompt.mjs): the tools that make a file, and only once
114
+ // that file exists. A hosted video answers {stillRendering} and arrives through get_job, so get_job is on the list and a
115
+ // queued, running or failed job is not a finished render. Pure, no imports — the twin rule above.
116
+ export const RENDER_REPLY_TOOLS = Object.freeze(['generate_image', 'generate_video', 'render_ad', 'make_template_ad', 'make_motion_film', 'make_thumbnail', 'make_explainer', 'generate_avatar', 'stitch_video', 'product_sizzle', 'recast_motion', 'finish_video', 'multiply_ad', 'clone_video', 'clone_static', 'remix_static', 'get_job']);
117
+ const _VIDEO_FILE = /\.(mp4|mov|webm|m4v)(\?|#|$)/i, _IMAGE_FILE = /\.(png|jpe?g|webp|gif)(\?|#|$)/i;
118
+ export function finishedRenderKind(tool, r) {
119
+ if (!RENDER_REPLY_TOOLS.includes(String(tool || '')) || !r || r.isError || !Array.isArray(r.content)) return '';
120
+ const s = r.structuredContent;
121
+ if (!s || typeof s !== 'object' || s.stillRendering) return '';
122
+ if (typeof s.status === 'string' && s.status && s.status !== 'done') return '';
123
+ const inner = [s, s.raw, s.result, s.result && s.result.data].filter((x) => x && typeof x === 'object');
124
+ const has = (k) => inner.some((x) => (Array.isArray(x[k]) ? x[k].length > 0 : !!x[k]));
125
+ const url = String(s.url || '');
126
+ if (has('video') || _VIDEO_FILE.test(url)) return 'video';
127
+ if (has('image') || has('images') || has('thumbnails') || has('slides') || _IMAGE_FILE.test(url)) return 'image';
128
+ return '';
129
+ }
130
+
112
131
  // ── OUT OF CREDITS ON A HOST THAT FORBIDS SELLING CREDITS (2026-09-30, OpenAI's plugin guidelines) ─────────────────
113
132
  // "Plugins may: explain that a certain feature is not available with the user's current plan or entitlement; link to
114
133
  // an informational page … Plugins may not: link directly to a checkout … or promote upgrades." ChatGPT is not offered
package/mcp/tools.mjs CHANGED
@@ -32,7 +32,7 @@ function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolN
32
32
  import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
33
33
  import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
34
34
  // THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
35
- import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint } from './tool-hints.mjs';
35
+ import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind } from './tool-hints.mjs';
36
36
  // THE CHATGPT EXTENSIONS APP (home, Library panel, concept picker, brief, setup, settings, @-mentions), registered only
37
37
  // behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
38
38
  // ./openai-extensions.mjs. Same twin-safe specifier rule.
@@ -521,6 +521,28 @@ export const withAccountArg = (name, def) => {
521
521
  return { ...def, inputSchema: { ...s, account: ACCOUNT_ARG } };
522
522
  };
523
523
 
524
+ // A FINISHED RENDER ON AN ACCOUNT WITH NOTHING CONNECTED ENDS BY OFFERING TO POST IT (2026-11-02). The server decides
525
+ // (once, at most twice, never after a connection; lib/connect-prompt.mjs) and words it, so every host and an older CLI
526
+ // say one thing. It rides the reply and never a tool description, which OpenAI scans. A separate text block, so the block
527
+ // a card parses stays exactly as it was. Bounded and best effort: the render already succeeded, so a slow or failed read
528
+ // of this next step says nothing and costs the caller nothing. The REST passthrough (/v1/tools, the one store with no
529
+ // `client`) is an integration parsing a response, not a person in a chat, so it is left out.
530
+ const CONNECT_PROMPT_WAIT_MS = 2000;
531
+ export async function withConnectPrompt(tool, r, post = apiPost) {
532
+ try {
533
+ const kind = finishedRenderKind(tool, r);
534
+ if (!kind) return r;
535
+ const store = mcpCtx.getStore();
536
+ if (store && (store.connectPromptQuiet || (store.remote && store.client === undefined))) return r;
537
+ const sc = r.structuredContent || {};
538
+ const job = String(sc.jobId || sc.id || '').slice(0, 80);
539
+ const client = store ? String(store.client || '') : 'hermoso';
540
+ const d = await Promise.race([Promise.resolve(post('/api/mcp/connect-prompt', { tool, kind, job, client })).catch(() => null), new Promise((res) => { const t = setTimeout(() => res(null), CONNECT_PROMPT_WAIT_MS); if (t && typeof t.unref === 'function') t.unref(); })]);
541
+ if (!d || d.show !== true || typeof d.text !== 'string' || !d.text) return r;
542
+ const out = { ...r, content: [...r.content, { type: 'text', text: d.text }], structuredContent: { ...sc, connectPrompt: { channels: Array.isArray(d.channels) ? d.channels : [] } } };
543
+ return withHints(out, [...hintsOf(r), { do: 'offer to post this; hand the user one of the connect links in this reply', why: 'no publishing channel is connected, and connecting needs a browser sign-in on the platform' }]);
544
+ } catch { return r; }
545
+ }
524
546
  const wrap = (fn) => {
525
547
  // A NAMED closure, so registerTools' proxy can stamp the tool name onto the very function it registers and this
526
548
  // body can read it back. Reading a name off `fn` instead would be wrong: `fn` is the inner handler and the thing
@@ -560,7 +582,7 @@ const wrap = (fn) => {
560
582
  }
561
583
  }
562
584
  } catch {}
563
- return r;
585
+ return await withConnectPrompt(_tool, r);
564
586
  }
565
587
  catch (e) {
566
588
  try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
@@ -1020,6 +1042,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
1020
1042
  .dl { border: 1px solid rgba(128,128,128,.45); border-radius: 999px; padding: 2px 10px; font-size: 12px; font-weight: 600;
1021
1043
  text-decoration: none; color: inherit; opacity: .8; }
1022
1044
  .dl:hover { opacity: 1; background: rgba(128,128,128,.14); }
1045
+ .next { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 0 12px 10px; font-size: 12.5px; }
1023
1046
  .spacer { flex: 1; }
1024
1047
  .wordmark { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; opacity: .4; }
1025
1048
  .empty { padding: 18px 16px; font-size: 13px; opacity: .75; }
@@ -1041,6 +1064,28 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
1041
1064
  // free, read-only get_job over the host bridge (get_job carries openai/widgetAccessible for exactly this) until
1042
1065
  // the media exists, then renders it. The model's own get_job loop still works and is unaffected — whichever
1043
1066
  // arrives first wins, because both paths land in the same view().
1067
+ // THE "POST THIS" LINKS (2026-11-02). On ChatGPT a hosted video finishes through THIS card's get_job poll, so that answer
1068
+ // never reaches the model; without a row here the next step could be said to nobody. Read from structuredContent, or
1069
+ // from the prompt's text block on a host that hands callTool the content array alone. Only our own app's connect links.
1070
+ var postLinks = [];
1071
+ function postLinksFrom(r) {
1072
+ if (!r || typeof r !== 'object') return [];
1073
+ var okUrl = function (u) { return /^https:\/\/([a-z0-9-]+\.)*hermoso\.ai\/\?connect=[a-z_]{1,40}(&[A-Za-z0-9_=-]+)*$/.test(String(u || '')); };
1074
+ var q = [r, r.structuredContent, r.toolOutput, r.result], out = [];
1075
+ for (var i = 0; i < q.length; i++) {
1076
+ var ch = q[i] && q[i].connectPrompt && q[i].connectPrompt.channels;
1077
+ if (Array.isArray(ch)) { for (var j = 0; j < ch.length; j++) if (ch[j] && ch[j].label && okUrl(ch[j].url)) out.push({ label: String(ch[j].label), url: String(ch[j].url) }); }
1078
+ if (out.length) return out;
1079
+ }
1080
+ var blocks = Array.isArray(r.content) ? r.content : (r.result && Array.isArray(r.result.content) ? r.result.content : []);
1081
+ for (var b = 0; b < blocks.length && b < 8; b++) {
1082
+ var t = blocks[b] && blocks[b].text;
1083
+ if (typeof t !== 'string' || t.indexOf('?connect=') < 0) continue;
1084
+ var lines = t.split('\n');
1085
+ for (var n = 0; n < lines.length; n++) { var m = /^- (.+): (https:\/\/\S+)$/.exec(lines[n]); if (m && okUrl(m[2])) out.push({ label: m[1], url: m[2] }); }
1086
+ }
1087
+ return out;
1088
+ }
1044
1089
  var live = null; // the payload OUR poll resolved — it is newer than a frozen toolOutput, so it wins
1045
1090
  var polls = 0, timer = null, pollErr = '', missed = 0, timeouts = 0, busy = 0, backoff = 0, gone = false, lastJob = null;
1046
1091
  // MAX_MISSES IS A TIME BUDGET, NOT A COUNT, AND 3 WAS SHORTER THAN A RENDER (2026-08-24). With the backoff
@@ -1262,6 +1307,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
1262
1307
  var guard = setTimeout(function () { if (settled) return; settled = true; timeouts++; backoff++; pollErr = 'timeout'; render(); }, POLL_TIMEOUT_MS); // a bridge that did not answer in 8s must not be hit again in 5
1263
1308
  api.callTool('get_job', { id: v.jobId }).then(once(function (r) {
1264
1309
  var d = jobFrom(r);
1310
+ var pl = postLinksFrom(r); if (pl.length) postLinks = pl;
1265
1311
  if (d && typeof d === 'object' && d.status === 'not_found') { gone = true; return; } // FINAL — one answer settles it; burning three misses on it would only delay the truth
1266
1312
  // A POLL ANSWER MUST BE A get_job RESULT, AND get_job ALWAYS CARRIES A status FIELD (2026-08-24).
1267
1313
  // Measured: 29 consecutive polls, every one readable, not one of them ever done, against a job that had
@@ -1351,7 +1397,9 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
1351
1397
  return;
1352
1398
  }
1353
1399
  if (!body) { root.innerHTML = ''; stop(); collapse(); return; }
1354
- root.innerHTML = '<div class="card">' + body + footer + '</div>';
1400
+ var _ol = postLinksFrom({ structuredContent: (window.openai && window.openai.toolOutput) || null }); if (_ol.length) postLinks = _ol;
1401
+ var nextRow = (!v.pending && !v.error && postLinks.length) ? '<div class="next"><span>Post this:</span>' + postLinks.map(function (l) { return '<a class="dl" href="' + esc(l.url) + '" target="_blank" rel="noopener">' + esc(l.label) + '</a>'; }).join('') + '</div>' : '';
1402
+ root.innerHTML = '<div class="card">' + body + footer + nextRow + '</div>';
1355
1403
  if (v.pending) { schedule(); } else { stop(); }
1356
1404
  }
1357
1405
  render();
@@ -3546,11 +3594,14 @@ function registerChatGPTExtensions(rawServer, ctx, opts) {
3546
3594
  // unchanged (awaitRenderJob reads waitMs only after submitJob).
3547
3595
  call: (name, args, extra, o) => {
3548
3596
  const s = ctx.srcOf[name]; if (!s || typeof s.handler !== 'function') return null;
3549
- const run = () => s.handler(args || {}, extra);
3597
+ // connectPromptQuiet: the app reads through the tool handlers, and that answer goes to a screen rather than the
3598
+ // model, so it must not spend a showing of the "post this" next step (withConnectPrompt).
3599
+ const run = () => { const cur = mcpCtx.getStore(); return cur ? mcpCtx.run({ ...cur, connectPromptQuiet: true }, () => s.handler(args || {}, extra)) : s.handler(args || {}, extra); };
3550
3600
  const store = o && o.noWait ? mcpCtx.getStore() : null;
3551
3601
  return store ? mcpCtx.run({ ...store, waitMs: 0 }, run) : run();
3552
3602
  },
3553
3603
  prefs: { read: () => readStore(EXT_PREFS_KEY), write: (v) => writeStore(EXT_PREFS_KEY, v) },
3604
+ markHomeConnectCard: () => apiPost('/api/mcp/connect-prompt/home', {}),
3554
3605
  });
3555
3606
  installEntrypointIcons(rawServer);
3556
3607
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.495",
3
+ "version": "0.1.496",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1109 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 30 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram 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",