@mnemoverse/mcp-memory-server 0.11.0 → 0.12.0

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 CHANGED
@@ -325,28 +325,50 @@ saw and diff it against what the server serves today, by version.
325
325
  not have one; once declared, that schema's fields are add-only under this
326
326
  same rule. Where this server's output schema deliberately differs from the
327
327
  hosted connector's, the CHANGELOG entry says so; today that is
328
+ `memory_id` in every output schema, a plain string here and a
329
+ GUID-validated string there (this package's ids are opaque);
328
330
  `memory_list_recent`'s `next_cursor`, optional here (absent when the
329
331
  service sent a continuation token this client will not pass on) and
330
- required there.
332
+ required there; `memory_stats`, which carries five optional fields
333
+ (`episodes`, `prototypes`, `hebbian_edges`, `avg_valence`,
334
+ `avg_importance`) the connector's schema does not declare; the room
335
+ tools (`memory_create_room`, `memory_invite_to_room`, `memory_join_room`,
336
+ `memory_list_rooms`), where several fields the connector marks required
337
+ are optional here (`name`, `scope`, `already_member`, and the invite's
338
+ `code`, `scope`, `room_address` and `expires_at`), because a value core
339
+ did not send is an honest outcome here rather than a placeholder; and
340
+ `memory_list_rooms` and `vault_list`, which drop a row with no usable
341
+ identity (`room_id`, `address`, `role`; `alias`, `context`) from the data,
342
+ reported once on stderr, instead of emitting empty strings into required
343
+ fields.
331
344
  - **Removing or renaming a tool or a tool's input parameter, or dropping or
332
345
  renaming a declared annotation field,** is announced one MINOR ahead: the
333
346
  tool (or parameter) stays, its description says
334
347
  `deprecated since x.y, removed in x.z`, and the change lands only in the
335
348
  announced version, with its CHANGELOG line. A renamed parameter is accepted
336
349
  under both names until then (0.11: `memory_feedback`'s `atom_ids` became
337
- `memory_ids`). A rename is announced by naming
350
+ `memory_ids`; its removal, first announced for 0.12, lands in 0.13, since
351
+ 0.12 shipped sooner than that announcement assumed). A rename is announced by naming
338
352
  both the old and the new name; the version pair alone does not say what a
339
353
  client should look for. Because a MINOR may add a field but not remove one, a
340
354
  renamed annotation field is declared under both names until the announced
341
355
  version.
342
356
  - Any difference between two servers of the same version is a bug. Report it
343
- with both `tools/list` outputs.
357
+ with both `tools/list` outputs. One exception, by configuration: a server
358
+ built on the `/shared` entry point may ask for its own noun in the three
359
+ descriptions that name the server (`wording.serverNoun`, below), which
360
+ changes those three description strings and nothing else; tool names,
361
+ input and output schemas and annotations never vary by configuration.
344
362
 
345
- The list above is the 0.11 surface: ten tools, each declaring all four hints.
363
+ The list above is the 0.12 surface: ten tools, each declaring all four hints.
346
364
  The hosted connector at `mcp.mnemoverse.com/mcp` serves the same ten.
347
365
 
348
366
  **If the hosted connector stops answering in a session.** A client can keep showing the connector as connected while every call in that session fails with "not connected". Reconnecting it on claude.ai does not revive a session that is already stuck; reconnect from inside the session instead (in Claude Code, `/mcp`, then sign in again). Meanwhile this local server, set up with an API key from the same account as in the Quick Start, reaches the same memory and does not depend on that session's sign-in.
349
367
 
368
+ ### Building a second MCP server on this package
369
+
370
+ `@mnemoverse/mcp-memory-server/shared` is the entry point another server registers these same tools from, instead of keeping its own copy (ADR-025, mnemoverse-core). It exports `registerMemoryTools`/`registerMemoryPrompts`/`registerMemoryResources`, the three typed error classes (`ApiError`, `NetworkError`, `UnreadableBodyError`), `MAX_RESULT_CHARS`/`capResult`, and two optional dependencies a hosted deployment injects to speak in its own voice: `wording` (its own server noun and error vocabulary, including an OAuth mode under which no 401 or 403 explanation names an API key) and `writeAuthor` (vouching for the end user behind a write). See [`docs/shared.md`](./docs/shared.md) for the full contract.
371
+
350
372
  ## Use cases
351
373
 
352
374
  The pattern that pays off first is cross-tool continuity: a decision made while pairing in Claude Code is there when you open Cursor an hour later, and the preference you stated in VS Code holds in a ChatGPT session that evening. Teams use shared rooms the same way — one place where an agent's lessons about a codebase accumulate instead of being re-taught per seat. And because recall re-ranks from feedback, the memories that keep proving useful surface first, which matters once a store grows past what anyone curates by hand.
