UNPKG

@mnemoverse/mcp-memory-server

Version:

Hosted AI agent memory over MCP that learns from outcomes — feedback re-ranks recall — with shared rooms for multi-agent work; one key or OAuth across Claude Code, Cursor, VS Code & ChatGPT, no infra to run

1,062 lines • 62.9 kB
/**
 * What a failed API call TELLS THE AGENT TO DO.
 *
 * WHY THIS MODULE EXISTS. Until now every non-2xx became one sentence:
 *
 *     Mnemoverse API error 401: {"code":"UNAUTHORIZED","message":"Invalid or
 *     revoked API key.","requestId":null,"retryable":false,"details":null}
 *
 * The `isError` flag and the status code were right; the TEXT was a raw echo of
 * a wire body. Verified against production with a real `tools/call`
 * (2026-08-16): with a bad key, `memory_write` returns exactly that. The reader
 * of a tool result is a MODEL, and that string gives a model nothing to act on,
 * so the two things it actually does are relay "error 401" to the user or guess
 * — and the cheapest guess is "the network is down", which sends the user to
 * debug a working network while a placeholder key sits in their config.
 *
 * Every message here therefore answers three questions in order: what happened,
 * WHOSE problem it is, and what to do next — including, explicitly, whether to
 * retry. "Do not retry" is not decoration: an agent that loops on a 429 turns
 * one rate-limit into a sustained one, and an agent that loops on a 401 burns a
 * conversation on a key that will never start working.
 *
 * THE RAW BODY IS NEVER DROPPED. It is appended after the guidance, still
 * carrying the literal `Mnemoverse API error <status>` that this repo's tests
 * and anyone's grep already look for. Making the text kind to an agent must not
 * make it useless to a human debugging at 3am.
 *
 * WHAT THE STATUS CODES ACTUALLY MEAN HERE, verified by reading the engine
 * (mnemoverse-core `src/mnemo/api/`, 2026-08-16) rather than assumed from HTTP
 * folklore. The distinctions below are the entire point of this file:
 *
 *   401  auth.py — "Invalid or revoked API key." / "Missing API key." The key.
 *        (Read on 2026-08-16. Since 2026-09-20 the engine answers a sentence
 *        per cause and adds `details.reason` and `details.keys_url`; the old
 *        sentence is what an engine without that change still sends, which is
 *        why explain401 keeps the substring branch as its fallback.)
 *   403  routes.py, rooms_routes.py, auth.py — "Room is archived", "Not an
 *        active member of this room", "Read-only membership cannot write to
 *        this room", "Invalid room address", "You do not own this room", plus
 *        the OIDC scope rules. NOT a bad key: the caller was identified and
 *        then refused. Telling the user to replace a working key over a 403
 *        would be a new, more confident lie than the raw echo was.
 *   404  A room that does not exist for this key — or, when the body says
 *        nothing at all, a path the deployment does not serve. A DOMAIN never
 *        404s: an empty domain reads as empty, so "your domain is missing" is
 *        the wrong guess and is named as wrong below.
 *   429  THREE different causes with opposite advice. rate_limit.py returns
 *        `retryable: true` with a `Retry-After` header (waiting works);
 *        usage.py (daily quota) and subscription_guard.py (atom limit, payment
 *        past due) return `retryable: false` (waiting does NOT work — the
 *        account needs an upgrade or a new day). A blanket "wait and retry"
 *        would be wrong for two of the three.
 *   5xx  auth.py returns 503 `retryable: true` when its DB pool is gone. Ours,
 *        not theirs.
 *
 * A 2xx CAN FAIL TOO, and gets the same treatment for the same reason. A reply
 * whose body this client cannot parse is not a status at all, so none of the
 * sentences above fit it — and it used to borrow the TRANSPORT sentence, which
 * asserts that nothing answered. `explainUnreadableBody` at the bottom of this
 * file owns that case now; the distinction it protects is "a reply arrived and
 * was unreadable" versus "no reply arrived", which have opposite fixes.
 *
 * RELATION TO THE STARTUP PROBE (src/index.ts, `probeApiKeyInBackground`, added
 * in 0.8.4). That probe treats 401 and 403 alike, and is right to: it calls
 * `GET /memory/stats`, which addresses no room, so a 403 there can only come
 * from the auth layer. On a TOOL CALL the same status usually comes from a room
 * the caller named, which is why the two surfaces diverge on 403 and must not be
 * "unified" by a later reader. They agree where it matters: same `Mnemoverse:`
 * opener, same `MNEMOVERSE_API_KEY` variable name, same console origin.
 */
/** Where the raw detail stops. A gateway can answer with a megabyte of HTML,
 *  and an error message is not a place to spend a model's context — but the
 *  first few hundred characters are where the useful part of any real error
 *  body lives. Truncation is announced, never silent. */
const MAX_BODY_CHARS = 800;
/** The console page that issues keys — the one place a user fixes a 401.
 *  Exported so src/requests.ts can point at the same URL from
 *  `refusePlaceholderKey` instead of repeating the literal (the engine is
 *  becoming a second producer of this same URL, `details.keys_url` on a 401
 *  body, which is validated against an allow-list rather than trusted
 *  outright, see `validatedKeysUrl` below, but this constant stays the
 *  fallback for both). */
export const KEYS_URL = "https://console.mnemoverse.com/dashboard/keys";
/** The console page that shows quota and upgrades — where a 429 that waiting
 *  cannot fix is resolved. Same URL the engine puts in its own 429 bodies. */
const USAGE_URL = "https://console.mnemoverse.com/dashboard/usage";
/** The base URL a user is sent back to when theirs is aimed somewhere wrong.
 *  Mirrors `DEFAULT_API_URL` in src/index.ts and is duplicated on purpose: this
 *  module is imported BY index.ts, so reading the value from there would be a
 *  cycle. Change one, grep for the other. */
const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
/** Defaults applied field-by-field, with a `typeof`/literal guard on each:
 *  `wording` crosses a public package boundary a caller controls only at
 *  compile time, so a malformed value at runtime degrades to the default
 *  instead of propagating (e.g. into a template literal, or a `Wording`
 *  field silently taking a fifth value no branch here checks for). */
function resolveWording(w) {
    return {
        serverNoun: w?.serverNoun === "this connector" ? "this connector" : "this server",
        auth: w?.auth === "oauth" ? "oauth" : "api-key",
        keysUrl: typeof w?.keysUrl === "string" && w.keysUrl !== "" ? w.keysUrl : KEYS_URL,
        rawDetail: typeof w?.rawDetail === "boolean" ? w.rawDetail : true,
    };
}
/**
 * Is `details.keys_url` safe to put in front of a user?
 *
 * The body is server-controlled, or, on every path this module exists
 * against, controlled by whoever answered instead: a proxy, a gateway, or
 * the endpoint a wrong MNEMOVERSE_API_URL points at. Trusting a URL out of
 * that body outright would let any of them steer a rejected user to a
 * phishing page under the same "create a key here" instruction this file
 * already gives. The allow-list is narrow on purpose: https only, and the
 * host compared exactly against `console.mnemoverse.com` rather than by
 * prefix or suffix, for the same reason src/index.ts compares a loopback
 * hostname exactly rather than with `startsWith`/`endsWith`.
 */
