hermoso 0.1.251 → 0.1.253
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 +8 -1
- package/mcp/hermoso-mcp.mjs +7 -4
- package/mcp/http.mjs +14 -4
- package/mcp/tool-cost.mjs +103 -0
- package/mcp/tool-health.mjs +103 -0
- package/mcp/tool-hints.mjs +57 -0
- package/mcp/tools.mjs +317 -57
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,13 @@ campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools,
|
|
|
8
8
|
**841 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
|
+
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
12
|
+
and insights, comments and DMs, connectors, brand profiles and team seats are **free on every plan**, with no
|
|
13
|
+
per-post or per-channel fee and no seat pricing. Credits are spent only on running an AI model (image, video,
|
|
14
|
+
voice, text, planning, post-production) and on Ad Spy research, and posting an ad you already rendered is never a
|
|
15
|
+
second charge. The one exception is X (Twitter), where posting and reads bill a few credits per call because X
|
|
16
|
+
charges per API request.
|
|
17
|
+
|
|
11
18
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
12
19
|
Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in
|
|
13
20
|
Google Merchant Center. Publishing and scheduling — **ten** channels: Facebook, Instagram, Threads, TikTok,
|
|
@@ -149,7 +156,7 @@ session, run `/mcp`, find the server and press Authenticate. Measured against Cl
|
|
|
149
156
|
|
|
150
157
|
Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
|
|
151
158
|
learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
|
|
152
|
-
`plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits — same prices as the Studio. Only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management and analytics are free on every plan (X
|
|
159
|
+
`plan_ad`/`plan_variations` to use it). Renders bill your Hermoso credits — same prices as the Studio. Only AI model runs and Ad Spy research spend credits; publishing, scheduling, ads management and analytics are free on every plan (posting to X and reading X data are the one per-call exception, managing X ads is free).
|
|
153
160
|
|
|
154
161
|
## 1. MCP server (stdio) — Claude Code / Cursor / Codex
|
|
155
162
|
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -17,10 +17,13 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
17
17
|
instructions: MCP_INSTRUCTIONS,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
// Roster scoping, same groups as the hosted connector's ?tools= (see registerTools).
|
|
21
|
-
//
|
|
22
|
-
// ~
|
|
23
|
-
//
|
|
20
|
+
// Roster scoping, same groups as the hosted connector's ?tools= (see registerTools). SINCE 2026-09-17 THE DEFAULT
|
|
21
|
+
// IS CORE-FIRST: the `core` group plus the handful of tools that make a connection drivable, measured at ~6K tokens
|
|
22
|
+
// against ~87K for the old default of every group but the opt-in three. Nothing is lost — everything else is held
|
|
23
|
+
// out of the LIST on SIZE alone, and `find_tools` finds it, `call_tool` runs it and a direct tools/call to a name
|
|
24
|
+
// you already know still works. `enable_tools` LISTS a whole group mid-session for a client that re-lists (stdio
|
|
25
|
+
// does). HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further; and
|
|
26
|
+
// MCP_CORE_FIRST=1 opts this process into the small core-first roster (the default is the full roster: see mcp/tools.mjs).
|
|
24
27
|
// An unknown group EXITS rather than silently serving all of them — a scoped connection you did not get is
|
|
25
28
|
// worse than one you were told you could not have.
|
|
26
29
|
// Both env names are read: HERMOSO_TOOLS is the current prefix, HEIST_TOOLS the pre-rebrand name that is live in
|
package/mcp/http.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
17
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
18
|
-
import { registerTools, MCP_INSTRUCTIONS, parseToolScope } from './tools.mjs';
|
|
18
|
+
import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
19
19
|
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.
|
|
@@ -234,8 +234,11 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
234
234
|
// `?tools=research,create` narrows the roster this connection advertises (see registerTools). Read here rather
|
|
235
235
|
// than inside registerTools so BOTH the anonymous discovery handshake and a real session honour the same query,
|
|
236
236
|
// and so an unknown group is refused at the door with the valid list instead of silently serving every group.
|
|
237
|
-
// ABSENT,
|
|
238
|
-
//
|
|
237
|
+
// ABSENT, an AUTHENTICATED session resolves to the CORE-FIRST default (see defaultToolGroups in tools.mjs): the
|
|
238
|
+
// core tools plus a few that make the connection drivable, with everything else held out of the LIST on size and
|
|
239
|
+
// reachable through find_tools + call_tool. `?tools=all` restores the full roster for one connection and
|
|
240
|
+
// `MCP_CORE_FIRST=1` opts this process into the small core-first roster; the default is the full roster. The anonymous discovery path above is
|
|
241
|
+
// deliberately NOT core-first — see the comment on its registerTools call.
|
|
239
242
|
// The scope fixed here is the STARTING roster, not a cage: `enable_tools` widens it mid-session and the SDK
|
|
240
243
|
// notifies the client. That is deliberate — the old comment's "tools/list must not change under a live client"
|
|
241
244
|
// was the right instinct for a scope the SERVER changes silently, and the wrong one for a change the CLIENT
|
|
@@ -260,7 +263,14 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
260
263
|
// scope to and nothing honest to read — and a registry crawler or an agent deciding whether to connect MUST
|
|
261
264
|
// see the real catalog, not a zero-connector one. registerTools treats an absent `connectors` exactly like a
|
|
262
265
|
// failed read: full roster. Do not "fix" this by reading the workspace off the request; it is forgeable.
|
|
263
|
-
|
|
266
|
+
// ── AND THE ANONYMOUS ROSTER IS NOT CORE-FIRST, DELIBERATELY (2026-09-17) ──────────────────────────────────
|
|
267
|
+
// An authenticated session pays for its roster on every turn, which is the whole argument for core-first. This
|
|
268
|
+
// request pays for nothing and is nobody's turn: it is a registry crawler (registry.modelcontextprotocol.io,
|
|
269
|
+
// Glama, Smithery), OpenAI's own submission scanner, or an agent deciding whether to connect at all — and for
|
|
270
|
+
// every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
|
|
271
|
+
// a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
|
|
272
|
+
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
273
|
+
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
264
274
|
if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
|
|
265
275
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
266
276
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ── WHAT A TOOL COSTS, AS find_tools REPORTS IT (2026-09-17) ───────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// An agent choosing between two tools should be able to see the price before it calls one, the way it can see the
|
|
4
|
+
// parameters. Monid's `inspect` does exactly this and their docs tell the agent to read the live price rather than
|
|
5
|
+
// trust the doc — "it is the source of truth, not this document". Ours has to hold the same line.
|
|
6
|
+
//
|
|
7
|
+
// THERE IS NO PER-TOOL PRICE TABLE IN THIS PRODUCT, AND INVENTING ONE WOULD BE THE WRONG FIX. Credits are spent
|
|
8
|
+
// per MODEL RUN, and what a run costs depends on the model, the resolution and the length — which is why the only
|
|
9
|
+
// exact numbers live in the live catalog `/api/generate/status` serves and `hermoso_capabilities` prints. A number
|
|
10
|
+
// written into this file would be a second, stale answer to a question the server already answers correctly
|
|
11
|
+
// ([[unsourced-capability-comments]]). So: THIS MODULE CLASSIFIES, IT NEVER PRICES. Every digit find_tools shows
|
|
12
|
+
// comes from that same live catalog, fetched once per process and quoted as a range; when the catalog has not been
|
|
13
|
+
// read the row says the class and no number, which is honest rather than guessed.
|
|
14
|
+
//
|
|
15
|
+
// THE CLASSIFICATION IS THE SERVER'S OWN ONE SENTENCE, MAPPED ONTO THE GROUP MARKERS. `COST_MODEL_SENTENCE` in
|
|
16
|
+
// server.js — the one copy every surface prints — says credits are spent on exactly two things, running an AI
|
|
17
|
+
// model and Ad Spy research, with X as the single per-call exception, and that everything else (publishing,
|
|
18
|
+
// scheduling, campaign management, analytics, comments, DMs, connectors, brands, team) is free on every plan.
|
|
19
|
+
// Those two things are the `create` and `research` groups, which each tool declares by the `server.group()` marker
|
|
20
|
+
// it was written under and which `tools/tool-group-truth-check.mjs` already holds true. So the rule derives from
|
|
21
|
+
// two things that are independently maintained, not from a hand-list of 841 names:
|
|
22
|
+
//
|
|
23
|
+
// group `create` → 'model' (it runs a model — image, video, voice, text, planning, post-production)
|
|
24
|
+
// group `research` → 'research' (Ad Spy: our own research key pays the vendor)
|
|
25
|
+
// provider `x` → 'percall' (X bills us per API request; the documented exception)
|
|
26
|
+
// everything else → 'free'
|
|
27
|
+
//
|
|
28
|
+
// THE TWO EXCEPTION SETS BELOW ARE THE READS THAT LIVE INSIDE THOSE GROUPS — a Library listing or a job poll is
|
|
29
|
+
// filed under `create` because that is the section it was written in, and it spends nothing. They are the only
|
|
30
|
+
// hand-maintained part, they are small, and `tools/tool-cost-health-check.mjs` cross-examines them against each
|
|
31
|
+
// tool's OWN description: a tool whose description says "0 credits" or "Free, read-only" may not be classed paid,
|
|
32
|
+
// and one whose description states its OWN price ("Spends credits" at a sentence start, or a "Paid (…)" clause)
|
|
33
|
+
// may not be classed free. That makes the two statements check each other instead of drifting apart. The claim has
|
|
34
|
+
// to be about THIS tool: three genuinely free tools merely CONTAIN the words — hermoso_credits prints the rule
|
|
35
|
+
// itself, list_creators explains that generating a fresh face costs credits, and set_competitor_watch says the
|
|
36
|
+
// weekly RUN spends — so a loose match would have mis-flagged all three.
|
|
37
|
+
//
|
|
38
|
+
// PURE, NO IMPORTS, so the checks RUN these functions and so the cli twin — which ships without lib/ and without a
|
|
39
|
+
// repo around it — resolves the same specifier the server copy does.
|
|
40
|
+
|
|
41
|
+
// Reads inside `create`. Each is a lookup or a poll: it returns something we already hold and runs no model.
|
|
42
|
+
export const CREATE_FREE_READS = Object.freeze([
|
|
43
|
+
'get_job', 'list_library', 'fetch_asset', 'list_skills', 'get_skill',
|
|
44
|
+
'list_product_photos', 'fetch_app_screens', 'list_hooks',
|
|
45
|
+
'list_meta_posts', 'list_published_posts', 'post_performance', 'diagnose_posts', 'backfill_posts',
|
|
46
|
+
]);
|
|
47
|
+
// Reads inside `research`. `find_competitors` says "0 credits" in its own description (it is the discovery model,
|
|
48
|
+
// billed to us, not a paid ad-data call); the watch tools only write and read a stored preference — the weekly
|
|
49
|
+
// run that spends is a job, not this call.
|
|
50
|
+
export const RESEARCH_FREE_READS = Object.freeze(['find_competitors', 'list_watch_findings', 'set_competitor_watch']);
|
|
51
|
+
|
|
52
|
+
export const COST_CLASSES = Object.freeze(['free', 'model', 'research', 'percall']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The cost CLASS of one tool. `group` is the registry's own marker for it; `provider` is toolProvider(name).
|
|
56
|
+
* Unknown/unmapped ⇒ 'free', which matches the server's sentence: everything that is not a model run or Ad Spy
|
|
57
|
+
* research is free on every plan.
|
|
58
|
+
*/
|
|
59
|
+
export function toolCostClass(name, group, provider = null) {
|
|
60
|
+
const n = String(name || '');
|
|
61
|
+
if (provider === 'x') return 'percall'; // X charges per API request — the one exception
|
|
62
|
+
if (group === 'create') return CREATE_FREE_READS.includes(n) ? 'free' : 'model';
|
|
63
|
+
if (group === 'research') return RESEARCH_FREE_READS.includes(n) ? 'free' : 'research';
|
|
64
|
+
return 'free';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The sentence each class prints. NO DIGITS HERE — see the header. `range` is filled in by the caller from the
|
|
68
|
+
// live catalog when it has read one.
|
|
69
|
+
export function costLabel(cls, range = '') {
|
|
70
|
+
if (cls === 'percall') return 'a few credits per call (X charges per API request)';
|
|
71
|
+
if (cls === 'research') return 'credits (Ad Spy research)';
|
|
72
|
+
if (cls === 'model') return range ? `credits — ${range}` : 'credits (runs a model; hermoso_capabilities has the exact per-model figure)';
|
|
73
|
+
return 'free';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The one range find_tools quotes, built from the SAME payload hermoso_capabilities prints
|
|
78
|
+
* (`GET /api/generate/status`). Returns '' when the catalog has not been read — an absent number is never
|
|
79
|
+
* replaced by a guessed one ([[failed-read-is-not-empty]]).
|
|
80
|
+
*
|
|
81
|
+
* `kind` picks which catalog to read: a video tool is priced by the video models, an image tool by the image ones.
|
|
82
|
+
* Anything else gets the whole span, which is the honest answer for a tool that could route to either.
|
|
83
|
+
*/
|
|
84
|
+
export function creditRangeFrom(status, kind = 'any') {
|
|
85
|
+
if (!status || typeof status !== 'object') return '';
|
|
86
|
+
const nums = [];
|
|
87
|
+
const eat = (rows) => { for (const m of rows || []) {
|
|
88
|
+
if (typeof m?.credits === 'number') nums.push(m.credits);
|
|
89
|
+
else if (m?.credits && typeof m.credits === 'object') for (const v of Object.values(m.credits)) if (typeof v === 'number') nums.push(v);
|
|
90
|
+
if (m?.creditsBySize) for (const v of Object.values(m.creditsBySize)) if (typeof v === 'number') nums.push(v);
|
|
91
|
+
} };
|
|
92
|
+
if (kind === 'image' || kind === 'any') eat(status.options?.image?.models);
|
|
93
|
+
if (kind === 'video' || kind === 'any') eat(status.options?.video?.models);
|
|
94
|
+
const ok = nums.filter((n) => Number.isFinite(n) && n > 0);
|
|
95
|
+
if (!ok.length) return '';
|
|
96
|
+
const lo = Math.min(...ok), hi = Math.max(...ok);
|
|
97
|
+
return lo === hi ? `${lo} credits` : `${lo}-${hi} credits by model/length/resolution`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Which catalog a generation tool is priced from. Deliberately tiny and name-shaped: a tool it does not recognise
|
|
101
|
+
// gets the class label with no number, which is the safe direction.
|
|
102
|
+
export const costKindOf = (name) => (/video|avatar|sizzle|explainer|stitch|reframe|upscale|recast|dub|clip|subtitle|beat|finish|multiply|motion/i.test(String(name || '')) ? 'video'
|
|
103
|
+
: /image|thumbnail|photo|static|render_ad|template/i.test(String(name || '')) ? 'image' : 'any');
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ── IS THIS TOOL WORKING RIGHT NOW? (2026-09-17) ───────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Monid's discover puts a health status and a median run time on every row, and hides an endpoint that is in
|
|
4
|
+
// outage. That is worth copying: an agent picking between two tools should not have to discover by spending a
|
|
5
|
+
// minute and a credit that one of them has failed its last nine calls.
|
|
6
|
+
//
|
|
7
|
+
// WHERE THE NUMBERS COME FROM, AND WHY NOT FROM THE ERROR LEDGER ALONE. The error ledger is the right instinct —
|
|
8
|
+
// it is our record of what users hit — but it records only FAILURES, with no successes and no durations, so a
|
|
9
|
+
// failure RATE and a typical duration cannot be computed from it at all. A ledger read is also an HTTP round trip
|
|
10
|
+
// per find_tools call, which is exactly the cost this feature must not add. So health is measured where the calls
|
|
11
|
+
// already pass: `wrap()` in mcp/tools.mjs, the one seam every tool handler returns through, and the same seam that
|
|
12
|
+
// FEEDS the error ledger via reportToolError. Same signal, in process, free, and it carries the two things the
|
|
13
|
+
// ledger cannot: the successes and the clock.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IT IS AND IS NOT. It is THIS PROCESS's recent experience, which on the hosted server is every caller's
|
|
16
|
+
// calls and on stdio is this session's. It is therefore evidence, not a fleet statistic — and the difference is
|
|
17
|
+
// stated in the wording find_tools prints. NO RECENT CALLS IS SAID AS SUCH, never rendered as healthy
|
|
18
|
+
// ([[failed-read-is-not-empty]]): "we have not seen this tool run lately" and "this tool works" are different
|
|
19
|
+
// claims and the second one is the dangerous one to guess.
|
|
20
|
+
//
|
|
21
|
+
// BOUNDED BY CONSTRUCTION. At most HEALTH_TOOLS_MAX tools are tracked (LRU), at most HEALTH_SAMPLES_MAX outcomes
|
|
22
|
+
// each, and anything older than HEALTH_WINDOW_MS is dropped on read. Worst case is a few tens of kilobytes, on a
|
|
23
|
+
// process that already holds a 350K-token tool canon. No timer, no I/O, no allocation on the read path beyond the
|
|
24
|
+
// window filter — a find_tools call that scans 841 rows must stay cheap.
|
|
25
|
+
//
|
|
26
|
+
// PURE-ISH AND DEPENDENCY-FREE: one module-level Map and functions over it, so the check RUNS this rather than
|
|
27
|
+
// reading it, and so the cli twin (no lib/, no repo) resolves the same specifier as the server copy.
|
|
28
|
+
|
|
29
|
+
export const HEALTH_WINDOW_MS = 60 * 60 * 1000; // an hour: long enough to see a pattern, short enough to be "now"
|
|
30
|
+
export const HEALTH_SAMPLES_MAX = 20; // per tool
|
|
31
|
+
export const HEALTH_TOOLS_MAX = 400; // distinct tools tracked, LRU by last write
|
|
32
|
+
export const HEALTH_FAILING_RATE = 0.6; // "nearly all failing" — ranked last, and said out loud
|
|
33
|
+
export const HEALTH_DEGRADED_RATE = 0.25;
|
|
34
|
+
export const HEALTH_MIN_CALLS = 3; // below this, one bad call is noise, not a verdict
|
|
35
|
+
|
|
36
|
+
const _tools = new Map(); // name -> [{ at, ok, ms }] (insertion-ordered ⇒ LRU)
|
|
37
|
+
|
|
38
|
+
/** Record one finished tool call. Never throws — a health write may not break a tool's own answer. */
|
|
39
|
+
export function recordToolOutcome(name, opts) {
|
|
40
|
+
try {
|
|
41
|
+
// DESTRUCTURED INSIDE THE try, NOT IN THE PARAMETER LIST. A default only fires on `undefined`, so
|
|
42
|
+
// `recordToolOutcome(name, null)` threw a TypeError BEFORE the try could catch it — and this function is
|
|
43
|
+
// called from inside `wrap()`, where a throw would replace a tool's real answer with a health-bookkeeping
|
|
44
|
+
// error. Measured: the check's "a bad write never throws" assertion went red on exactly that call.
|
|
45
|
+
const { ok, ms = 0, now = Date.now() } = opts || {};
|
|
46
|
+
const n = String(name || '');
|
|
47
|
+
if (!n) return;
|
|
48
|
+
let arr = _tools.get(n);
|
|
49
|
+
if (arr) _tools.delete(n); else arr = []; // re-insert ⇒ this tool is the most recently used
|
|
50
|
+
arr.push({ at: now, ok: !!ok, ms: Number(ms) || 0 });
|
|
51
|
+
if (arr.length > HEALTH_SAMPLES_MAX) arr.splice(0, arr.length - HEALTH_SAMPLES_MAX);
|
|
52
|
+
_tools.set(n, arr);
|
|
53
|
+
while (_tools.size > HEALTH_TOOLS_MAX) _tools.delete(_tools.keys().next().value);
|
|
54
|
+
} catch { /* health is never load-bearing */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test seam only — the checks need a clean slate between sections. */
|
|
58
|
+
export function _resetToolHealth() { _tools.clear(); }
|
|
59
|
+
export function _healthSize() { return _tools.size; }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The verdict for one tool. Shapes:
|
|
63
|
+
* { state: 'unseen' } — nothing recent; SAY SO, never "healthy"
|
|
64
|
+
* { state: 'healthy'|'degraded'|'failing', calls, failures, failRate, medianMs }
|
|
65
|
+
*/
|
|
66
|
+
export function toolHealth(name, now = Date.now()) {
|
|
67
|
+
const arr = (_tools.get(String(name || '')) || []).filter((s) => now - s.at <= HEALTH_WINDOW_MS);
|
|
68
|
+
if (!arr.length) return { state: 'unseen', calls: 0 };
|
|
69
|
+
const calls = arr.length;
|
|
70
|
+
const failures = arr.filter((s) => !s.ok).length;
|
|
71
|
+
const failRate = failures / calls;
|
|
72
|
+
const times = arr.map((s) => s.ms).filter((m) => m > 0).sort((a, b) => a - b);
|
|
73
|
+
const medianMs = times.length ? times[Math.floor(times.length / 2)] : 0;
|
|
74
|
+
// A SINGLE FAILED CALL IS NOT A VERDICT. Below HEALTH_MIN_CALLS the honest answer is that we have seen it run,
|
|
75
|
+
// with however many failures, and nothing stronger — so it stays 'healthy' for ranking and the counts are
|
|
76
|
+
// printed beside it. Above it, the rate decides.
|
|
77
|
+
const state = calls >= HEALTH_MIN_CALLS && failRate >= HEALTH_FAILING_RATE ? 'failing'
|
|
78
|
+
: calls >= HEALTH_MIN_CALLS && failRate >= HEALTH_DEGRADED_RATE ? 'degraded'
|
|
79
|
+
: 'healthy';
|
|
80
|
+
return { state, calls, failures, failRate, medianMs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One short phrase for a find_tools row. `unseen` says it is unseen; it never reads as an endorsement. */
|
|
84
|
+
export function healthLabel(h) {
|
|
85
|
+
if (!h || h.state === 'unseen') return 'no recent calls';
|
|
86
|
+
const t = h.medianMs ? `, ~${h.medianMs >= 1000 ? `${(h.medianMs / 1000).toFixed(1)}s` : `${h.medianMs}ms`} typical` : '';
|
|
87
|
+
if (h.state === 'healthy') return `${h.calls}/${h.calls - h.failures} recent calls ok${t}`.replace(/^(\d+)\/(\d+)/, '$2 of $1');
|
|
88
|
+
return `${h.state.toUpperCase()}: ${h.failures} of ${h.calls} recent calls failed${t}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The rank penalty a row carries. 0 = nothing wrong. Higher sorts LATER.
|
|
93
|
+
* A tool that cannot run at all (a connector this workspace has not made) and a tool that is failing its calls are
|
|
94
|
+
* both worse picks than a working one — but NEITHER IS HIDDEN, because "we have no such tool" is the single most
|
|
95
|
+
* expensive wrong answer this product can give ([[prompt-rosters-go-stale]]).
|
|
96
|
+
*/
|
|
97
|
+
export function healthPenalty(h, hold = null) {
|
|
98
|
+
let p = 0;
|
|
99
|
+
if (hold) p += 2; // not connected / host policy / directory cage
|
|
100
|
+
if (h?.state === 'failing') p += 2;
|
|
101
|
+
else if (h?.state === 'degraded') p += 1;
|
|
102
|
+
return p;
|
|
103
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ── HINTS: THE NEXT STEP, NAMED (2026-09-17) ───────────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// This product already tells an agent what to do next, constantly and well — "connect it under Settings ▸
|
|
4
|
+
// Connectors, then call again", "run buy_credits to top up", "call find_tools then call_tool", "reconnect with
|
|
5
|
+
// ?tools=all". Every one of those is a SENTENCE inside a wall of other sentences, so reading it is a parsing job
|
|
6
|
+
// an agent may or may not do. Monid rides a `Hints` block on its responses — server-suggested next command,
|
|
7
|
+
// related endpoints, caveats — and tells the agent to prefer hints over guessing. That is the cheap half we were
|
|
8
|
+
// missing: the advice exists, it just was not addressable.
|
|
9
|
+
//
|
|
10
|
+
// SO THIS ADDS A FIELD AND REMOVES NOTHING. The prose stays exactly as it was, because it is what a model
|
|
11
|
+
// actually reads and because half our hosts show only the text. The hint is the same advice, keyed, for a client
|
|
12
|
+
// or an agent that wants to branch on it rather than match a string.
|
|
13
|
+
//
|
|
14
|
+
// WHERE IT RIDES. `_meta`, which the MCP spec makes the sanctioned extension point on any result ("any result MAY
|
|
15
|
+
// carry it"), so every client that does not know the key ignores it. This file already puts the structured error
|
|
16
|
+
// marker there for the same reason. NOT `structuredContent`: a tool declares an outputSchema and a caller may
|
|
17
|
+
// validate against it, and a next-step suggestion is not part of any tool's output contract.
|
|
18
|
+
//
|
|
19
|
+
// THE SHAPE IS {do, why} AND NOTHING ELSE. `do` is the action, written as the call to make where there is one, so
|
|
20
|
+
// it can be executed without interpretation; `why` is the reason, so an agent can decide rather than obey. No
|
|
21
|
+
// severity, no codes, no nesting — a richer shape would need a schema, a version and a migration, and the whole
|
|
22
|
+
// value here is that it is free to add wherever we already write the sentence.
|
|
23
|
+
//
|
|
24
|
+
// PURE, NO IMPORTS — same twin-safety rule as roster-scope.mjs and well-formed.mjs.
|
|
25
|
+
|
|
26
|
+
export const HINTS_KEY = 'hermoso.ai/hints';
|
|
27
|
+
export const HINTS_MAX = 4; // a list nobody reads is not a hint; keep it to the next step, not a plan
|
|
28
|
+
export const HINT_DO_MAX = 200, HINT_WHY_MAX = 300;
|
|
29
|
+
|
|
30
|
+
/** Normalise a hint list. Drops anything without a `do`, trims, caps. Never throws. */
|
|
31
|
+
export function normalizeHints(hints) {
|
|
32
|
+
try {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const h of Array.isArray(hints) ? hints : []) {
|
|
35
|
+
const doIt = String(h?.do ?? '').replace(/\s+/g, ' ').trim().slice(0, HINT_DO_MAX);
|
|
36
|
+
if (!doIt) continue; // a hint with no action is noise
|
|
37
|
+
out.push({ do: doIt, why: String(h?.why ?? '').replace(/\s+/g, ' ').trim().slice(0, HINT_WHY_MAX) });
|
|
38
|
+
if (out.length >= HINTS_MAX) break;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
} catch { return []; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attach hints to a tool result without touching anything already on it — including an existing `_meta`, which
|
|
46
|
+
* carries the structured error marker on every failure and must survive.
|
|
47
|
+
* A result with no usable hints comes back BY REFERENCE, unchanged: adding an empty key to every reply in the
|
|
48
|
+
* product would be pure weight.
|
|
49
|
+
*/
|
|
50
|
+
export function withHints(result, hints) {
|
|
51
|
+
const list = normalizeHints(hints);
|
|
52
|
+
if (!list.length || !result || typeof result !== 'object') return result;
|
|
53
|
+
return { ...result, _meta: { ...(result._meta || {}), [HINTS_KEY]: list } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read them back — the shape a check and a client both use, so neither has to know the key. */
|
|
57
|
+
export const hintsOf = (result) => (result && result._meta && Array.isArray(result._meta[HINTS_KEY])) ? result._meta[HINTS_KEY] : [];
|