package/dist/errors.d.ts CHANGED
@@ -76,6 +76,41 @@
76
76
  * outright, see `validatedKeysUrl` below, but this constant stays the
77
77
  * fallback for both). */
78
78
  export declare const KEYS_URL = "https://console.mnemoverse.com/dashboard/keys";
79
+ /**
80
+ * How a server that registers these tools wants its failures worded (STEP4-2,
81
+ * STEP4-3, owner decisions 2026-09-24). Every field is optional, and every
82
+ * field's absence means exactly what this file already does today: the
83
+ * whole point is that a consumer supplying no `wording` at all gets
84
+ * byte-identical text to before this type existed.
85
+ *
86
+ * - `serverNoun`: what the three tool descriptions that name themselves
87
+ * call this deployment ("this server" vs "this connector"). Read by
88
+ * src/tools.ts at registration time; ignored by this module.
89
+ * - `auth`: which credential the CALLER holds, not which one core issued.
90
+ * "api-key" (default) keeps every existing MNEMOVERSE_API_KEY-flavoured
91
+ * sentence. "oauth" is for a server whose user never sees an API key at
92
+ * all (a hosted connector minting a key on their behalf): no 401/403
93
+ * explanation under this mode names MNEMOVERSE_API_KEY, an env var, an
94
+ * MCP config file, or the keys console; each says what an OAuth user can
95
+ * actually do instead (reconnect, sign in again, check the plan, wait
96
+ * for the retry window).
97
+ * - `keysUrl`: replaces {@link KEYS_URL} wherever the api-key vocabulary
98
+ * prints a console URL, for a deployment whose key-management page is
99
+ * not console.mnemoverse.com. Has no effect under `auth: "oauth"`, which
100
+ * prints no console URL at all.
101
+ * - `rawDetail`: whether the wire body's raw tail (STEP4-3; e.g. `Raw
102
+ * detail — Mnemoverse API error 401 on …`) is appended after the
103
+ * guidance. Defaults to `true`, unchanged from every release before this
104
+ * one. Held in reserve for a deployment whose core-side error envelopes
105
+ * are found to echo request content back in `details`: set to `false`
106
+ * only once that check finds something to hide.
107
+ */
108
+ export interface Wording {
109
+ serverNoun?: "this server" | "this connector";
110
+ auth?: "api-key" | "oauth";
111
+ keysUrl?: string;
112
+ rawDetail?: boolean;
113
+ }
79
114
  /** Everything known about one failed call, at the moment it failed. */
80
115
  export interface ApiFailure {
81
116
  /** HTTP status. */
@@ -146,12 +181,16 @@ export declare function parseErrorEnvelope(body: string): ErrorEnvelope;
146
181
  export declare function retryAfterSeconds(header: string | null | undefined): number | undefined;
147
182
  /**
148
183
  * The agent-facing explanation for one failed call, with the raw body kept
149
- * after it.
184
+ * after it (unless `wording.rawDetail === false`, STEP4-3).
150
185
  *
151
186
  * Total by construction: every status reaches a sentence, and the fallback says
152
187
  * that it has no specific guidance instead of inventing some.
188
+ *
189
+ * `wording` (STEP4-2/3, owner 2026-09-24) is optional and, absent, resolves
190
+ * to exactly today's behaviour: every call site before this release passes
191
+ * none, so every existing pin stays byte-identical.
153
192
  */
154
- export declare function explainApiFailure(f: ApiFailure): string;
193
+ export declare function explainApiFailure(f: ApiFailure, wording?: Wording): string;
155
194
  /**
156
195
  * The request never got an answer at all: DNS, connectivity, a host that does
157
196
  * not listen, or this client's own probe deadline firing.
@@ -172,7 +211,7 @@ export declare function explainApiFailure(f: ApiFailure): string;
172
211
  * that case now. Every sentence below asserts that nothing answered, so it may
173
212
  * only be reached when nothing did.
174
213
  */
175
- export declare function explainNetworkFailure(method: string, path: string, cause: unknown): string;
214
+ export declare function explainNetworkFailure(method: string, path: string, cause: unknown, wording?: Wording): string;
176
215
  /** A 2xx-or-not response whose BODY this client could not turn into the JSON
177
216
  * this API speaks. The status is known — a reply arrived — which is the whole
178
217
  * difference between this and {@link ApiFailure} or a transport failure. */
@@ -212,7 +251,7 @@ export interface UnreadableBody {
212
251
  * say whether the operation ran, because a body it cannot read is no evidence
213
252
  * either way. That last clause matters most for a write.
214
253
  */
215
- export declare function explainUnreadableBody(f: UnreadableBody): string;
254
+ export declare function explainUnreadableBody(f: UnreadableBody, wording?: Wording): string;
216
255
  /**
217
256
  * {@link explainUnreadableBody} as an error, so a caller can tell "a reply
218
257
  * arrived and was unreadable" from "no reply arrived" by TYPE rather than by
@@ -222,14 +261,25 @@ export declare class UnreadableBodyError extends Error {
222
261
  readonly status: number;
223
262
  readonly method: string;
224
263
  readonly path: string;
225
- constructor(f: UnreadableBody);
264
+ /** The bytes that failed to parse, when they were read at all. Kept so
265
+ * {@link withWording} re-renders the same arm of the explanation. */
266
+ readonly bodyPreview: string | undefined;
267
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
268
+ * release) reproduces today's message exactly: see {@link explainUnreadableBody}. */
269
+ constructor(f: UnreadableBody, wording?: Wording);
270
+ /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
271
+ withWording(wording: Wording | undefined): UnreadableBodyError;
226
272
  }