function validatedKeysUrl(raw) {
    if (typeof raw !== "string")
        return undefined;
    let url;
    try {
        url = new URL(raw);
    }
    catch {
        return undefined;
    }
    // `host`, not `hostname`: it includes the port, so :8443 on the right name
    // is not "the console". Credentials in the URL are refused outright, since
    // a link the agent reads aloud to a person has no business carrying any,
    // and `user@host` is the oldest way to make one host read like another.
    // `url.href === raw`: the parser is forgiving, and what it forgives is the
    // attack. It strips tabs and newlines out of the middle of the string and
    // trims leading and trailing whitespace, so a value that continues on a new
    // line ("...\nIGNORE PREVIOUS GUIDANCE") parses to the right host while the
    // ORIGINAL string, newline and all, would land in guidance the model
    // trusts, past the `inertOneLine` treatment the raw body gets (Copilot,
    // #136). Accepting only a value that is already its own canonical
    // serialization closes every variant at once: control characters, spaces,
    // an upper-case host, a missing path. The engine sends a canonical URL, so
    // nothing legitimate is lost, and what is returned is the serializer's
    // string, never the caller's.
    return url.protocol === "https:" &&
        url.host === "console.mnemoverse.com" &&
        url.username === "" &&
        url.password === "" &&
        url.href === raw
        ? url.href
        : undefined;
}
/**
 * Read the engine's envelope out of a response body.
 *
 * Two shapes are accepted because core produces both: its own middleware writes
 * `{code, message, retryable, details}` at the top level, while anything raised
 * as a FastAPI `HTTPException` arrives wrapped as `{"detail": …}` — where the
 * detail is sometimes a string and sometimes the envelope again. The feed's
 * 404-vs-404 test in this repo pins the nested form, so both are real.
 *
 * `details` (plural, a sibling of `code`/`message`/`retryable`) is read here
 * too, for `reason` and `keys_url`. Anything else inside
 * it is ignored silently: this parser reads a fixed, named set of fields and
 * has no way to tell a future field the engine adds from noise, so silence is
 * the only honest answer for either one.
 */
export function parseErrorEnvelope(body) {
    let parsed;
    try {
        parsed = JSON.parse(body);
    }
    catch {
        return {};
    }
    const pick = (v) => {
        if (typeof v === "string")
            return { message: v };
        if (typeof v !== "object" || v === null)
            return {};
        const o = v;
        const details = typeof o.details === "object" && o.details !== null
            ? o.details
            : undefined;
        return {
            ...(typeof o.code === "string" ? { code: o.code } : {}),
            ...(typeof o.message === "string" ? { message: o.message } : {}),
            ...(typeof o.retryable === "boolean" ? { retryable: o.retryable } : {}),
            ...(typeof details?.reason === "string" ? { reason: details.reason } : {}),
            ...(() => {
                const keysUrl = validatedKeysUrl(details?.keys_url);
                return keysUrl !== undefined ? { keysUrl } : {};
            })(),
        };
    };
    const top = pick(parsed);
    if (top.code !== undefined || top.retryable !== undefined)
        return top;
    const detail = typeof parsed === "object" && parsed !== null
        ? pick(parsed.detail)
        : {};
    // A top-level `message` with no code still beats nothing; the nested envelope
    // wins when it carries the machine-readable half.
    return detail.code !== undefined || detail.retryable !== undefined
        ? detail
        : { ...detail, ...top };
}
/** `Retry-After` as whole seconds, when it is a plain number. The HTTP-date
 *  form is legal too, and is deliberately NOT parsed: an unparsed header
 *  degrades into "wait a moment", which is honest, whereas a misparsed date
 *  would print a confident and wrong number of seconds. */
export function retryAfterSeconds(header) {
    if (typeof header !== "string")
        return undefined;
    // Digits only, because bare Number() answers confidently for inputs that are
    // not delta-seconds at all: Number("") and Number("   ") are 0 ("Wait 0
    // seconds"), Number("0x10") is 16, Number("3e1") is 30, and Number("1e21")
    // prints as scientific notation in the wait text (Copilot + panel, #93).
    // Six digits caps the printable wait at ~11 days; anything longer degrades
    // to the honest vague form.
    const s = header.trim();
    if (!/^\d{1,6}$/.test(s))
        return undefined;
    return Number(s);
}
function has(message, needle) {
    return (message ?? "").toLowerCase().includes(needle);
}
/**
 * 401: which 401? The engine has more than one (panel, #93).
 *
 * The key-flavored bodies this client's data plane actually sends ("Missing
 * API key. Send X-Api-Key header.", "Invalid or revoked API key." — both
 * probed live against core.mnemoverse.com) get the founder-endorsed
 * replace-the-key instruction. But core's routes.py also raises "Caller org
 * not identified — a tenant API key is required to …" (401) when the
 * deployment has no tenant identity for the caller — a static-auth
 * self-host hitting a room tool is the everyday case — and there the key is
 * VALID, so "replace it" would be a confident wrong cause. That clause runs
 * FIRST because its sentence itself contains "API key": a naive key-mention
 * test would misroute it. A message naming neither gets the honest generic
 * form; silence is the same unknown-refuser case the 403 branch handles.
 *
 * A fourth source arrived after the above was written: `details.reason`
 * on the engine's 401, which names the key problem outright instead of
 * leaving this file to guess from prose. It is checked second, still after
 * "caller org not identified" for the same reason that clause runs first at
 * all (a valid key must never be told to replace itself), and still before
 * the substring guess, since a named reason needs no guessing.
 *
 * UNDER `wording.auth === "oauth"` (STEP4-2, owner, 2026-09-24) none of the
 * above applies: the caller never held an API key at all, so `reason` (a
 * diagnosis of WHICH key problem this is) and every key-flavoured branch
 * below would be a wrong cause stated confidently. "Caller org not
 * identified" is the one exception: it is about tenant identification, not
 * about a key, and can fire under either credential type, so it is checked
 * first regardless of `auth`, worded for whichever credential the caller
 * actually holds. Every other 401 collapses to one OAuth-flavoured sentence:
 * reconnect or sign in again, because that is the one thing an OAuth user
 * can actually do about a 401, and naming a wrong one of five key reasons
 * would be worse than naming none.
 */