227
273
  /** {@link explainNetworkFailure} as an error, so `instanceof` can tell a
228
274
  * transport failure from an HTTP one without reading either message. */
229
275
  export declare class NetworkError extends Error {
230
276
  readonly method: string;
231
277
  readonly path: string;
232
- constructor(method: string, path: string, cause: unknown);
278
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
279
+ * release) reproduces today's message exactly: see {@link explainNetworkFailure}. */
280
+ constructor(method: string, path: string, cause: unknown, wording?: Wording);
281
+ /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
282
+ withWording(wording: Wording | undefined): NetworkError;
233
283
  }
234
284
  /**
235
285
  * A failed API call, as an ERROR OBJECT rather than a parsed string.
@@ -249,7 +299,22 @@ export declare class ApiError extends Error {
249
299
  readonly path: string;
250
300
  /** The engine's envelope, already parsed — so a caller never re-parses. */
251
301
  readonly envelope: ErrorEnvelope;
252
- constructor(f: ApiFailure);
302
+ /** The `Retry-After` header as it arrived, when the response carried one.
303
+ * Kept so {@link withWording} re-renders a 429 with its retry sentence. */
304
+ readonly retryAfter: string | null | undefined;
305
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
306
+ * release) reproduces today's message exactly: see {@link explainApiFailure}. */
307
+ constructor(f: ApiFailure, wording?: Wording);
308
+ /**
309
+ * The same failure, explained under `wording`: a new instance of this
310
+ * class whose message is what the constructor would have produced with
311
+ * that `wording`, every field and {@link isBare404} unchanged. The message
312
+ * is a pure function of the failure and the wording, so re-rendering an
313
+ * instance under the wording it was built with gives the same text back.
314
+ * This is how `MemoryToolDeps.wording` reaches an error the consumer's
315
+ * `apiFetch` built without it: see {@link rewordFailure}.
316
+ */
317
+ withWording(wording: Wording | undefined): ApiError;
253
318
  /**
254
319
  * A 404 the ENGINE did not answer: silence, or the router's literal
255
320
  * defaults. This is what a path the deployment does not serve looks like
@@ -269,3 +334,21 @@ export declare class ApiError extends Error {
269
334
  */
270
335
  get isBare404(): boolean;
271
336
  }
337
+ /**
338
+ * Re-explain one of this module's three errors under `wording`; hand
339
+ * anything else back untouched.
340
+ *
341
+ * This is how `MemoryToolDeps.wording` reaches the error text (STEP4-2): a
342
+ * consumer's `apiFetch` constructs `ApiError`, `NetworkError` and
343
+ * `UnreadableBodyError` however it likes, and `registerMemoryTools` passes
344
+ * every rejection through here with the `wording` it was given, so the
345
+ * consumer states its wording once, on `deps`, and the constructors need no
346
+ * second copy. Re-rendering is idempotent (see {@link ApiError.withWording}),
347
+ * so an `apiFetch` that does pass the same `wording` to the constructors
348
+ * gets the same text either way; one that passed a different wording gets
349
+ * `deps.wording`, the one source of truth for this registration. A
350
+ * rejection that is none of the three classes (a plain `Error`, an
351
+ * `McpError`) is returned as is: it carries no wording to apply, and its
352
+ * identity may matter to whoever threw it.
353
+ */
354
+ export declare function rewordFailure(e: unknown, wording: Wording | undefined): unknown;
package/dist/errors.js CHANGED
@@ -89,6 +89,19 @@ const USAGE_URL = "https://console.mnemoverse.com/dashboard/usage";
89
89
  * module is imported BY index.ts, so reading the value from there would be a
90
90
  * cycle. Change one, grep for the other. */
91
91
  const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