function explain401(env, wording) {
    const m = env.message;
    if (has(m, "caller org not identified")) {
        return wording.auth === "oauth"
            ? "Mnemoverse: this deployment could not identify a tenant account for " +
                "this request (401). The caller's sign-in itself was not rejected " +
                "— do NOT tell the user to reconnect over this. Room and " +
                "shared-memory operations need the multi-tenant backend, which a " +
                "self-hosted or static-auth deployment does not have. Quote the " +
                "detail below, and do not retry the same call against this " +
                "deployment."
            : "Mnemoverse: this deployment could not identify a tenant account for " +
                "this request (401). The API key itself was not rejected — do NOT " +
                "tell the user to replace it. Room and shared-memory operations need " +
                "the multi-tenant backend, which a self-hosted or static-auth " +
                "deployment does not have. Quote the detail below, and do not retry " +
                "the same call against this deployment.";
    }
    if (wording.auth === "oauth") {
        return ("Mnemoverse: the user's sign-in was rejected (401). There is no " +
            "credential for them to edit — tell them to disconnect and " +
            "reconnect the app, or sign in again, to refresh their session. Do " +
            "not retry until they do.");
    }
    // `details.reason` (an engine change not yet released as of this
    // one): the engine's own diagnosis of WHICH key problem this is, five
    // named values. Checked here, after "caller org not identified" and before
    // the substring guess right below, because a named reason is a strictly
    // better source than sniffing the message text for "api key": this branch
    // makes that guess unnecessary for every engine new enough to send one.
    // AN UNKNOWN REASON, OR NO REASON AT ALL, every engine released today,
    // falls straight through the switch below to the existing clauses, byte
    // for byte: `reason` is additive, never a replacement for a message this
    // parser cannot yet interpret.
    if (env.reason !== undefined) {
        const keysUrl = env.keysUrl ?? wording.keysUrl;
        switch (env.reason) {
            case "placeholder_key":
                return ("Mnemoverse: your API key was rejected (401). The engine itself " +
                    "recognises the configured value as the example key from the " +
                    "documentation, not one anyone created. Tell the user to replace " +
                    `MNEMOVERSE_API_KEY with a real key from ${keysUrl}. Do not retry ` +
                    "until they replace it.");
            case "revoked_key":
                return ("Mnemoverse: your API key was rejected (401). This key was " +
                    "revoked and will never work again, no matter how many times the " +
                    `call is retried. Tell the user to create a new one at ${keysUrl} ` +
                    "and put it in their MCP client config in place of this one. Do " +
                    "not retry with the same key.");
            case "invalid_key":
                return ("Mnemoverse: your API key was rejected (401). The engine does " +
                    "not recognise this key at all, most often because it was pasted " +
                    "incompletely. Tell the user to check that the whole key was " +
                    `copied from ${keysUrl}, with nothing missing from either end, or ` +
                    "to create a new one there. Do not retry until it is fixed.");
            case "malformed_key":
                return ("Mnemoverse: your API key was rejected (401). The configured " +
                    "value does not have the shape of a Mnemoverse key at all " +
                    "(mk_live_ followed by 32 lower-case hex characters). Common " +
                    "causes are pasting something else entirely, such as an OAuth " +
                    "token, a key cut short in the paste, or a key wrapped in " +
                    // Not "surrounding spaces": `fetch` trims whitespace around a header
                    // value (and so does HTTP), so a padded key reaches the engine
                    // intact and is judged on its own. Probed live on 2026-09-20: a
                    // quoted key is malformed_key, a key with a trailing space is not.
                    `quotes. Tell the user to check MNEMOVERSE_API_KEY against a real ` +
                    `key from ${keysUrl}. Do not retry until it is fixed.`);
            case "missing_key":
                return ("Mnemoverse: your API key was rejected (401). The X-Api-Key " +
                    "header did not arrive at all, so the engine never saw a key to " +
                    "check. Tell the user to set MNEMOVERSE_API_KEY in their MCP " +
                    `client config to a real key from ${keysUrl}, then restart the ` +
                    "MCP server. Do not retry until it is set.");
            default:
                // Not one of the five named values. Fall through: an engine that
                // adds a sixth reason before this client learns it must not get a
                // worse answer than the one it would have gotten with no reason at
                // all.
                break;
        }
    }
    if (has(m, "api key") || has(m, "x-api-key")) {
        // Wording endorsed by the founder, kept verbatim — this is the sentence
        // the whole change was commissioned for. One widening, grounded in the
        // docs: the setup pages ship more than one placeholder spelling
        // (agent-setup.md uses mk_live_USER_KEY).
        return ("Mnemoverse: your API key was rejected (401). Tell the user their " +
            "MNEMOVERSE_API_KEY is not valid — if it still reads a docs " +
            'placeholder such as "mk_live_YOUR_KEY" or "mk_live_USER_KEY" (any ' +
            "value they did not create at the console themselves) it must be " +
            `replaced with a real key from ${wording.keysUrl}. Do not retry until they ` +
            "replace it.");
    }
    if (m !== undefined || env.code !== undefined) {
        return ("Mnemoverse: the request was refused as unauthorized (401), and the " +
            "engine's own explanation is in the detail below — quote it to the " +
            "user rather than guessing. Do not assume the API key is wrong: the " +
            "message did not say that. Do not retry the same call unchanged.");
    }
    return ("Mnemoverse: something answered 401 without speaking this API's error " +
        "language — the body carries neither of the shapes the engine " +
        "produces, so the refuser may be a proxy, a gateway, or a " +
        "MNEMOVERSE_API_URL aimed somewhere unexpected, and the engine may never " +
        "have seen the request. Do not tell the user their key is wrong — this " +
        "client cannot tell WHO refused. Quote the detail below and check the " +
        "path to the API first.");
}
/** 403: when the ENGINE refused, the credential was accepted and then the
 *  request was refused — name WHICH refusal, from the engine's own message,
 *  and never blame the credential. But that first clause is only known when
 *  the engine actually spoke: a proxy, a WAF or a tunnel also answers 403, in
 *  HTML, and asserting "the credential identified the account fine" about a
 *  response the engine may never have seen is exactly the confident wrong
 *  cause this module exists to avoid.
 *
 *  Under `wording.auth === "oauth"` every noun for the credential changes
 *  and nothing else does: the clause naming it as innocent ("The API key" /
 *  "Your sign-in"), the holder the room-permission causes speak about ("This
 *  key" / "This account", since an OAuth user holds no key and membership is
 *  the account's), and the one word the opaque-403 branch declines to blame
 *  ("the key" / "the sign-in"). Review round 2 on the wording slice: the
 *  first cut changed only the innocent clause and left "This key is not an
 *  active member" in the same sentence that had just told an OAuth user
 *  their sign-in was fine. */