92
+ /** Defaults applied field-by-field, with a `typeof`/literal guard on each:
93
+ * `wording` crosses a public package boundary a caller controls only at
94
+ * compile time, so a malformed value at runtime degrades to the default
95
+ * instead of propagating (e.g. into a template literal, or a `Wording`
96
+ * field silently taking a fifth value no branch here checks for). */
97
+ function resolveWording(w) {
98
+ return {
99
+ serverNoun: w?.serverNoun === "this connector" ? "this connector" : "this server",
100
+ auth: w?.auth === "oauth" ? "oauth" : "api-key",
101
+ keysUrl: typeof w?.keysUrl === "string" && w.keysUrl !== "" ? w.keysUrl : KEYS_URL,
102
+ rawDetail: typeof w?.rawDetail === "boolean" ? w.rawDetail : true,
103
+ };
104
+ }
92
105
  /**
93
106
  * Is `details.keys_url` safe to put in front of a user?
94
107
  *
@@ -232,16 +245,42 @@ function has(message, needle) {
232
245
  * "caller org not identified" for the same reason that clause runs first at
233
246
  * all (a valid key must never be told to replace itself), and still before
234
247
  * the substring guess, since a named reason needs no guessing.
248
+ *
249
+ * UNDER `wording.auth === "oauth"` (STEP4-2, owner, 2026-09-24) none of the
250
+ * above applies: the caller never held an API key at all, so `reason` (a
251
+ * diagnosis of WHICH key problem this is) and every key-flavoured branch
252
+ * below would be a wrong cause stated confidently. "Caller org not
253
+ * identified" is the one exception: it is about tenant identification, not
254
+ * about a key, and can fire under either credential type, so it is checked
255
+ * first regardless of `auth`, worded for whichever credential the caller
256
+ * actually holds. Every other 401 collapses to one OAuth-flavoured sentence:
257
+ * reconnect or sign in again, because that is the one thing an OAuth user
258
+ * can actually do about a 401, and naming a wrong one of five key reasons
259
+ * would be worse than naming none.
235
260
  */