function explain403(env, wording) {
    const oauth = wording.auth === "oauth";
    if (saidNothing(env)) {
        const blamed = oauth ? "the sign-in" : "the key";
        return ("Mnemoverse: this request was refused (403) by something that did not " +
            "speak this API's error language — the body carries neither of the two " +
            "shapes the engine produces. That points at a proxy, a gateway, or a " +
            "MNEMOVERSE_API_URL aimed somewhere unexpected, and the engine may never " +
            `have seen the request — so do not blame ${blamed} and do not blame room ` +
            "permissions: this client cannot tell WHO refused it. Quote the detail " +
            "below to the user, and do not retry until the path to the API is explained.");
    }
    const holder = oauth ? "This account" : "This key";
    const holderLc = oauth ? "this account" : "this key";
    const m = env.message;
    // A SCOPE refusal: the credential identified the account and lacks a
    // permission the call needs (a write with a read-only token). Two producers
    // today, both name the scope: core's middleware ("Token lacks memory:write
    // scope") and the hosted connector's own gate ("the token lacks the
    // memory:write scope"). It is not about a room, so it must not fall
    // through to the generic sentence that guesses "most often the room it
    // addressed" (the connector's refusal did exactly that, step 4 review).
    // The check is keyed on that vocabulary (a scope NAME, or one of the lack
    // verbs lacks, lacking, missing, without, requires, required, needs
    // followed by the word "scope" in the same sentence), not on the bare word
    // "scope" ("outside the scope of this plan and needs a review" is not a
    // scope refusal), and it is tried AFTER the room causes: room
    // membership is itself called a scope elsewhere on this surface, so a room
    // refusal that happens to say "scope" keeps its room diagnosis (Sigma on
    // #175).
    const LACK_THEN_SCOPE = /\b(?:lacks?|lacking|missing|without|requires?|required|needs?)\b[^.;]*\bscope\b/i;
    const scopeRefusal = has(m, "scope") && (has(m, "memory:") || LACK_THEN_SCOPE.test(m ?? ""));
    // Core's other scope sentence, "Route has no scope policy; denied by
    // default", is NOT about the credential: the route itself has no policy
    // registered, and the middleware denies it by default. No re-authorization
    // and no other credential changes that, so it gets its own diagnosis
    // (CodeRabbit and Copilot on #175). Keyed on core's exact clause ("no
    // scope policy"), so a message that names a lacking scope and merely
    // mentions a scope policy stays a scope refusal; core's sentence itself
    // names no scope and does not say "lacks", so it never matches
    // `scopeRefusal` either.
    const routePolicy = has(m, "no scope policy");
    // Under oauth the remedy is real: the connector's consent flow grants
    // scopes. Under api-key it is credential-neutral: an API key carries no
    // scope selection this package knows of (the scope rules are OIDC-only, and
    // no producer sends a scope refusal to an API-key caller today), so the
    // reply names no fix a key swap cannot deliver; the user is told which
    // scope was refused and grants it on their side (Copilot and the internal
    // refuter on #175).
    // The scope NAMES come from the engine's message itself, matched as
    // `memory:<word>` and nothing else, so the api-key advice can name what to
    // grant even when `rawDetail: false` withholds the message (Copilot on
    // #175). Only the clause that states the lack is read: from "lacks",
    // "missing", "without", "requires" or "needs" up to the end of the
    // sentence, a comma, a dash (en, em, or a spaced hyphen), an opening
    // parenthesis, a conjunction (but, while, whereas, although, though, yet,
    // however, except), or a word that introduces what IS held ("granted",
    // "has", "holds", "carries"), so a scope the message lists as held in any
    // of those positions is not presented as refused (CodeRabbit on #175).
    // The trade-off is deliberate: a comma-separated list of LACKING scopes is
    // cut to its first item, and a held scope after a colon or after a
    // connective not in that list ("with memory:read retained") would still
    // be named; no producer writes either today (core names exactly one
    // scope). A message that names scopes with the name
    // BEFORE the verb ("memory:write scope is required") keeps them all,
    // unless it also speaks of held scopes; a refusal that names none gets
    // the generic sentence.
    const text = m ?? "";
    const scopesIn = (t) => [...new Set((t.match(/\bmemory:[a-z_]+/gi) ?? []).map((x) => x.toLowerCase()))];
    const lackClause = text.match(/\b(?:lacks?|lacking|missing|without|requires?|required|needs?)\b((?:(?!\b(?:granted|held|has|holds|carries|but|while|whereas|although|though|yet|however|except)\b|\s-\s)[^.;(,\u2013\u2014])*)/i)?.[1] ?? "";
    const speaksOfHeld = /\b(?:granted|held|has|holds|carries)\b/i.test(text);
    // Names the message says are HELD are never refused, wherever they sit: a
    // name followed by "is/are (already) held|granted|available|present", or
    // any name after a held-word up to the end of that fragment (Sigma on
    // #175: "lacks memory:write scope but memory:read is already held").
    const heldNames = new Set([
        // The window between the name and "is held" may not contain another
        // scope name, or "memory:write scope but memory:read is held" would
        // count memory:write as held.
        ...[...text.matchAll(/\b(memory:[a-z_]+)\b(?:(?!memory:)[^.;,]){0,40}?\b(?:is|are)\s+(?:already\s+|still\s+)?(?:held|granted|available|present)\b/gi)].map((x) => x[1].toLowerCase()),
        // The held fragment ends where a lack verb or a conjunction starts, so
        // "has memory:read scope but lacks memory:write scope" holds only read.
        ...[
            ...text.matchAll(/\b(?:granted|held|has|holds|carries)\b((?:(?!\b(?:lacks?|lacking|missing|without|requires?|required|needs?|but|while|whereas|although|though|yet|however|except)\b)[^.;])*)/gi),
            // A name the lack clause itself refuses is not held by this fragment
            // ("has memory:write scope for room A but lacks memory:write scope
            // for room B" refuses memory:write).
        ].flatMap((x) => scopesIn(x[1])).filter((x) => !scopesIn(lackClause).includes(x)),
    ]);
    const candidates = scopesIn(lackClause).length > 0 || speaksOfHeld ? scopesIn(lackClause) : scopesIn(text);
    const named = candidates.filter((x) => !heldNames.has(x));
    // Reads are said to be unaffected only when the REFUSED scope is the write
    // scope and the read scope is not among the refused (Copilot and Sigma on
    // #175; the flag reads the refused names, not the whole message, so
    // "lacks memory:admin scope; has memory:write scope" does not count as a
    // write refusal); a refusal that names no scope falls back to the words
    // "write scope"/"read scope". "Unaffected by this refusal" is all the
    // refusal supports: whether reads work depends on the read scope, which it
    // says nothing about.
    // And never when the message states the read scope lacking anywhere, even
    // past the cut of the lack clause ("lacks memory:write scope, memory:read
    // scope"): a read name that is not held, or the words "read scope".
    const readStatedLacking = (scopesIn(text).includes("memory:read") && !heldNames.has("memory:read")) || has(m, "read scope");
    const writeScope = named.length > 0
        ? named.includes("memory:write") && !named.includes("memory:read") && !readStatedLacking
        : has(m, "write scope") && !has(m, "read scope");
    const list = named.length <= 2 ? named.join(" and ") : `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]}`;
    const refused = named.length === 0
        ? "this call was refused for a missing scope so they can grant it on their side"
        : named.length === 1
            ? `the ${named[0]} scope was refused so they can grant it on their side`
            : `the ${list} scopes were refused so they can grant them on their side`;
    const remedy = oauth
        ? `tell the user to re-authorize this connector with ${writeScope ? "write access" : "the access it needs"}`
        : `tell the user ${refused}`;
    // The remedy either follows the reads clause mid-sentence or opens its own
    // sentence, capitalised.
    const advice = writeScope ? `Reads are not affected by this refusal, since they need only the read scope; ${remedy}.` : `${remedy[0].toUpperCase()}${remedy.slice(1)}.`;
    // The opening clause of the reply (below) already says what is missing, so
    // the cause carries the pointer to the engine's words, the advice, and the
    // one prohibition. The pointer exists only when the raw-detail paragraph
    // will be there: under `rawDetail: false` the consumer withholds the body,
    // and a pointer to withheld content is a lie (Copilot on #175).
    const pointer = wording.rawDetail ? "The engine's own words are in the detail below. " : "";
    const scopeCause = `${pointer}${advice} ` + "Do not work around it by trying another tool.";
    const routePolicyCause = "The engine denies this route by default because no scope policy is " +
        "registered for it: a gap in the server's own configuration, not anything " +
        `about ${holderLc}, so no re-authorization and no other credential changes ` +
        "it. Tell the user exactly what was refused so it can be reported, and do " +
        "not work around it by trying another tool.";
    const cause = has(m, "archiv")
        ? "The room you addressed is archived. An archived room refuses every read " +
            "and every write, for its owner as much as for a member, and this client " +
            "has no operation that reopens one."
        : has(m, "not an active member") || has(m, "member of this room")
            ? `${holder} is not an active member of the room you addressed. Ask the ` +
                "room's owner for an invite; memory_list_rooms shows the rooms it can " +
                "already reach."
            : has(m, "read-only") && (has(m, "membership") || has(m, "room"))
                ? `${holder}'s membership in that room is read-only — it can read the ` +
                    "room but not write to it. Ask the room's owner for write access."
                : has(m, "invalid room address")
                    ? 'The room address was not in the form the engine accepts ' +
                        '("xroom:room_..."). Take the exact address from memory_list_rooms ' +
                        "rather than composing one."
                    : has(m, "own this room")
                        ? "That room belongs to another account, and only its owner can do " +
                            `this. memory_list_rooms shows which rooms ${holderLc} owns.`
                        : routePolicy
                            ? routePolicyCause
                            : scopeRefusal
                                ? scopeCause
                                : `Something about this request is not permitted for ${holderLc} — ` +
                                    "most often the room it addressed. Check memory_list_rooms, and " +
                                    "if nothing there explains it, tell the user exactly what was " +
                                    "refused instead of guessing.";
    const subject = oauth ? "Your sign-in" : "The API key";
    // The innocent clause ("NOT the problem") would contradict the scope cause
    // that follows it: for that one refusal the credential did identify the
    // account, and the decision was about a scope it does not carry, so the
    // clause says exactly that (CodeRabbit on #175). Every other 403, the room
    // causes and the route-policy denial included, keeps the clause byte for
    // byte.
    const opening = cause === scopeCause
        ? `${subject} identified the account fine, but it does not carry a scope this call needs. `
        : `${subject} is NOT the problem — it identified the account fine, and this was a permission decision. `;
    return `Mnemoverse: this request was refused (403). ${opening}${cause} Do not retry the same call: it will be refused again.`;
}
/**
 * Did this response say ANYTHING an engine would have said?
 *
 * Not "does it have a `code`" — that was the old test, and it is wrong, because
 * the engine has TWO error styles. Its own middleware writes the full
 * `{code, message, retryable}` envelope, but every route that raises a FastAPI
 * `HTTPException` — the whole of `rooms_routes.py`, and there is no custom
 * handler to normalise them — serialises to a bare `{"detail": "Room not
 * found."}` with no code at all. Under a code-only test, a real 404 from the
 * room routes would be diagnosed as "your MNEMOVERSE_API_URL is wrong": a
 * confident, checkable falsehood about the user's config.
 *
 * A message with no code is still the engine speaking. Silence — an unparseable
 * body, or JSON carrying neither field — is what a proxy, a tunnel or a base URL
 * pointing elsewhere produces.
 */
function saidNothing(env) {
    return env.code === undefined && env.message === undefined;
}
/**
 * Did the ENGINE answer this 404 — or just the framework's router?
 *
 * Core registers no HTTPException handler, so an unmatched route is answered
 * by Starlette's literal default `{"detail":"Not Found"}` (verified live
 * against production) — a body with a message but no code, which
 * `saidNothing` alone would mistake for an engine answer (Copilot + panel,
 * #93). Every 404 the data plane actually sends is a MnemoError carrying a
 * code. Match the router defaults case-sensitively so engine prose that
 * merely contains "not found" cannot collide.
 */
function engineSilentOn404(env) {
    return (saidNothing(env) ||
        (env.code === undefined &&
            (env.message === "Not Found" || env.message === "Method Not Allowed")));
}
/**
 * 404: three producers with three different honest answers (panel, #93).
 *
 * (1) POST /memory/rooms/join — the engine's "Invite code not found." No
 *     room address was involved and memory_list_rooms cannot help a
 *     non-member, so the room story would be a confident wrong cause.
 * (2) An engine 404 carrying a code — every 404 the data plane sends is a
 *     MnemoError with one — is about something the request addressed,
 *     usually a room.
 * (3) No code — the router default, a foreign API, a proxy, or silence: the
 *     ENGINE did not answer this, so point at the endpoint and the base URL,
 *     not at rooms.
 */
function explain404(f, env) {
    if (f.path === "/memory/rooms/join") {
        return ("Mnemoverse: this invite code was not recognized (404). Most often it " +
            "was copied with a typo or was never issued — have the user re-check " +
            'the exact code (it starts with "mnvr_"), and if it is right, ask ' +
            "whoever sent it for a fresh one. memory_list_rooms will not help " +
            "here: the user is not a member yet. Do not retry the same code.");
    }
    if (env.code === undefined) {
        return (`Mnemoverse: the API answered 404 for ${f.method} ${f.path} without ` +
            "the engine's error envelope — which is what a route the deployment " +
            "does not serve looks like, not what a missing memory looks like. " +
            "Either MNEMOVERSE_API_URL is aimed at something that is not the " +
            "Mnemoverse API, or this server is newer than the deployment it is " +
            "talking to. If the user's MCP client config sets MNEMOVERSE_API_URL, " +
            "have them check it; if it sets none (the desktop extension exposes " +
            "only the API key), the deployment likely needs updating. Do not retry.");
    }
    return ("Mnemoverse: what this call addressed does not exist for this key (404). " +
        "The usual cause is a room address that is mistyped, or a room that was " +
        "deleted — call memory_list_rooms for the addresses this key can actually " +
        "reach. Note that a DOMAIN never causes this: a domain holding nothing " +
        "simply reads as empty, so do not tell the user their domain is missing. " +
        "Do not retry this call unchanged.");
}
/** 429: three causes, opposite advice. The envelope's `retryable` is the
 *  discriminator, because it is the only thing the engine states outright. */
function explain429(f, env, wording) {
    const secs = retryAfterSeconds(f.retryAfter);
    const wait = secs === undefined ? "about a minute" : `${secs} seconds`;
    if (env.retryable === true) {
        // The one 429 sentence that names the credential holder (Sigma, review
        // round 2 on the wording slice): "this key" for an API-key caller, "this
        // account" for an OAuth user who holds no key. The other two branches
        // speak about the account and the plan already.
        const holder = wording.auth === "oauth" ? "this account" : "this key";
        return ("Mnemoverse: rate-limited (429). This is the per-minute request limit and " +
            `it clears by itself. Wait ${wait}, then make AT MOST ONE more attempt — ` +
            "do not retry in a loop and do not fan out into more calls, which is what " +
            "turns a one-minute limit into a sustained one. If the retry also fails, " +
            `stop and tell the user ${holder} is hitting its rate limit.`);
    }
    if (env.retryable === false) {
        return ("Mnemoverse: refused for quota, not for speed (429). Waiting will NOT " +
            "clear this one — the account is at its daily limit, at its stored-memory " +
            "limit, or its subscription is blocking writes. Do not retry. Tell the " +
            `user what the detail below says and point them at ${USAGE_URL}.`);
    }
    return ("Mnemoverse: refused as too many requests (429), and the body does not say " +
        "whether waiting helps. Do not retry in a loop. Wait a moment, make at most " +
        "one more attempt, and if that fails tell the user rather than continuing — " +
        `if this is a quota rather than a rate, ${USAGE_URL} is where they resolve it.`);
}
/**
 * The agent-facing explanation for one failed call, with the raw body kept
 * after it (unless `wording.rawDetail === false`, STEP4-3).
 *
 * Total by construction: every status reaches a sentence, and the fallback says
 * that it has no specific guidance instead of inventing some.
 *
 * `wording` (STEP4-2/3, owner 2026-09-24) is optional and, absent, resolves
 * to exactly today's behaviour: every call site before this release passes
 * none, so every existing pin stays byte-identical.
 */
export function explainApiFailure(f, wording) {
    const resolved = resolveWording(wording);
    const env = parseErrorEnvelope(f.body);
    let guidance;
    if (f.status === 401) {
        guidance = explain401(env, resolved);
    }
    else if (f.status === 403) {
        guidance = explain403(env, resolved);
    }
    else if (f.status === 404) {
        guidance = explain404(f, env);
    }
    else if (f.status === 429) {
        guidance = explain429(f, env, resolved);
    }
    else if (f.status === 400 || f.status === 422) {
        guidance =
            "Mnemoverse: the engine rejected the CONTENTS of this request " +
                `(${f.status}). This is about the arguments you sent — not the API key, ` +
                "not the network, and not the user's setup. Read the detail below, fix " +
                "the argument it names, and do not resend the same body.";
    }
    else if (f.status === 409) {
        guidance = explain409(f);
    }
    else if (f.status >= 500) {
        guidance =
            `Mnemoverse: the memory service failed (${f.status}). This is OUR side, ` +
                "not the user's — their API key, their config and their network are all " +
                "fine, so do not send them to check any of those. One retry after a few " +
                "seconds is reasonable. If it fails again, say plainly that Mnemoverse is " +
                "having a problem and continue without memory rather than retrying.";
    }
    else {
        guidance =
            `Mnemoverse: the API returned HTTP ${f.status}, and this client has no ` +
                "specific guidance for that status. Do not invent a cause for the user — " +
                "quote the detail below. One retry is acceptable; a loop is not.";
    }
    return resolved.rawDetail ? `${guidance}\n\n${rawDetail(f)}` : guidance;
}
/**
 * A server-controlled string, made safe to quote inside model-facing text.
 *
 * The body is server-controlled — or, on the misconfigured-URL and proxy paths
 * this module itself names, controlled by whoever answered instead. Spliced raw
 * into model-facing text, a body with newlines could open its own paragraph and
 * speak in this module's instruction voice (panel, #93). Strip control,
 * format/bidi and line/paragraph separators so the quoted body stays one inert
 * line; the guidance above it is the only voice here.
 *
 * Shared with {@link explainUnreadableBody}, whose preview is quoted from
 * exactly the same kind of source — a body written by whoever answered.
 */
function inertOneLine(s) {
    return s.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, " ");
}
/** The debugging half: what the wire actually said, still carrying the literal
 *  `Mnemoverse API error <status>` that predates this module. */
function rawDetail(f) {
    const body = f.body.length > MAX_BODY_CHARS
        ? `${f.body.slice(0, MAX_BODY_CHARS)}… [body truncated at ${MAX_BODY_CHARS} chars]`
        : f.body;
    return `Raw detail — Mnemoverse API error ${f.status} on ${f.method} ${f.path}: ${inertOneLine(body)}`;
}
/**
 * Was this transport failure a REDIRECT this client refused to follow?
 *
 * `redirect: "error"` (src/index.ts, apiFetch) makes undici reject with
 * `TypeError: fetch failed` whose `cause` is `Error: unexpected redirect` —
 * verified by executing the case against a real local 302
 * (test/redirect-refusal.test.ts, which uses a real server precisely because a
 * stubbed fetch has no redirect handling to exercise). The literal string is
 * `makeNetworkError('unexpected redirect')` and is identical in undici 5
 * (Node 18), 6 (Node 20 / 22) and 7 (Node 24) — read in all three, because the
 * CI matrix and this package's `engines` span them.
 *
 * WITHOUT THIS BRANCH the failure is indistinguishable from a dead host: same
 * `TypeError: fetch failed`, and the message below would tell the user to debug
 * a "connectivity or DNS problem" on a network that is working perfectly, while
 * the actual cause — a base URL aimed at something that bounces — sat in their
 * config. That is the confident wrong cause this whole module exists against.
 *
 * The cause chain is WALKED rather than read at one depth, since the nesting is
 * undici's to change; the match is the exact sentinel rather than the word
 * "redirect", because a DNS failure against a host whose NAME contains
 * "redirect" would otherwise be diagnosed as one. If a future runtime renames
 * the sentinel this stops firing and the honest generic sentence takes over —
 * a degradation, not a lie — and the real-server test goes red, which is where
 * the rename gets noticed.
 */