236
- function explain401(env) {
261
+ function explain401(env, wording) {
237
262
  const m = env.message;
238
263
  if (has(m, "caller org not identified")) {
239
- return ("Mnemoverse: this deployment could not identify a tenant account for " +
240
- "this request (401). The API key itself was not rejected — do NOT " +
241
- "tell the user to replace it. Room and shared-memory operations need " +
242
- "the multi-tenant backend, which a self-hosted or static-auth " +
243
- "deployment does not have. Quote the detail below, and do not retry " +
244
- "the same call against this deployment.");
264
+ return wording.auth === "oauth"
265
+ ? "Mnemoverse: this deployment could not identify a tenant account for " +
266
+ "this request (401). The caller's sign-in itself was not rejected " +
267
+ "— do NOT tell the user to reconnect over this. Room and " +
268
+ "shared-memory operations need the multi-tenant backend, which a " +
269
+ "self-hosted or static-auth deployment does not have. Quote the " +
270
+ "detail below, and do not retry the same call against this " +
271
+ "deployment."
272
+ : "Mnemoverse: this deployment could not identify a tenant account for " +
273
+ "this request (401). The API key itself was not rejected — do NOT " +
274
+ "tell the user to replace it. Room and shared-memory operations need " +
275
+ "the multi-tenant backend, which a self-hosted or static-auth " +
276
+ "deployment does not have. Quote the detail below, and do not retry " +
277
+ "the same call against this deployment.";
278
+ }
279
+ if (wording.auth === "oauth") {
280
+ return ("Mnemoverse: the user's sign-in was rejected (401). There is no " +
281
+ "credential for them to edit — tell them to disconnect and " +
282
+ "reconnect the app, or sign in again, to refresh their session. Do " +
283
+ "not retry until they do.");
245
284
  }
246
285
  // `details.reason` (an engine change not yet released as of this
247
286
  // one): the engine's own diagnosis of WHICH key problem this is, five
@@ -254,7 +293,7 @@ function explain401(env) {
254
293
  // for byte: `reason` is additive, never a replacement for a message this
255
294
  // parser cannot yet interpret.
256
295
  if (env.reason !== undefined) {
257
- const keysUrl = env.keysUrl ?? KEYS_URL;
296
+ const keysUrl = env.keysUrl ?? wording.keysUrl;
258
297
  switch (env.reason) {
259
298
  case "placeholder_key":
260
299
  return ("Mnemoverse: your API key was rejected (401). The engine itself " +
@@ -309,7 +348,7 @@ function explain401(env) {
309
348
  "MNEMOVERSE_API_KEY is not valid — if it still reads a docs " +
310
349
  'placeholder such as "mk_live_YOUR_KEY" or "mk_live_USER_KEY" (any ' +
311
350
  "value they did not create at the console themselves) it must be " +
312
- `replaced with a real key from ${KEYS_URL}. Do not retry until they ` +
351
+ `replaced with a real key from ${wording.keysUrl}. Do not retry until they ` +
313
352
  "replace it.");
314
353
  }
315
354
  if (m !== undefined || env.code !== undefined) {
@@ -326,33 +365,48 @@ function explain401(env) {
326
365
  "client cannot tell WHO refused. Quote the detail below and check the " +
327
366
  "path to the API first.");
328
367
  }
329
- /** 403: when the ENGINE refused, the key was accepted and then the request
330
- * was refused — name WHICH refusal, from the engine's own message, and never
331
- * blame the key. But that first clause is only known when the engine actually
332
- * spoke: a proxy, a WAF or a tunnel also answers 403, in HTML, and asserting
333
- * "the key identified the account fine" about a response the engine may never
334
- * have seen is exactly the confident wrong cause this module exists to avoid. */
335
- function explain403(env) {
368
+ /** 403: when the ENGINE refused, the credential was accepted and then the
369
+ * request was refused — name WHICH refusal, from the engine's own message,
370
+ * and never blame the credential. But that first clause is only known when
371
+ * the engine actually spoke: a proxy, a WAF or a tunnel also answers 403, in
372
+ * HTML, and asserting "the credential identified the account fine" about a
373
+ * response the engine may never have seen is exactly the confident wrong
374
+ * cause this module exists to avoid.
375
+ *
376
+ * Under `wording.auth === "oauth"` every noun for the credential changes
377
+ * and nothing else does: the clause naming it as innocent ("The API key" /
378
+ * "Your sign-in"), the holder the room-permission causes speak about ("This
379
+ * key" / "This account", since an OAuth user holds no key and membership is
380
+ * the account's), and the one word the opaque-403 branch declines to blame
381
+ * ("the key" / "the sign-in"). Review round 2 on the wording slice: the
382
+ * first cut changed only the innocent clause and left "This key is not an
383
+ * active member" in the same sentence that had just told an OAuth user
384
+ * their sign-in was fine. */
385
+ function explain403(env, wording) {
386
+ const oauth = wording.auth === "oauth";
336
387
  if (saidNothing(env)) {
388
+ const blamed = oauth ? "the sign-in" : "the key";
337
389
  return ("Mnemoverse: this request was refused (403) by something that did not " +
338
390
  "speak this API's error language — the body carries neither of the two " +
339
391
  "shapes the engine produces. That points at a proxy, a gateway, or a " +
340
392
  "MNEMOVERSE_API_URL aimed somewhere unexpected, and the engine may never " +
341
- "have seen the request — so do not blame the key and do not blame room " +
393
+ `have seen the request — so do not blame ${blamed} and do not blame room ` +
342
394
  "permissions: this client cannot tell WHO refused it. Quote the detail " +
343
395
  "below to the user, and do not retry until the path to the API is explained.");
344
396
  }
397
+ const holder = oauth ? "This account" : "This key";
398
+ const holderLc = oauth ? "this account" : "this key";
345
399
  const m = env.message;
346
400
  const cause = has(m, "archiv")
347
401
  ? "The room you addressed is archived. An archived room refuses every read " +
348
402
  "and every write, for its owner as much as for a member, and this client " +
349
403
  "has no operation that reopens one."
350
404
  : has(m, "not an active member") || has(m, "member of this room")
351
- ? "This key is not an active member of the room you addressed. Ask the " +
405
+ ? `${holder} is not an active member of the room you addressed. Ask the ` +
352
406
  "room's owner for an invite; memory_list_rooms shows the rooms it can " +
353
407
  "already reach."
354
408
  : has(m, "read-only")
355
- ? "This key's membership in that room is read-only — it can read the " +
409
+ ? `${holder}'s membership in that room is read-only — it can read the ` +
356
410
  "room but not write to it. Ask the room's owner for write access."
357
411
  : has(m, "invalid room address")
358
412
  ? 'The room address was not in the form the engine accepts ' +
@@ -360,12 +414,13 @@ function explain403(env) {
360
414
  "rather than composing one."
361
415
  : has(m, "own this room")
362
416
  ? "That room belongs to another account, and only its owner can do " +
363
- "this. memory_list_rooms shows which rooms this key owns."
364
- : "Something about this request is not permitted for this key — " +
417
+ `this. memory_list_rooms shows which rooms ${holderLc} owns.`
418
+ : `Something about this request is not permitted for ${holderLc} — ` +
365
419
  "most often the room it addressed. Check memory_list_rooms, and " +
366
420
  "if nothing there explains it, tell the user exactly what was " +
367
421
  "refused instead of guessing.";
368
- return ("Mnemoverse: this request was refused (403). The API key is NOT the problem " +
422
+ const subject = oauth ? "Your sign-in" : "The API key";
423
+ return (`Mnemoverse: this request was refused (403). ${subject} is NOT the problem ` +
369
424
  "— it identified the account fine, and this was a permission decision. " +
370
425
  `${cause} Do not retry the same call: it will be refused again.`);
371
426
  }
@@ -444,15 +499,20 @@ function explain404(f, env) {
444
499
  }
445
500
  /** 429: three causes, opposite advice. The envelope's `retryable` is the
446
501
  * discriminator, because it is the only thing the engine states outright. */
447
- function explain429(f, env) {
502
+ function explain429(f, env, wording) {
448
503
  const secs = retryAfterSeconds(f.retryAfter);
449
504
  const wait = secs === undefined ? "about a minute" : `${secs} seconds`;
450
505
  if (env.retryable === true) {
506
+ // The one 429 sentence that names the credential holder (Sigma, review
507
+ // round 2 on the wording slice): "this key" for an API-key caller, "this
508
+ // account" for an OAuth user who holds no key. The other two branches
509
+ // speak about the account and the plan already.
510
+ const holder = wording.auth === "oauth" ? "this account" : "this key";
451
511
  return ("Mnemoverse: rate-limited (429). This is the per-minute request limit and " +
452
512
  `it clears by itself. Wait ${wait}, then make AT MOST ONE more attempt — ` +
453
513
  "do not retry in a loop and do not fan out into more calls, which is what " +
454
514
  "turns a one-minute limit into a sustained one. If the retry also fails, " +
455
- "stop and tell the user this key is hitting its rate limit.");
515
+ `stop and tell the user ${holder} is hitting its rate limit.`);
456
516
  }
457
517
  if (env.retryable === false) {
458
518
  return ("Mnemoverse: refused for quota, not for speed (429). Waiting will NOT " +
@@ -467,25 +527,30 @@ function explain429(f, env) {
467
527
  }
468
528
  /**
469
529
  * The agent-facing explanation for one failed call, with the raw body kept
470
- * after it.
530
+ * after it (unless `wording.rawDetail === false`, STEP4-3).
471
531
  *
472
532
  * Total by construction: every status reaches a sentence, and the fallback says
473
533
  * that it has no specific guidance instead of inventing some.
534
+ *
535
+ * `wording` (STEP4-2/3, owner 2026-09-24) is optional and, absent, resolves
536
+ * to exactly today's behaviour: every call site before this release passes
537
+ * none, so every existing pin stays byte-identical.
474
538
  */
475
- export function explainApiFailure(f) {
539
+ export function explainApiFailure(f, wording) {
540
+ const resolved = resolveWording(wording);
476
541
  const env = parseErrorEnvelope(f.body);
477
542
  let guidance;
478
543
  if (f.status === 401) {
479
- guidance = explain401(env);
544
+ guidance = explain401(env, resolved);
480
545
  }
481
546
  else if (f.status === 403) {
482
- guidance = explain403(env);
547
+ guidance = explain403(env, resolved);
483
548
  }
484
549
  else if (f.status === 404) {
485
550
  guidance = explain404(f, env);
486
551
  }
487
552
  else if (f.status === 429) {
488
- guidance = explain429(f, env);
553
+ guidance = explain429(f, env, resolved);
489
554
  }
490
555
  else if (f.status === 400 || f.status === 422) {
491
556
  guidance =
@@ -511,7 +576,7 @@ export function explainApiFailure(f) {
511
576
  "specific guidance for that status. Do not invent a cause for the user — " +
512
577
  "quote the detail below. One retry is acceptable; a loop is not.";
513
578
  }
514
- return `${guidance}\n\n${rawDetail(f)}`;
579
+ return resolved.rawDetail ? `${guidance}\n\n${rawDetail(f)}` : guidance;
515
580
  }
516
581
  /**
517
582
  * A server-controlled string, made safe to quote inside model-facing text.
@@ -594,12 +659,13 @@ function refusedRedirect(cause) {
594
659
  * that case now. Every sentence below asserts that nothing answered, so it may
595
660
  * only be reached when nothing did.
596
661
  */
597
- export function explainNetworkFailure(method, path, cause) {
662
+ export function explainNetworkFailure(method, path, cause, wording) {
663
+ const resolved = resolveWording(wording);
598
664
  const name = cause instanceof Error ? cause.name : "";
599
665
  const timedOut = name === "TimeoutError" || name === "AbortError";
600
666
  const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
601
667
  if (refusedRedirect(cause)) {
602
- return (`Mnemoverse: ${method} ${path} was answered with a REDIRECT, and this ` +
668
+ const head = `Mnemoverse: ${method} ${path} was answered with a REDIRECT, and this ` +
603
669
  "client refused to follow it — so the API key was never sent to whatever " +
604
670
  "the redirect pointed at. That refusal is the whole point: following a " +
605
671
  "redirect re-sends the request headers to the new host, which hands a " +
@@ -610,7 +676,10 @@ export function explainNetworkFailure(method, path, cause) {
610
676
  "network: the request arrived somewhere and was answered. Tell the user " +
611
677
  "to check MNEMOVERSE_API_URL and point it straight at the API (the " +
612
678
  `default is ${DEFAULT_API_URL}). Do not retry: the same address will ` +
613
- `redirect again.\n\n${rawTransportDetail(method, path, detail)}`);
679
+ "redirect again.";
680
+ return resolved.rawDetail
681
+ ? `${head}\n\n${rawTransportDetail(method, path, detail)}`
682
+ : head;
614
683
  }
615
684
  const head = timedOut
616
685
  ? `Mnemoverse: the memory service did not answer ${method} ${path} in time.`
@@ -620,11 +689,11 @@ export function explainNetworkFailure(method, path, cause) {
620
689
  ? "The service may just be slow right now."
621
690
  : "That is a connectivity or DNS problem, or MNEMOVERSE_API_URL pointing at " +
622
691
  "a host that does not answer.";
623
- return (`${head} ${cause_} This is NOT a rejected API key and NOT a quota — no ` +
692
+ const body = `${head} ${cause_} This is NOT a rejected API key and NOT a quota — no ` +
624
693
  "reply arrived to say anything about either, so do not send the user to " +
625
694
  "check their key. One retry is reasonable. If that also fails, tell the " +
626
- "user memory is unreachable and carry on without it rather than retrying.\n\n" +
627
- rawTransportDetail(method, path, detail));
695
+ "user memory is unreachable and carry on without it rather than retrying.";
696
+ return resolved.rawDetail ? `${body}\n\n${rawTransportDetail(method, path, detail)}` : body;
628
697
  }
629
698
  /** The debugging half of a transport failure, in one place so the redirect
630
699
  * branch and the generic one cannot drift into two spellings of it. */
@@ -657,7 +726,8 @@ const MAX_PREVIEW_CHARS = 200;
657
726
  * say whether the operation ran, because a body it cannot read is no evidence
658
727
  * either way. That last clause matters most for a write.
659
728
  */
660
- export function explainUnreadableBody(f) {
729
+ export function explainUnreadableBody(f, wording) {
730
+ const resolved = resolveWording(wording);
661
731
  // TWO ARMS, because the two failures have different causes and the wrong one
662
732
  // is a wrong instruction. A body that PARSED WRONG is a foreign answer — a
663
733
  // portal, a proxy, a base URL aimed elsewhere. A body that stopped ARRIVING
@@ -680,10 +750,11 @@ export function explainUnreadableBody(f) {
680
750
  `nothing about the user's key. One retry is reasonable; if it ` +
681
751
  `repeats, tell the user the reply is arriving incomplete and quote ` +
682
752
  `the detail below, status included.`;
683
- return (`Mnemoverse: something answered HTTP ${f.status} for ${f.method} ` +
753
+ const head = `Mnemoverse: something answered HTTP ${f.status} for ${f.method} ` +
684
754
  `${f.path}, but ${body} Whether the operation itself ran is unknown ` +
685
755
  `either way — if this was a write, treat it as neither saved nor ` +
686
- `refused.\n\n${rawUnreadableDetail(f)}`);
756
+ "refused.";
757
+ return resolved.rawDetail ? `${head}\n\n${rawUnreadableDetail(f)}` : head;
687
758
  }
688
759
  /** The debugging half for an unreadable body: what stopped the read, and the
689
760
  * first bytes of what arrived — through the same inert filter as
@@ -708,12 +779,25 @@ export class UnreadableBodyError extends Error {
708
779
  status;
709
780
  method;
710
781
  path;
711
- constructor(f) {
712
- super(explainUnreadableBody(f), { cause: f.cause });
782
+ /** The bytes that failed to parse, when they were read at all. Kept so
783
+ * {@link withWording} re-renders the same arm of the explanation. */
784
+ bodyPreview;
785
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
786
+ * release) reproduces today's message exactly: see {@link explainUnreadableBody}. */
787
+ constructor(f, wording) {
788
+ super(explainUnreadableBody(f, wording), { cause: f.cause });
713
789
  this.name = "UnreadableBodyError";
714
790
  this.status = f.status;
715
791
  this.method = f.method;
716
792
  this.path = f.path;
793
+ this.bodyPreview = f.bodyPreview;
794
+ }
795
+ /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
796
+ withWording(wording) {
797
+ const f = { status: this.status, method: this.method, path: this.path, cause: this.cause };
798
+ if (this.bodyPreview !== undefined)
799
+ f.bodyPreview = this.bodyPreview;
800
+ return new UnreadableBodyError(f, wording);
717
801
  }
718
802
  }
719
803
  /**
@@ -744,12 +828,18 @@ function explain409(f) {
744
828
  export class NetworkError extends Error {
745
829
  method;
746
830
  path;
747
- constructor(method, path, cause) {
748
- super(explainNetworkFailure(method, path, cause), { cause });
831
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
832
+ * release) reproduces today's message exactly: see {@link explainNetworkFailure}. */
833
+ constructor(method, path, cause, wording) {
834
+ super(explainNetworkFailure(method, path, cause, wording), { cause });
749
835
  this.name = "NetworkError";
750
836
  this.method = method;
751
837
  this.path = path;
752
838
  }
839
+ /** The same failure, explained under `wording`: see {@link ApiError.withWording}. */
840
+ withWording(wording) {
841
+ return new NetworkError(this.method, this.path, this.cause, wording);
842
+ }
753
843
  }
754
844
  /**
755
845
  * A failed API call, as an ERROR OBJECT rather than a parsed string.
@@ -769,15 +859,36 @@ export class ApiError extends Error {
769
859
  path;
770
860
  /** The engine's envelope, already parsed — so a caller never re-parses. */
771
861
  envelope;
772
- constructor(f) {
773
- super(explainApiFailure(f));
862
+ /** The `Retry-After` header as it arrived, when the response carried one.
863
+ * Kept so {@link withWording} re-renders a 429 with its retry sentence. */
864
+ retryAfter;
865
+ /** `wording` (STEP4-2/3): optional, and absent (every call site before this
866
+ * release) reproduces today's message exactly: see {@link explainApiFailure}. */
867
+ constructor(f, wording) {
868
+ super(explainApiFailure(f, wording));
774
869
  this.name = "ApiError";
775
870
  this.status = f.status;
776
871
  this.body = f.body;
777
872
  this.method = f.method;
778
873
  this.path = f.path;
874
+ this.retryAfter = f.retryAfter;
779
875
  this.envelope = parseErrorEnvelope(f.body);
780
876
  }
877
+ /**
878
+ * The same failure, explained under `wording`: a new instance of this
879
+ * class whose message is what the constructor would have produced with
880
+ * that `wording`, every field and {@link isBare404} unchanged. The message
881
+ * is a pure function of the failure and the wording, so re-rendering an
882
+ * instance under the wording it was built with gives the same text back.
883
+ * This is how `MemoryToolDeps.wording` reaches an error the consumer's
884
+ * `apiFetch` built without it: see {@link rewordFailure}.
885
+ */
886
+ withWording(wording) {
887
+ const f = { status: this.status, body: this.body, method: this.method, path: this.path };
888
+ if (this.retryAfter !== undefined)
889
+ f.retryAfter = this.retryAfter;
890
+ return new ApiError(f, wording);
891
+ }
781
892
  /**
782
893
  * A 404 the ENGINE did not answer: silence, or the router's literal
783
894
  * defaults. This is what a path the deployment does not serve looks like
@@ -799,4 +910,27 @@ export class ApiError extends Error {
799
910
  return this.status === 404 && engineSilentOn404(this.envelope);
800
911
  }
801
912
  }
913
+ /**
914
+ * Re-explain one of this module's three errors under `wording`; hand
915
+ * anything else back untouched.
916
+ *
917
+ * This is how `MemoryToolDeps.wording` reaches the error text (STEP4-2): a
918
+ * consumer's `apiFetch` constructs `ApiError`, `NetworkError` and
919
+ * `UnreadableBodyError` however it likes, and `registerMemoryTools` passes
920
+ * every rejection through here with the `wording` it was given, so the
921
+ * consumer states its wording once, on `deps`, and the constructors need no
922
+ * second copy. Re-rendering is idempotent (see {@link ApiError.withWording}),
923
+ * so an `apiFetch` that does pass the same `wording` to the constructors
924
+ * gets the same text either way; one that passed a different wording gets
925
+ * `deps.wording`, the one source of truth for this registration. A
926
+ * rejection that is none of the three classes (a plain `Error`, an
927
+ * `McpError`) is returned as is: it carries no wording to apply, and its
928
+ * identity may matter to whoever threw it.
929
+ */
930
+ export function rewordFailure(e, wording) {
931
+ if (e instanceof ApiError || e instanceof NetworkError || e instanceof UnreadableBodyError) {
932
+ return e.withWording(wording);
933
+ }
934
+ return e;
935
+ }
802
936
  //# sourceMappingURL=errors.js.map