function refusedRedirect(cause) {
    let e = cause;
    for (let depth = 0; depth < 5; depth++) {
        if (!(e instanceof Error))
            return false;
        if (e.message.toLowerCase().includes("unexpected redirect"))
            return true;
        e = e.cause;
    }
    return false;
}
/**
 * The request never got an answer at all: DNS, connectivity, a host that does
 * not listen, or this client's own probe deadline firing.
 *
 * Included even though the brief was about HTTP statuses, because it is the
 * OTHER half of the same defect and the two are easy to confuse. The 401
 * message above tells an agent not to blame the network; this is the case where
 * the network genuinely IS the answer, and `TypeError: fetch failed` — which is
 * all a model saw before — is no more actionable than the raw 401 body was.
 *
 * A timeout is named separately because the advice differs in one word: an
 * unreachable host is unlikely to become reachable in three seconds, whereas a
 * request that ran out of time may well succeed on a second try.
 *
 * NARROWED to `fetch()` itself rejecting. Reading the BODY of a response that
 * did arrive used to land here too, so a 200 carrying a sign-in page printed
 * "no HTTP response came back" — see {@link explainUnreadableBody}, which owns
 * that case now. Every sentence below asserts that nothing answered, so it may
 * only be reached when nothing did.
 */
export function explainNetworkFailure(method, path, cause, wording) {
    const resolved = resolveWording(wording);
    const name = cause instanceof Error ? cause.name : "";
    const timedOut = name === "TimeoutError" || name === "AbortError";
    const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
    if (refusedRedirect(cause)) {
        const head = `Mnemoverse: ${method} ${path} was answered with a REDIRECT, and this ` +
            "client refused to follow it — so the API key was never sent to whatever " +
            "the redirect pointed at. That refusal is the whole point: following a " +
            "redirect re-sends the request headers to the new host, which hands a " +
            "live key to whoever answered. This API serves one stable base path and " +
            "never redirects legitimately, so this means MNEMOVERSE_API_URL is aimed " +
            "at something else — a proxy, a tunnel, a captive portal, or an address " +
            "that has been tampered with. It is NOT a rejected key and NOT a broken " +
            "network: the request arrived somewhere and was answered. Tell the user " +
            "to check MNEMOVERSE_API_URL and point it straight at the API (the " +
            `default is ${DEFAULT_API_URL}). Do not retry: the same address will ` +
            "redirect again.";
        return resolved.rawDetail
            ? `${head}\n\n${rawTransportDetail(method, path, detail)}`
            : head;
    }
    const head = timedOut
        ? `Mnemoverse: the memory service did not answer ${method} ${path} in time.`
        : `Mnemoverse: the memory service could not be reached at all — ${method} ` +
            `${path} failed before any HTTP response came back.`;
    const cause_ = timedOut
        ? "The service may just be slow right now."
        : "That is a connectivity or DNS problem, or MNEMOVERSE_API_URL pointing at " +
            "a host that does not answer.";
    const body = `${head} ${cause_} This is NOT a rejected API key and NOT a quota — no ` +
        "reply arrived to say anything about either, so do not send the user to " +
        "check their key. One retry is reasonable. If that also fails, tell the " +
        "user memory is unreachable and carry on without it rather than retrying.";
    return resolved.rawDetail ? `${body}\n\n${rawTransportDetail(method, path, detail)}` : body;
}
/** The debugging half of a transport failure, in one place so the redirect
 *  branch and the generic one cannot drift into two spellings of it. */
function rawTransportDetail(method, path, detail) {
    return `Raw detail — request to ${method} ${path} failed: ${detail}`;
}
/** How much of an unreadable body is worth quoting. A sign-in page, an SPA
 *  shell or a proxy notice announces itself in its first line; the rest is
 *  markup, and an error message is not a place to spend a model's context. */
const MAX_PREVIEW_CHARS = 200;
/**
 * A reply ARRIVED and could not be read — which is not a dead network.
 *
 * WHY THIS IS SEPARATE FROM {@link explainNetworkFailure}. Both failures used
 * to throw the same NetworkError, so `JSON.parse` choking on a 200 printed
 * "the memory service could not be reached at all — POST /memory/read failed
 * before any HTTP response came back… That is a connectivity or DNS problem".
 * Against a captive portal, a MITM proxy, an SPA that serves its shell with a
 * 200, or a body that stops mid-stream, every clause of that is false: a reply
 * came, with a status, and the Raw detail underneath it quoted a SyntaxError
 * out of the body it had just called nonexistent.
 *
 * The cost is the same one this module exists to remove — a confident wrong
 * cause. "Connectivity or DNS" sends the user to debug working wifi while a
 * portal or a mis-set MNEMOVERSE_API_URL answers every request with a page.
 *
 * WHAT THIS MESSAGE MAY CLAIM. Only what the status proves: something answered,
 * and its answer is not this API's JSON. It does NOT name who answered — this
 * client cannot tell the engine from a gateway in front of it — and it does NOT
 * say whether the operation ran, because a body it cannot read is no evidence
 * either way. That last clause matters most for a write.
 */
export function explainUnreadableBody(f, wording) {
    const resolved = resolveWording(wording);
    // TWO ARMS, because the two failures have different causes and the wrong one
    // is a wrong instruction. A body that PARSED WRONG is a foreign answer — a
    // portal, a proxy, a base URL aimed elsewhere. A body that stopped ARRIVING
    // is a dropped connection, where "check MNEMOVERSE_API_URL" would be the
    // same kind of confident wrong cause this module removes everywhere else.
    const body = typeof f.bodyPreview === "string"
        ? `the body is not JSON this API produces — this client could not read ` +
            `the result. A reply DID arrive, so this is neither a dead network ` +
            `nor a rejected key: nothing here failed to connect, and nothing here ` +
            `refused anything. Do not send the user to debug their connection, ` +
            `and do not tell them their key is the problem. A 2xx in a foreign ` +
            `shape comes from something in FRONT of the API — a captive portal or ` +
            `sign-in page, a proxy or gateway, or MNEMOVERSE_API_URL aimed at ` +
            `something that is not the Mnemoverse API. One retry is reasonable; ` +
            `if it repeats, quote the detail below and have the user check ` +
            `MNEMOVERSE_API_URL and whatever sits between them and the API.`
        : `the body could not be read to the end — it stopped part-way, after ` +
            `that status had already arrived. That is a dropped connection ` +
            `or a deadline firing mid-body; it is NOT a refusal and it says ` +
            `nothing about the user's key. One retry is reasonable; if it ` +
            `repeats, tell the user the reply is arriving incomplete and quote ` +
            `the detail below, status included.`;
    const head = `Mnemoverse: something answered HTTP ${f.status} for ${f.method} ` +
        `${f.path}, but ${body} Whether the operation itself ran is unknown ` +
        `either way — if this was a write, treat it as neither saved nor ` +
        "refused.";
    return resolved.rawDetail ? `${head}\n\n${rawUnreadableDetail(f)}` : head;
}
/** The debugging half for an unreadable body: what stopped the read, and the
 *  first bytes of what arrived — through the same inert filter as
 *  {@link rawDetail}, because the preview has exactly the same provenance. */
function rawUnreadableDetail(f) {
    const detail = f.cause instanceof Error ? `${f.cause.name}: ${f.cause.message}` : String(f.cause);
    const head = `Raw detail — HTTP ${f.status} on ${f.method} ${f.path}, body this client ` +
        `could not read: ${inertOneLine(detail)}`;
    if (typeof f.bodyPreview !== "string" || f.bodyPreview === "")
        return head;
    const note = f.bodyPreview.length > MAX_PREVIEW_CHARS
        ? ` … [preview truncated at ${MAX_PREVIEW_CHARS} chars]`
        : "";
    return `${head} First bytes: ${inertOneLine(f.bodyPreview.slice(0, MAX_PREVIEW_CHARS))}${note}`;
}
/**
 * {@link explainUnreadableBody} as an error, so a caller can tell "a reply
 * arrived and was unreadable" from "no reply arrived" by TYPE rather than by
 * matching a sentence — the same reason {@link ApiError} carries fields.
 */
export class UnreadableBodyError extends Error {
    status;
    method;
    path;
    /** The bytes that failed to parse, when they were read at all. Kept so
     *  {@link withWording} re-renders the same arm of the explanation. */
    bodyPreview;
    /** `wording` (STEP4-2/3): optional, and absent (every call site before this
     *  release) reproduces today's message exactly: see {@link explainUnreadableBody}. */
    constructor(f, wording) {
        super(explainUnreadableBody(f, wording), { cause: f.cause });
        this.name = "UnreadableBodyError";
        this.status = f.status;
        this.method = f.method;
        this.path = f.path;
        this.bodyPreview = f.bodyPreview;
    }
    /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
    withWording(wording) {
        const f = { status: this.status, method: this.method, path: this.path, cause: this.cause };
        if (this.bodyPreview !== undefined)
            f.bodyPreview = this.bodyPreview;
        return new UnreadableBodyError(f, wording);
    }
}
/**
 * 409: name the conflict this tool actually produced (panel, #93). The two
 * real producers on this server are the join tool (used/expired/revoked
 * invite) and memory_create_room (core: "A room with this name already
 * exists.", 409). The old single sentence gave the invite advice to a
 * duplicate-name conflict, misdirecting the agent to a nonexistent inviter.
 */
function explain409(f) {
    if (f.path === "/memory/rooms/join") {
        return ("Mnemoverse: this invite code cannot be used (409) — it has already " +
            "been used, has expired, or was revoked. Ask whoever invited the user " +
            "for a fresh one. Retrying the same code will conflict again.");
    }
    if (f.path === "/memory/rooms" && f.method === "POST") {
        return ("Mnemoverse: a room with this name already exists for this account " +
            "(409). Pick a different name, or reuse the existing room — " +
            "memory_list_rooms shows its address. Retrying the same name will " +
            "conflict again.");
    }
    return ("Mnemoverse: this request conflicts with the current state (409). The " +
        "engine's own explanation is in the detail below — quote it to the " +
        "user. Retrying the same request will conflict again.");
}
/** {@link explainNetworkFailure} as an error, so `instanceof` can tell a
 *  transport failure from an HTTP one without reading either message. */
export class NetworkError extends Error {
    method;
    path;
    /** `wording` (STEP4-2/3): optional, and absent (every call site before this
     *  release) reproduces today's message exactly: see {@link explainNetworkFailure}. */
    constructor(method, path, cause, wording) {
        super(explainNetworkFailure(method, path, cause, wording), { cause });
        this.name = "NetworkError";
        this.method = method;
        this.path = path;
    }
    /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
    withWording(wording) {
        return new NetworkError(this.method, this.path, this.cause, wording);
    }
}
/**
 * A failed API call, as an ERROR OBJECT rather than a parsed string.
 *
 * The status and the body are fields because a caller that needs to branch on
 * them must not do it by matching the message. `memory_list_recent` used to
 * decide between "the endpoint is not deployed" and "this room does not exist"
 * with `message.startsWith("Mnemoverse API error 404:")`, which coupled a
 * behavioural branch to the exact prefix of a user-facing sentence — so
 * improving the sentence, which is this change, would have silently flipped
 * that branch. Structured fields make the wording free to change.
 */
export class ApiError extends Error {
    status;
    body;
    method;
    path;
    /** The engine's envelope, already parsed — so a caller never re-parses. */
    envelope;
    /** The `Retry-After` header as it arrived, when the response carried one.
     *  Kept so {@link withWording} re-renders a 429 with its retry sentence. */
    retryAfter;
    /** `wording` (STEP4-2/3): optional, and absent (every call site before this
     *  release) reproduces today's message exactly: see {@link explainApiFailure}. */
    constructor(f, wording) {
        super(explainApiFailure(f, wording));
        this.name = "ApiError";
        this.status = f.status;
        this.body = f.body;
        this.method = f.method;
        this.path = f.path;
        this.retryAfter = f.retryAfter;
        this.envelope = parseErrorEnvelope(f.body);
    }
    /**
     * The same failure, explained under `wording`: a new instance of this
     * class whose message is what the constructor would have produced with
     * that `wording`, every field and {@link isBare404} unchanged. The message
     * is a pure function of the failure and the wording, so re-rendering an
     * instance under the wording it was built with gives the same text back.
     * This is how `MemoryToolDeps.wording` reaches an error the consumer's
     * `apiFetch` built without it: see {@link rewordFailure}.
     */
    withWording(wording) {
        const f = { status: this.status, body: this.body, method: this.method, path: this.path };
        if (this.retryAfter !== undefined)
            f.retryAfter = this.retryAfter;
        return new ApiError(f, wording);
    }
    /**
     * A 404 the ENGINE did not answer: silence, or the router's literal
     * defaults. This is what a path the deployment does not serve looks like
     * — the first version of this predicate tested silence alone, mistook the
     * real framework body `{"detail":"Not Found"}` for an engine answer, and
     * un-fired the feed's degrade branch for the exact rollout case it exists
     * for (panel, #93).
     *
     * NAMED FOR WHAT IT TESTS, not for what it implies: a gateway, a proxy or a
     * wrong MNEMOVERSE_API_URL can produce the same shapes and is
     * indistinguishable from here. Known, accepted edge: a foreign API whose
     * 404 nests its error under a key this parser does not read
     * (`{"error":{…}}`) also reads as bare, so the feed degrades to its
     * not-supported notice instead of surfacing the foreign body — bounded
     * harm, since against a wrong base URL the very next call fails with the
     * instructive no-envelope wording.
     */
    get isBare404() {
        return this.status === 404 && engineSilentOn404(this.envelope);
    }
}
/**
 * Re-explain one of this module's three errors under `wording`; hand
 * anything else back untouched.
 *
 * This is how `MemoryToolDeps.wording` reaches the error text (STEP4-2): a
 * consumer's `apiFetch` constructs `ApiError`, `NetworkError` and
 * `UnreadableBodyError` however it likes, and `registerMemoryTools` passes
 * every rejection through here with the `wording` it was given, so the
 * consumer states its wording once, on `deps`, and the constructors need no
 * second copy. Re-rendering is idempotent (see {@link ApiError.withWording}),
 * so an `apiFetch` that does pass the same `wording` to the constructors
 * gets the same text either way; one that passed a different wording gets
 * `deps.wording`, the one source of truth for this registration. A
 * rejection that is none of the three classes (a plain `Error`, an
 * `McpError`) is returned as is: it carries no wording to apply, and its
 * identity may matter to whoever threw it.
 */
export function rewordFailure(e, wording) {
    if (e instanceof ApiError || e instanceof NetworkError || e instanceof UnreadableBodyError) {
        return e.withWording(wording);
    }
    return e;
}
//# sourceMappingURL=errors.js.map