@mnemoverse/mcp-memory-server 0.9.1 → 0.10.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/dist/index.js CHANGED
@@ -14,8 +14,9 @@ import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, }
14
14
  import { exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
15
15
  // Every non-2xx becomes an instruction to the calling model instead of a raw
16
16
  // wire echo — see the header of src/errors.ts for why, and for what each status
17
- // actually means in this engine.
18
- import { ApiError, NetworkError } from "./errors.js";
17
+ // actually means in this engine. So does a 2xx whose body this client cannot
18
+ // parse, which is a third case and not a variant of either of the other two.
19
+ import { ApiError, NetworkError, UnreadableBodyError } from "./errors.js";
19
20
  /**
20
21
  * What we know about the scope this read actually covered — a VALUE rather
21
22
  * than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
@@ -62,19 +63,121 @@ function probeScope(searched) {
62
63
  // `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
63
64
  const require = createRequire(import.meta.url);
64
65
  const pkg = require("../package.json");
65
- const API_URL = process.env.MNEMOVERSE_API_URL || "https://core.mnemoverse.com/api/v1";
66
+ /** Where the key goes when the user names no host. Referenced by the base-URL
67
+ * refusal below, which has to tell them what to set it back to. */
68
+ const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
69
+ const API_URL = process.env.MNEMOVERSE_API_URL || DEFAULT_API_URL;
66
70
  const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
67
71
  // Hard cap on tool result size — required by Claude Connectors Directory
68
72
  // (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
69
73
  // Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
70
74
  const MAX_RESULT_CHARS = 24_000 * 4;
71
- // The API key is validated lazily — inside apiFetch, on the first tool call —
72
- // rather than at startup. This lets the server START WITHOUT a key so that
73
- // `tools/list` and other introspection work key-free. MCP directories and
74
- // registries (e.g. Glama) boot the server to enumerate and score its tools,
75
- // and clients may browse capabilities before sign-in; a startup exit on a
76
- // missing key blocks all of that. A tool *invocation* without a key returns a
77
- // clear, actionable error instead (see apiFetch).
75
+ /**
76
+ * Is `MNEMOVERSE_API_URL` an address this client may attach the API key to —
77
+ * and if not, what does the user have to change (#99, CWE-319)?
78
+ *
79
+ * `apiFetch` attaches `X-Api-Key` to whatever this variable holds. Until this
80
+ * check existed, a base URL spelled `http://` — a typo, a copied tunnel address,
81
+ * a misread doc — put a live `mk_live_` key in cleartext on every tool call, on
82
+ * every hop between the user and that host, with nothing anywhere saying so.
83
+ * The key is the whole account: it reads and writes that user's memory.
84
+ *
85
+ * THREE HOSTS ARE EXEMPT, AND ONLY AS LITERAL HOSTNAMES. `http://localhost`,
86
+ * `http://127.0.0.1` and `http://[::1]` are legitimate — a self-hosted engine,
87
+ * this repo's own test harness — and a guard that demanded https unconditionally
88
+ * would break them. The comparison is against `URL.hostname`, NOT a prefix or
89
+ * suffix of the raw string, because `http://localhost.evil.example` passes a
90
+ * prefix test and `http://evil.localhost` passes a suffix test while both
91
+ * resolve to somebody else's server. The exemption is also scheme-bound —
92
+ * `ftp://localhost` is refused — since "aimed at loopback" is not by itself a
93
+ * reason to trust a transport.
94
+ *
95
+ * THE IPv6 LITERAL CARRIES ITS BRACKETS. `new URL("http://[::1]:8100")` reports
96
+ * `hostname` as `"[::1]"` — brackets included, not `"::1"` — so that is what the
97
+ * comparison holds, and test/base-url-guard.test.ts pins both spellings a user
98
+ * can type: the parser compresses `[0:0:0:0:0:0:0:1]` to `[::1]` before this
99
+ * function sees it, so one literal covers the long form too.
100
+ *
101
+ * WHAT IS STILL REFUSED, AND ON PURPOSE. `host.docker.internal` is NOT loopback
102
+ * and is not exempt: it resolves through the container's resolver to an address
103
+ * on the host network, so the packet — and the key — leaves the container before
104
+ * anything knows where it lands. "Aimed at my own machine" is a claim about
105
+ * intent; the exemption is about the wire. `[::ffff:127.0.0.1]` is refused for
106
+ * a smaller reason: it does reach 127.0.0.1, but it is a fourth spelling of an
107
+ * address that already has three legal ones, and widening an exemption that
108
+ * protects a live key is a decision, not a convenience.
109
+ *
110
+ * RETURNS A STRING RATHER THAN THROWING, and is called at import rather than
111
+ * on each request, because a throw at import would take the server down before
112
+ * `tools/list` — the one thing that is documented to work without any config at
113
+ * all (registries boot this server to enumerate its tools). The refusal has to
114
+ * land where the keyless refusal lands: inside a tool CALL.
115
+ *
116
+ * THE MESSAGE NEVER QUOTES THE URL, only its scheme. src/errors.ts keeps the
117
+ * base URL out of every message it builds on the stated grounds that the base
118
+ * can carry credentials — `http://user:pass@host` is exactly the case this guard
119
+ * fires on, so the one message whose subject IS the base URL is also the one
120
+ * most likely to leak it into a model's context and a client's logs. A scheme
121
+ * is safe to interpolate for a second reason: the URL parser restricts it to
122
+ * `[a-z0-9+.-]`, so it cannot carry a newline and open its own paragraph in
123
+ * this file's instruction voice.
124
+ */
125
+ function refuseInsecureBaseUrl(raw) {
126
+ let url;
127
+ try {
128
+ url = new URL(raw);
129
+ }
130
+ catch {
131
+ return {
132
+ toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL is not " +
133
+ "a URL this client can parse — so it cannot tell whether sending the " +
134
+ "API key to it would expose the key, and it will not guess. Nothing " +
135
+ "was sent. This is the user's configuration: their key, their network " +
136
+ "and the service are all fine. Tell them to set MNEMOVERSE_API_URL to " +
137
+ "a complete https:// URL including the scheme and the path — the " +
138
+ `default is ${DEFAULT_API_URL} — or to unset it and use that default. ` +
139
+ "A local engine is the one exception and must be addressed as " +
140
+ "http://localhost, http://127.0.0.1 or http://[::1]. Do not retry " +
141
+ "until it is changed; every memory tool will fail the same way " +
142
+ "until then.",
143
+ startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
144
+ "MNEMOVERSE_API_URL is not a URL this server can parse, so it cannot " +
145
+ "tell whether sending your API key over it would expose it. Set it to " +
146
+ `a complete https:// URL such as ${DEFAULT_API_URL}, or unset it to ` +
147
+ "use that default. Every memory tool will fail until it is changed.",
148
+ };
149
+ }
150
+ const loopback = url.hostname === "localhost" ||
151
+ url.hostname === "127.0.0.1" ||
152
+ // Brackets included: that is what `URL.hostname` reports for an IPv6 host.
153
+ url.hostname === "[::1]";
154
+ if (url.protocol === "https:" || (url.protocol === "http:" && loopback)) {
155
+ return undefined;
156
+ }
157
+ return {
158
+ toolCall: "Mnemoverse: this tool did not run, because MNEMOVERSE_API_URL does not " +
159
+ `address the API over https — its scheme is ${url.protocol}// — and ` +
160
+ "sending the API key to it would put a live mk_live_ key on the wire in " +
161
+ "cleartext, readable by anything on the path. Nothing was sent: this " +
162
+ "client refuses the call instead. This is the user's configuration, not " +
163
+ "a fault of the key, the network or the service. Tell them to change " +
164
+ "MNEMOVERSE_API_URL to the https:// form of the same host — the default " +
165
+ `is ${DEFAULT_API_URL} — or, if they are deliberately running the ` +
166
+ "engine on their own machine, to address it as http://localhost, " +
167
+ "http://127.0.0.1 or http://[::1] — the only three plain-http hosts " +
168
+ "this client accepts. Do not retry until it is changed; every memory " +
169
+ "tool will fail the same way until then.",
170
+ startupLog: "Mnemoverse: startup key check SKIPPED, and nothing was sent — " +
171
+ `MNEMOVERSE_API_URL has the scheme ${url.protocol}// rather than ` +
172
+ "https://, and this server will not put your API key on the wire in " +
173
+ `cleartext. Set it to ${DEFAULT_API_URL}, or to http://localhost, ` +
174
+ "http://127.0.0.1 or http://[::1] if you run the engine yourself. " +
175
+ "Every memory tool will fail until it is changed.",
176
+ };
177
+ }
178
+ /** The verdict on this process's base URL, computed once. `undefined` means the
179
+ * key may go out; anything else is what each surface says instead. */
180
+ const BASE_URL_REFUSAL = refuseInsecureBaseUrl(API_URL);
78
181
  /**
79
182
  * Fetch from the Mnemoverse core API with authentication.
80
183
  *
@@ -104,10 +207,29 @@ async function apiFetch(path, options = {}) {
104
207
  "and starts with mk_live_. Do not retry until it is set; every memory " +
105
208
  "tool will fail the same way until then.");
106
209
  }
210
+ if (BASE_URL_REFUSAL !== undefined) {
211
+ // Alongside the key check, and after it: with no key configured there is
212
+ // nothing to expose, and "set the variable" is the more useful first thing
213
+ // to tell someone who has set neither. Both are decided from CONFIGURATION
214
+ // alone, which is why both belong here rather than in a diagnosis of the
215
+ // response — a guard that fired after fetch would print the right sentence
216
+ // about a key it had already put on the wire.
217
+ throw new Error(BASE_URL_REFUSAL.toolCall);
218
+ }
107
219
  let res;
108
220
  try {
109
221
  res = await fetch(`${API_URL}${path}`, {
110
222
  ...options,
223
+ // AFTER the spread, so no call site can opt out of it. `fetch` defaults to
224
+ // "follow", and Node re-sends request headers to the redirect target: the
225
+ // WHATWG rules strip Authorization, Cookie and Proxy-Authorization when
226
+ // the origin changes and say nothing about a custom header, so
227
+ // `X-Api-Key` rides along to whoever answered (#99, CWE-200). This API
228
+ // serves one stable base path and never redirects legitimately, so the
229
+ // only traffic this refuses is an exfiltration path. The resulting
230
+ // rejection is named by explainNetworkFailure rather than left to look
231
+ // like a dead host.
232
+ redirect: "error",
111
233
  headers: {
112
234
  "Content-Type": "application/json",
113
235
  "X-Api-Key": API_KEY,
@@ -126,13 +248,19 @@ async function apiFetch(path, options = {}) {
126
248
  if (!res.ok) {
127
249
  // `fetch()` resolves once HEADERS arrive; a connection reset during the
128
250
  // body read rejects HERE, not in the catch above, and used to surface as
129
- // a raw undici TypeError (Copilot, #93). Same failure, same wrapper.
251
+ // a raw undici TypeError (Copilot, #93).
252
+ //
253
+ // It is NOT the same failure as the catch above, and calling it one threw
254
+ // the status away: a 502 whose body died mid-stream was reported as "the
255
+ // memory service could not be reached at all… before any HTTP response
256
+ // came back", about a response whose status we are holding. The status is
257
+ // the most actionable thing this failure has, so it goes in the sentence.
130
258
  let text;
131
259
  try {
132
260
  text = await res.text();
133
261
  }
134
262
  catch (cause) {
135
- throw new NetworkError(method, path, cause);
263
+ throw new UnreadableBodyError({ status: res.status, method, path, cause });
136
264
  }
137
265
  throw new ApiError({
138
266
  status: res.status,
@@ -149,14 +277,38 @@ async function apiFetch(path, options = {}) {
149
277
  if (res.status === 204 || res.headers.get("content-length") === "0") {
150
278
  return {};
151
279
  }
152
- // Same mid-body exposure as the error path above: headers said 2xx, then
153
- // the stream died or the JSON arrived truncated. Without the wrapper this
154
- // surfaces as a raw SyntaxError/TypeError nobody explains.
280
+ // A 2xx WHOSE BODY THIS CLIENT CANNOT READ. Read the text first and parse it
281
+ // second, rather than `res.json()`, for one reason: `res.json()` consumes the
282
+ // stream, so when it throws there is nothing left to quote — and the bytes
283
+ // are the evidence. A captive portal's sign-in page, an SPA shell served with
284
+ // 200 text/html, a MITM proxy's notice, or a JSON body that stopped mid-write
285
+ // all land here, and the first line of any of them identifies the culprit.
286
+ //
287
+ // Both throws used to be NetworkError, which asserts that NOTHING answered —
288
+ // "could not be reached at all… before any HTTP response came back… a
289
+ // connectivity or DNS problem" — while a 200 sat in this very function's
290
+ // hand, and the Raw detail below it quoted a SyntaxError out of the body it
291
+ // had just called nonexistent. Sending a user to debug wifi while a portal
292
+ // answers every request is the confident wrong cause src/errors.ts exists to
293
+ // remove. A real `fetch()` rejection above keeps that wording; this does not.
294
+ let body;
155
295
  try {
156
- return (await res.json());
296
+ body = await res.text();
157
297
  }
158
298
  catch (cause) {
159
- throw new NetworkError(method, path, cause);
299
+ throw new UnreadableBodyError({ status: res.status, method, path, cause });
300
+ }
301
+ try {
302
+ return JSON.parse(body);
303
+ }
304
+ catch (cause) {
305
+ throw new UnreadableBodyError({
306
+ status: res.status,
307
+ method,
308
+ path,
309
+ bodyPreview: body,
310
+ cause,
311
+ });
160
312
  }
161
313
  }
162
314
  /**
@@ -188,7 +340,7 @@ moreHint = "Use a more specific query to see all results.") {
188
340
  return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
189
341
  }
190
342
  /**
191
- * A 200 whose body does not carry the array core always sends for a listing.
343
+ * A 2xx whose body does not carry what core always sends for this operation.
192
344
  *
193
345
  * Reading such a body as an EMPTY list is the substitution this release exists
194
346
  * to remove: `Array.isArray(x) ? x : []` turned "this client could not read
@@ -200,8 +352,19 @@ moreHint = "Use a more specific query to see all results.") {
200
352
  * is NOT ("a list of your rooms"), and the absence it is NOT evidence of
201
353
  * ("you have none") — so every consumer states its own boundary while the
202
354
  * sentence stays one sentence everywhere (truth F13, 2026-08-08).
355
+ *
356
+ * NOT ONLY LISTS any more: memory_write was the last surface still reading
357
+ * a missing field as a stated one — `if (r?.stored)`, whose else-branch was an
358
+ * unconditional "NOT STORED — nothing was saved" plus a mechanism nobody sent.
359
+ * It is the same class of claim about a different shape, so it gets the same
360
+ * sentence, and the name no longer says "List".
361
+ *
362
+ * `extra` exists for the write surface alone: a list that could not be read
363
+ * leaves the caller merely uninformed, whereas a WRITE that could not be read
364
+ * leaves an operation whose outcome is unknown, and the caller must be told not
365
+ * to report either outcome to the user.
203
366
  */
204
- function unreadableListReply(subject, notA, absence) {
367
+ function unreadableAnswerReply(subject, notA, absence, extra = "") {
205
368
  return {
206
369
  content: [
207
370
  {
@@ -213,7 +376,7 @@ function unreadableListReply(subject, notA, absence) {
213
376
  // unrecognised body (truth re-verification, 2026-08-09). Pinned in
214
377
  // test/handlers.test.ts.
215
378
  text: `${subject} came back in a shape this client does not recognise — so this ` +
216
- `is not ${notA}, and it is not evidence that ${absence}. Retry; ` +
379
+ `is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
217
380
  `if it persists, whatever answered this call — the memory service, a ` +
218
381
  `gateway or proxy in front of it, or the endpoint a mis-set ` +
219
382
  `MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
@@ -272,6 +435,29 @@ export const server = new McpServer({
272
435
  function isRoomDomain(domain) {
273
436
  return typeof domain === "string" && domain.startsWith("xroom:");
274
437
  }
438
+ /**
439
+ * The three outcomes this client will ever print a WRITE promise about for a
440
+ * room membership. Core grants exactly two scopes — "read" and "read_write"
441
+ * (rooms_routes.py `_VALID_SCOPES`) — and refuses a read-only member's
442
+ * memory_write with a 403 "Read-only membership cannot write to this room"
443
+ * (src/errors.ts). Anything else on the wire (missing field, a Copilot-shaped
444
+ * partial body) is UNSPECIFIED: the server did not say what memory_write would
445
+ * do for this membership, so this client does not guess either — it is treated
446
+ * like "read" for the purpose of NOT promising write, but is not told it is
447
+ * read-only, because that is also a claim the response did not make.
448
+ *
449
+ * Bug hunt (pre-0.9.2, P2): memory_join_room's usage sentence and
450
+ * memory_list_rooms's per-row tail both used to print "use domain=... [on
451
+ * memory_write / memory_read] to read and write" unconditionally — true for a
452
+ * read_write membership, false for a read-only one.
453
+ */
454
+ function roomScopeVerdict(scope) {
455
+ if (scope === "read_write")
456
+ return "read_write";
457
+ if (scope === "read")
458
+ return "read";
459
+ return "unspecified";
460
+ }
275
461
  // --- Tool: memory_write ---
276
462
  server.registerTool("memory_write", {
277
463
  description: "Store a long-term memory that persists across sessions AND across every AI tool the user has connected to Mnemoverse (Claude, ChatGPT, Cursor, VS Code) — write once, recall everywhere. Call this PROACTIVELY the moment the user states a preference, makes a decision, or you learn a durable fact (people, roles, project setup, a lesson). Don't wait to be asked. Never store passwords, API keys, payment data, MFA codes, government IDs, or health records; skip transient chatter that only matters this turn. Behavior: an importance gate may filter low-value writes, so the result tells you whether the memory was stored or filtered. Write `content` as a self-contained statement that still makes sense when recalled out of context.",
@@ -288,7 +474,12 @@ server.registerTool("memory_write", {
288
474
  domain: z
289
475
  .string()
290
476
  .optional()
291
- .describe("Namespace to organize memories (e.g. 'engineering', 'user:alice', 'project:acme')"),
477
+ .describe("Namespace to organize memories (e.g. 'engineering', 'user:alice', 'project:acme')." +
478
+ " Matched byte-for-byte — a leading space, a different case, or an invisible" +
479
+ " character opens a SEPARATE, permanent store, so reuse an exact name from" +
480
+ " memory_stats rather than retyping one. To write into a shared room, pass its" +
481
+ " address here instead (e.g. 'xroom:room_01ABC'). Find room addresses with" +
482
+ " memory_list_rooms."),
292
483
  },
293
484
  annotations: {
294
485
  title: "Store Memory",
@@ -327,6 +518,29 @@ server.registerTool("memory_write", {
327
518
  method: "POST",
328
519
  body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
329
520
  });
521
+ // `stored` MUST BE A BOOLEAN before either verdict below may be printed.
522
+ //
523
+ // This was the last surface reading a MISSING field as a field that said
524
+ // false. `if (r?.stored)` sent every body that did not say `true` to the
525
+ // else-branch, whose first four words are "NOT STORED — nothing was saved"
526
+ // and whose last sentence explains WHY: "Writes are gated on how much a
527
+ // memory adds… so a near-duplicate is refused." So a 204, an empty `{}`, a
528
+ // proxy or gateway answering `{"ok":true}`, and a mis-set
529
+ // MNEMOVERSE_API_URL all produced an absence claim about the user's memory
530
+ // AND a fabricated mechanism for it — two statements, neither with any
531
+ // evidence behind it. The write is also the surface where being wrong costs
532
+ // most: a caller told "nothing was saved" re-words and retries, or drops
533
+ // the fact, and the atom that may in fact be sitting in the store is not
534
+ // what the user is told about.
535
+ //
536
+ // The four LIST surfaces have had this guard since 0.8.1 (truth F13); the
537
+ // write did not. The test for `boolean` and not for presence is deliberate:
538
+ // `{"stored":"yes"}` is not core speaking either.
539
+ if (typeof r?.stored !== "boolean") {
540
+ return unreadableAnswerReply("The write result", "confirmation that the memory was stored", "it was refused", " Whether the content reached memory is unknown from here — report the" +
541
+ " outcome of the RETRY, not of this call, and do not tell the user it" +
542
+ " was saved or that it was rejected.");
543
+ }
330
544
  // "unknown", not 0.00, when the server didn't send a score — the same rule
331
545
  // memory_stats got in this release. A live surface exists that answers
332
546
  // {"stored":false} with no reason and no score; printing "0.00" there
@@ -343,7 +557,9 @@ server.registerTool("memory_write", {
343
557
  const reasonQuote = r?.reason
344
558
  ? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
345
559
  : "";
346
- if (r?.stored) {
560
+ // Narrowed to `true` by the guard above, so this is now the server's stated
561
+ // verdict rather than "the body was not falsy".
562
+ if (r.stored) {
347
563
  return {
348
564
  content: [
349
565
  {
@@ -519,7 +735,7 @@ server.registerTool("memory_read", {
519
735
  // rooms, one level up from the probes (truth F13, 2026-08-08).
520
736
  const items = r?.items;
521
737
  if (!Array.isArray(items)) {
522
- return unreadableListReply("The search result", "a list of matches", "nothing matched");
738
+ return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
523
739
  }
524
740
  if (items.length === 0 && (since || until || exclude_author)) {
525
741
  // A bounded/filtered read that finds nothing is NOT a bad query —
@@ -627,13 +843,72 @@ server.registerTool("memory_read", {
627
843
  };
628
844
  });
629
845
  // --- Tool: memory_list_recent ---
846
+ /**
847
+ * How big ONE feed page may get, and how the handler stays under it (#104).
848
+ *
849
+ * THE INCIDENT. `memory_list_recent(domain: "xroom:…", limit: 40, cursor: …)`
850
+ * over a shared room of long archival entries produced a single tool result of
851
+ * 72,648 characters. Claude Code refused to inline it and spilled it to a file;
852
+ * a client without that fallback loses the page. MAX_RESULT_CHARS did not fire,
853
+ * and could not have: 72,648 is comfortably under 96,000. That number is
854
+ * `24,000 tokens × 4 chars/token`, and the 4 is an average over ordinary prose —
855
+ * archival room entries carry ids, code, punctuation and non-ASCII, which
856
+ * tokenize far worse. A cap derived from an optimistic ratio is not a promise
857
+ * about a client's real limit.
858
+ *
859
+ * WHY `limit` COULD NOT SOLVE IT. `limit` bounds the COUNT. Whether a count is
860
+ * safe depends entirely on how long the entries happen to be — 1,500–4,000+
861
+ * chars each in coordination rooms, against a 10,000-char write cap — and the
862
+ * caller cannot know that before asking. Too high explodes; too low costs
863
+ * dozens of round trips for the same catch-up.
864
+ *
865
+ * WHAT THIS IS. A page is assembled from small sub-requests and stops BEFORE
866
+ * the budget is exceeded, returning the server cursor of the last FULLY
867
+ * accepted sub-batch. The cursor is per-batch, which is why the batch is small:
868
+ * it is the granularity at which the page can end without losing or repeating
869
+ * an entry. Nothing is dropped silently — an entry that exceeds the budget on
870
+ * its own is returned whole, as a page of one, because per-entry truncation
871
+ * needs a fetch-one-by-id verb this server does not have (#104, suggestion 2).
872
+ *
873
+ * THE NUMBER IS DELIBERATELY CONSERVATIVE AND DELIBERATELY LOCAL. 40,000 is
874
+ * roughly half of what already failed in production; the true boundary of any
875
+ * given client is unmeasured, and calibrating it is follow-up work. It does NOT
876
+ * touch MAX_RESULT_CHARS, which is shared with memory_read and every other
877
+ * surface and remains the final backstop after this budget has done its work.
878
+ */
879
+ const LIST_PAGE_CHAR_BUDGET = 40_000;
880
+ /**
881
+ * Entries per sub-request. Small enough that the budget can end a page at a
882
+ * useful granularity, large enough that an ordinary short-entry feed still
883
+ * costs one or two round trips (the default `limit` of 20 costs two).
884
+ */
885
+ const LIST_PAGE_CHUNK = 10;
886
+ /**
887
+ * A ceiling on sub-requests per call — the loop's own stop condition is the
888
+ * budget, the caller's `limit`, or the end of the feed, and this fires only if
889
+ * a server answers in a way none of those three catch. `limit: 100` needs ten,
890
+ * plus a few for narrowing an over-budget batch.
891
+ */
892
+ const LIST_PAGE_MAX_REQUESTS = 16;
893
+ /**
894
+ * Appended when the page stopped because a LATER sub-request failed. Chunking
895
+ * multiplies the requests per call and therefore the chance one of them fails
896
+ * mid-page; discarding the entries already in hand would make this change a
897
+ * regression for exactly the long-entry rooms it exists for. Saying nothing
898
+ * would be worse — a short page with a valid cursor is indistinguishable from
899
+ * a page the budget ended, which is the could-not-fetch/does-not-exist
900
+ * collision this codebase keeps closing.
901
+ */
902
+ const LIST_PAGE_EARLY_STOP_NOTE = "\n\n(This page stopped early — the request for the next batch of older " +
903
+ "entries did not come back usable, so this page holds fewer entries than " +
904
+ "asked for. The cursor above is unaffected: continue from it.)";
630
905
  server.registerTool("memory_list_recent", {
631
- description: "List the NEWEST memories first — no search query needed. Semantic search answers 'what do I know about X'; this answers 'what happened lately': resuming work after a break, catching up on a shared room ('any new messages?'), or reviewing what was saved recently. Pass `since` (your last-seen time) to get only what's new, and page through older entries with the returned cursor. Complete by construction WITHIN ONE SCOPE — nothing is skipped there, unlike a semantic search. To catch up on a shared room you MUST pass its address as `domain`: rooms are separate stores and an unscoped call never covers them.",
906
+ description: "List the NEWEST memories first — no search query needed. Semantic search answers 'what do I know about X'; this answers 'what happened lately': resuming work after a break, catching up on a shared room ('any new messages?'), or reviewing what was saved recently. Pass `since` (your last-seen time) to get only what's new, and page through older entries with the returned cursor. Complete by construction WITHIN ONE SCOPE — nothing is skipped there, unlike a semantic search. A page is also bounded by SIZE, so a page of long entries comes back shorter than `limit` and hands you a cursor for the rest — nothing is dropped, and following the cursor is how you get it. To catch up on a shared room you MUST pass its address as `domain`: rooms are separate stores and an unscoped call never covers them.",
632
907
  inputSchema: {
633
908
  domain: z
634
909
  .string()
635
910
  .optional()
636
- .describe("Restrict to one domain. REQUIRED to read a shared room — pass its address ('xroom:room_01ABC'), because rooms are separate stores that an unscoped feed does NOT cover. Omit only when you mean your own domains. Room addresses come from memory_list_rooms."),
911
+ .describe("Restrict to one domain. REQUIRED to read a shared room — pass its address ('xroom:room_01ABC'), because rooms are separate stores that an unscoped feed does NOT cover. Omit only when you mean your own domains. Room addresses come from memory_list_rooms. Room entries are often long — a room feed usually reaches its size budget after a handful of them, so expect to page (see `limit`)."),
637
912
  since: z
638
913
  .string()
639
914
  .optional()
@@ -653,7 +928,7 @@ server.registerTool("memory_list_recent", {
653
928
  .min(1)
654
929
  .max(100)
655
930
  .optional()
656
- .describe("Page size (default: 20). Newest first."),
931
+ .describe("Most entries per page (default: 20). Newest first. ⚠️ A CEILING, not a promise: the page is ALSO bounded by size, so a page of long entries stops early and returns a cursor for the rest. In rooms whose entries run long, ask for 5–10 — a large `limit` there buys nothing the size budget will not take back, and costs round trips."),
657
932
  cursor: z
658
933
  .string()
659
934
  .max(512)
@@ -671,57 +946,151 @@ server.registerTool("memory_list_recent", {
671
946
  // ONE value for the request and for every decision about it — see the note
672
947
  // at the top of memory_read.
673
948
  const searched = searchedScope(domain);
674
- let r;
675
- try {
676
- r = await apiFetch("/memory/recent", {
677
- method: "POST",
678
- body: JSON.stringify(recentRequestBody({ domain, since, until, exclude_author, limit, cursor })),
679
- });
680
- }
681
- catch (e) {
682
- // Graceful degradation while the server side rolls out: a 404 with no
683
- // error `code` in the body is what an undeployed /memory/recent looks
684
- // like, so degrade to a usable alternative instead of surfacing a raw
685
- // HTTP error.
949
+ // The caller's `limit` is a CEILING on the item count. The page also has a
950
+ // character budget (LIST_PAGE_CHAR_BUDGET), and whichever binds first ends
951
+ // the page — which is why this is a loop over small sub-requests rather
952
+ // than one request for `limit` entries followed by a cap that arrives too
953
+ // late to do anything but truncate.
954
+ const ceiling = limit || 20;
955
+ const accepted = [];
956
+ // The server cursor of the last FULLY accepted sub-batch: the only value
957
+ // that can be handed back without losing or repeating an entry, since a
958
+ // cursor names a batch boundary and nothing finer.
959
+ let acceptedCursor;
960
+ // Where the next sub-request continues from — the caller's cursor first,
961
+ // the server's thereafter. Resending the caller's would replay page one.
962
+ let position = cursor || undefined;
963
+ let ask = Math.min(LIST_PAGE_CHUNK, ceiling);
964
+ let stoppedEarly = false;
965
+ for (let attempt = 0; attempt < LIST_PAGE_MAX_REQUESTS; attempt++) {
966
+ let r;
967
+ try {
968
+ r = await apiFetch("/memory/recent", {
969
+ method: "POST",
970
+ body: JSON.stringify(recentRequestBody({
971
+ domain,
972
+ since,
973
+ until,
974
+ exclude_author,
975
+ limit: ask,
976
+ cursor: position,
977
+ })),
978
+ });
979
+ }
980
+ catch (e) {
981
+ // A failure with entries already in hand ends the page instead of the
982
+ // call: the caller keeps what was fetched plus a cursor that still
983
+ // continues correctly, and the appended note says the page was cut
984
+ // short by a failed request rather than by the budget. With nothing
985
+ // accepted there is no page to return, so the error surfaces exactly
986
+ // as it did before chunking.
987
+ if (accepted.length > 0) {
988
+ stoppedEarly = true;
989
+ break;
990
+ }
991
+ // Graceful degradation while the server side rolls out: a 404 with no
992
+ // error `code` in the body is what an undeployed /memory/recent looks
993
+ // like, so degrade to a usable alternative instead of surfacing a raw
994
+ // HTTP error.
995
+ //
996
+ // NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
997
+ // deployment fact the check cannot establish: every engine 404 carries a
998
+ // `code`, so a real room-404 is excluded, but a gateway, a proxy or a
999
+ // wrong MNEMOVERSE_API_URL produces the same bare 404 and is
1000
+ // indistinguishable from here. The MESSAGE below still states the
1001
+ // deployment cause outright, which is more than this boolean knows —
1002
+ // listed in CHANGELOG's "Known and NOT fixed here" rather than papered
1003
+ // over with a hedge.
1004
+ //
1005
+ // NOW READ FROM STRUCTURED FIELDS. It used to be
1006
+ // `e.message.startsWith("Mnemoverse API error 404:")` plus a substring
1007
+ // hunt for `"code"` in the same string — a behavioural branch keyed to the
1008
+ // exact prefix of a user-facing sentence. Rewording that sentence, which
1009
+ // is precisely what src/errors.ts does, would have flipped this branch
1010
+ // silently: every per-request 404 would have degraded into "the service
1011
+ // does not support the feed yet". `ApiError.isBare404` asks the parsed
1012
+ // envelope instead, so the prose and the branch can no longer collide.
1013
+ const bare404 = e instanceof ApiError && e.isBare404;
1014
+ if (bare404) {
1015
+ return {
1016
+ content: [
1017
+ {
1018
+ type: "text",
1019
+ text: "The memory service does not support the recent-entries feed yet. " +
1020
+ "Use memory_read with order_by: 'recency' as an approximation.",
1021
+ },
1022
+ ],
1023
+ };
1024
+ }
1025
+ throw e;
1026
+ }
1027
+ // Same guard as memory_read: a 200 without an items array is UNREADABLE,
1028
+ // not empty — and the feed's empty heads below are precisely the absence
1029
+ // claims that must not be derived from it (truth F13, 2026-08-08). Mid
1030
+ // page it is treated like a failed sub-request, for the same reason.
1031
+ const batch = r?.items;
1032
+ if (!Array.isArray(batch)) {
1033
+ if (accepted.length > 0) {
1034
+ stoppedEarly = true;
1035
+ break;
1036
+ }
1037
+ return unreadableAnswerReply("The recent-entries feed", "an empty feed", "there is nothing to list");
1038
+ }
1039
+ const next = r?.next_cursor;
1040
+ // Measured on the TEXT THAT WOULD SHIP, rendered by the same function
1041
+ // that renders the answer — an estimate from item lengths would drift
1042
+ // from the renderer the first time a line gained a field. The early-stop
1043
+ // note's length is reserved up front: it is appended only when a LATER
1044
+ // sub-request fails, which cannot be known while this batch is being
1045
+ // sized, so every page keeps room for it (CodeRabbit, PR #108).
1046
+ const fits = formatRecentPage(accepted.concat(batch), next).length <=
1047
+ LIST_PAGE_CHAR_BUDGET - LIST_PAGE_EARLY_STOP_NOTE.length;
1048
+ if (fits) {
1049
+ accepted.push(...batch);
1050
+ acceptedCursor = next;
1051
+ position = typeof next === "string" && next ? next : undefined;
1052
+ // No cursor: the feed ended, and the page says so. No entries: the
1053
+ // server is not advancing, so continuing would spend requests on the
1054
+ // same nothing. Ceiling reached: the caller's count is spent.
1055
+ if (!position || batch.length === 0 || accepted.length >= ceiling)
1056
+ break;
1057
+ ask = Math.min(LIST_PAGE_CHUNK, ceiling - accepted.length);
1058
+ continue;
1059
+ }
1060
+ // Over budget. With entries already accepted, THIS is the ordinary stop:
1061
+ // keep the batches that fit and hand back the cursor of the last one.
1062
+ if (accepted.length > 0)
1063
+ break;
1064
+ // Nothing accepted yet, so this one batch is over budget by itself and
1065
+ // the page cannot be empty — something must be returned.
686
1066
  //
687
- // NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
688
- // deployment fact the check cannot establish: every engine 404 carries a
689
- // `code`, so a real room-404 is excluded, but a gateway, a proxy or a
690
- // wrong MNEMOVERSE_API_URL produces the same bare 404 and is
691
- // indistinguishable from here. The MESSAGE below still states the
692
- // deployment cause outright, which is more than this boolean knows —
693
- // listed in CHANGELOG's "Known and NOT fixed here" rather than papered
694
- // over with a hedge.
1067
+ // `batch.length > ask` means the server ignored `limit`; asking again,
1068
+ // smaller, would be a wasted round trip against a deployment that is not
1069
+ // listening, and MAX_RESULT_CHARS is the backstop for it. A batch of one
1070
+ // is the entry that exceeds the budget alone: it ships whole, because
1071
+ // dropping it is silent loss and truncating it needs a fetch-by-id verb
1072
+ // that does not exist yet (#104).
695
1073
  //
696
- // NOW READ FROM STRUCTURED FIELDS. It used to be
697
- // `e.message.startsWith("Mnemoverse API error 404:")` plus a substring
698
- // hunt for `"code"` in the same string — a behavioural branch keyed to the
699
- // exact prefix of a user-facing sentence. Rewording that sentence, which
700
- // is precisely what src/errors.ts does, would have flipped this branch
701
- // silently: every per-request 404 would have degraded into "the service
702
- // does not support the feed yet". `ApiError.isBare404` asks the parsed
703
- // envelope instead, so the prose and the branch can no longer collide.
704
- const bare404 = e instanceof ApiError && e.isBare404;
705
- if (bare404) {
706
- return {
707
- content: [
708
- {
709
- type: "text",
710
- text: "The memory service does not support the recent-entries feed yet. " +
711
- "Use memory_read with order_by: 'recency' as an approximation.",
712
- },
713
- ],
714
- };
1074
+ // KNOWN EDGE, inherited rather than introduced (CodeRabbit, PR #108):
1075
+ // when a limit-ignoring server's batch ships whole and capResult then
1076
+ // truncates the tail, the printed cursor points past entries the reader
1077
+ // never saw. 0.9.1 had the identical hazard (one request, the server's
1078
+ // cursor, the same cap). The alternatives are worse lies: slicing to
1079
+ // `ask` keeps the server's cursor and SKIPS the sliced entries silently;
1080
+ // rejecting the batch outright answers a working feed with "unreadable".
1081
+ // A contract-violating server is the precondition; the real fix is
1082
+ // fetch-by-id (#104 follow-up), not a guess here.
1083
+ const narrower = Math.max(1, Math.min(Math.floor(ask / 2), batch.length - 1));
1084
+ if (batch.length <= 1 || batch.length > ask || narrower >= ask) {
1085
+ accepted.push(...batch);
1086
+ acceptedCursor = next;
1087
+ break;
715
1088
  }
716
- throw e;
717
- }
718
- // Same guard as memory_read: a 200 without an items array is UNREADABLE,
719
- // not empty — and the feed's empty heads below are precisely the absence
720
- // claims that must not be derived from it (truth F13, 2026-08-08).
721
- const items = r?.items;
722
- if (!Array.isArray(items)) {
723
- return unreadableListReply("The recent-entries feed", "an empty feed", "there is nothing to list");
1089
+ // Re-ask the SAME position for fewer entries. `narrower < batch.length`
1090
+ // by construction, so the ask strictly shrinks and the loop converges.
1091
+ ask = narrower;
724
1092
  }
1093
+ const items = accepted;
725
1094
  if (items.length === 0) {
726
1095
  // THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
727
1096
  // filter that narrowed the window, not by `since` alone.
@@ -789,14 +1158,45 @@ server.registerTool("memory_list_recent", {
789
1158
  // 2026-08-08). Same order as memory_read's result page, same
790
1159
  // automatic drop: a cap that removed every escaped name removes the
791
1160
  // reason for the legend too.
792
- text: withDomainEscapeLegend(capResult(formatRecentPage(items, r?.next_cursor), "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)),
1161
+ //
1162
+ // The cursor is the last ACCEPTED batch's, never the newest one
1163
+ // seen: a batch that did not fit the budget was not returned, so
1164
+ // pointing past it would skip every entry in it.
1165
+ text: withDomainEscapeLegend(capResult(formatRecentPage(items, acceptedCursor) +
1166
+ (stoppedEarly ? LIST_PAGE_EARLY_STOP_NOTE : ""),
1167
+ // Still true, and now only reachable when ONE entry is larger
1168
+ // than the whole budget — the case `limit` cannot fix and the
1169
+ // global cap has to.
1170
+ "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)),
793
1171
  },
794
1172
  ],
795
1173
  };
796
1174
  });
797
1175
  // --- Tool: memory_feedback ---
1176
+ /**
1177
+ * Why ids can miss, listed once and used by both branches that need it — the
1178
+ * total miss (`updated_count: 0`) and the partial one (a count short of the
1179
+ * ids sent). They are the same event at two scales, and when the sentence
1180
+ * lived inline in the zero branch only, the partial case got no explanation at
1181
+ * all.
1182
+ *
1183
+ * No frequency claim. "Most often that means…" was a statistic we do not have
1184
+ * (review, 2026-08-08); the causes are listed as possibilities, with the one
1185
+ * the caller cannot otherwise guess first because it is invisible from the
1186
+ * tool surface — this tool takes no `domain`, so a room atom is unreachable
1187
+ * from it by construction.
1188
+ */
1189
+ const FEEDBACK_MISS_CAUSES = "Possible causes: the ids came from a shared room (this tool takes no domain " +
1190
+ "argument and cannot reach room atoms, so rating them is a no-op); the memory " +
1191
+ "was deleted; or the id came from somewhere other than a memory_read result.";
798
1192
  server.registerTool("memory_feedback", {
799
- description: "Report whether memories returned by memory_read were actually helpful. This is a learning signal, not a log: positive feedback raises a memory's ranking so it surfaces faster next time (across all of the user's tools), negative feedback lets it fade. Call it right after you act on (or reject) recalled memories, passing the ids from the memory_read results. NOTE: this reaches your own domains only — it takes no domain argument, so rating a memory that lives in a shared room silently does nothing.",
1193
+ description:
1194
+ // "negative feedback lets it fade" was withdrawn as false by 0.9.1
1195
+ // (#95) — and survived here, in the sentence every connected model
1196
+ // reads. Nothing time-decays and nothing is auto-deleted: a downvoted
1197
+ // memory is OUT-RANKED, and deletion has been administrative-only since
1198
+ // 0.9.0. The replacement is the wording that release put on the README.
1199
+ "Report whether memories returned by memory_read were actually helpful. This is a learning signal, not a log: positive feedback raises a memory's ranking so it surfaces faster next time (across all of the user's tools), negative feedback lowers it so other memories out-rank it — nothing is erased and nothing decays with time. Call it right after you act on (or reject) recalled memories, passing the ids from the memory_read results. NOTE: this reaches your own domains only — it takes no domain argument, so rating a memory that lives in a shared room silently does nothing.",
800
1200
  inputSchema: {
801
1201
  atom_ids: z
802
1202
  .array(z.string())
@@ -824,7 +1224,67 @@ server.registerTool("memory_feedback", {
824
1224
  method: "POST",
825
1225
  body: JSON.stringify({ atom_ids, outcome }),
826
1226
  });
827
- const count = r?.updated_count ?? 0;
1227
+ // A FIELD THE SERVER DID NOT SEND IS UNKNOWN, NOT ZERO — the rule
1228
+ // memory_stats already applies with `num()`, broken here by
1229
+ // `r?.updated_count ?? 0` in three directions at once:
1230
+ //
1231
+ // 1. A 200 with the field absent, an explicit null, and a 204 (which
1232
+ // apiFetch turns into `{}`) all became 0, and 0 prints "No feedback
1233
+ // was recorded" plus three causes for it — an absence claim read out
1234
+ // of a body that carried no claim. Under core's async path the
1235
+ // rating may well have been applied while the ack said nothing.
1236
+ // 2. A string "0" is not 0, so `??` passed it straight through and the
1237
+ // ±1 branches printed "The service reports 0 memories updated — they
1238
+ // should surface sooner next time": two clauses contradicting each
1239
+ // other in one sentence.
1240
+ // 3. Nothing rejected a negative: "reports -2 memories updated".
1241
+ //
1242
+ // So: usable means a non-negative integer. Anything else is UNKNOWN and
1243
+ // gets its own sentence, which diagnoses nothing — the causes of a miss
1244
+ // belong to a reported zero, not to a number we never received.
1245
+ const reported = r?.updated_count;
1246
+ const count = typeof reported === "number" && Number.isInteger(reported) && reported >= 0
1247
+ ? reported
1248
+ : undefined;
1249
+ // Direction is echoed for every outcome, including the ones with no count
1250
+ // to report: the same four words for +1 and -1 gave a caller no evidence
1251
+ // the loop did anything, which is why nobody calls it twice.
1252
+ const sent = outcome > 0
1253
+ ? `Rating sent: +${outcome} (helpful).`
1254
+ : outcome < 0
1255
+ ? `Rating sent: ${outcome} (unhelpful).`
1256
+ : "Rating sent: 0.";
1257
+ // Zero has no effect clause to offer — promising "this shifts how they
1258
+ // rank" for outcome 0 would be a claim we cannot make (dogfooding saw a
1259
+ // neutral rating move a score UP by about five points, CodeRabbit #65) —
1260
+ // so it offers the one thing that is actionable instead.
1261
+ //
1262
+ // WHAT 0 ACTUALLY DOES, restated against core#493 (merged 2026-08-13). The
1263
+ // valence step used to be `sign(outcome) * |prediction error|`, so outcome
1264
+ // 0 took the POSITIVE branch and pushed valence UP — which is what
1265
+ // dogfooding saw, and what the previous version of this comment recorded.
1266
+ // That is no longer true: core now uses the SIGNED error,
1267
+ // `pe = outcome - valence` (memory_engine.py:5167-5169), so a 0 against a
1268
+ // positive valence moves it DOWN, toward neutral. Either way 0 is not a
1269
+ // no-op and the line must not imply one — but the old explanation is now
1270
+ // backwards, and it ships verbatim inside dist/index.js, so it cannot be
1271
+ // left to rot in a comment.
1272
+ const pickADirection = outcome === 0
1273
+ ? " Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction."
1274
+ : "";
1275
+ if (count === undefined) {
1276
+ return {
1277
+ content: [
1278
+ {
1279
+ type: "text",
1280
+ text: `${sent} The service accepted the call but did not report how many ` +
1281
+ `memories it updated, so whether any changed is unknown from here. ` +
1282
+ `That is not evidence of a failure — do not re-send the same rating ` +
1283
+ `on the strength of it.` + pickADirection,
1284
+ },
1285
+ ],
1286
+ };
1287
+ }
828
1288
  // "Feedback recorded for 0 memories." is one character away from the
829
1289
  // success line and reads like one. But the DIAGNOSIS matters as much as
830
1290
  // the fact: an earlier version of this branch blamed deletion, which is
@@ -841,32 +1301,16 @@ server.registerTool("memory_feedback", {
841
1301
  content: [
842
1302
  {
843
1303
  type: "text",
844
- text:
845
- // No frequency claim. "Most often that means…" was a statistic
846
- // we do not have (review, 2026-08-08); the causes are listed as
847
- // possibilities, with the one the caller cannot otherwise guess
848
- // first because it is invisible from the tool surface.
849
- "No feedback was recorded — none of those ids matched a memory in your own " +
850
- "domains. Possible causes: the ids came from a shared room (this tool takes " +
851
- "no domain argument and cannot reach room atoms, so rating them is a no-op); " +
852
- "the memory was deleted; or the id came from somewhere other than a " +
853
- "memory_read result.",
1304
+ text: "No feedback was recorded — none of those ids matched a memory in your own " +
1305
+ `domains. ${FEEDBACK_MISS_CAUSES}`,
854
1306
  },
855
1307
  ],
856
1308
  };
857
1309
  }
858
- // Echo the DIRECTION, not just the count: the same four words for +1 and
859
- // -1 gave a caller no evidence the loop did anything, which is why nobody
860
- // calls it twice.
861
- //
862
- // The effect clause is per-direction. Promising "this shifts how they
863
- // rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
864
- // neutral rating move a score UP by about five points, so neither "no
865
- // change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
866
1310
  // WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
867
1311
  // service says it touched — and that is true only while core runs
868
1312
  // `feedback_async = False`. Under async it returns the number of ids
869
- // SUBMITTED, not applied (core schemas.py:689-695, memory_engine.py:4490),
1313
+ // SUBMITTED, not applied (core schemas.py:826-838, memory_engine.py:4898-4902),
870
1314
  // and nothing in the response says which mode ran. A server-side config
871
1315
  // flip would therefore turn a confident sentence here false on every
872
1316
  // installed client, silently. So the number is reported as the SERVICE'S
@@ -875,27 +1319,50 @@ server.registerTool("memory_feedback", {
875
1319
  // (mnemoverse-mcp-remote#38); one number, one degree of confidence,
876
1320
  // whichever surface a model reaches it through.
877
1321
  const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
878
- const text = outcome > 0
879
- ? `Rating sent: +${outcome} (helpful). The service reports ${noun} updated — they should surface sooner next time.`
1322
+ const effect = outcome > 0
1323
+ ? " — they should surface sooner next time."
880
1324
  : outcome < 0
881
- ? `Rating sent: ${outcome} (unhelpful). The service reports ${noun} updated — they should fade.`
882
- // Zero claims only what is knowable from here: the rating was sent,
883
- // the service reported a count, and ±1 send a clear signal.
884
- //
885
- // WHAT 0 ACTUALLY DOES, restated against core#493 (merged
886
- // 2026-08-13). The valence step used to be
887
- // `sign(outcome) * |prediction error|`, so outcome 0 took the
888
- // POSITIVE branch and pushed valence UP — which is what dogfooding
889
- // saw, and what the previous version of this comment recorded. That
890
- // is no longer true: core now uses the SIGNED error,
891
- // `pe = outcome - valence` (memory_engine.py:4986-4988), so a 0
892
- // against a positive valence moves it DOWN, toward neutral. Either
893
- // way 0 is not a no-op and the line must not imply one — but the old
894
- // explanation is now backwards, and it ships verbatim inside
895
- // dist/index.js, so it cannot be left to rot in a comment.
896
- : `Rating sent: 0. The service reports ${noun} updated. Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction.`;
1325
+ // No fade. 0.9.1 (#95) withdrew "lets it fade" as false — nothing
1326
+ // time-decays, nothing is auto-deleted, and deletion has been
1327
+ // administrative-only since 0.9.0 — and this line kept promising it
1328
+ // after the release that deleted the claim from the README.
1329
+ ? " — they should rank lower next time. Out-ranked, not erased: nothing is deleted and nothing decays with time."
1330
+ : ".";
1331
+ // WHAT THE COUNT IS NOT: a guarantee that every id landed. `atom_ids.length`
1332
+ // was never compared with it, so five ids and `updated_count: 2` printed
1333
+ // the unqualified success line and three silent misses — the typical shape
1334
+ // of the room case, where half the ids came off a room read this tool
1335
+ // cannot reach. A SHORTFALL can only come from core's sync path (the async
1336
+ // ack is exactly `len(atom_ids)`, memory_engine.py:4898-4902), where the
1337
+ // number is the authoritative count of atoms that existed — so the same
1338
+ // causes as the zero branch apply, at a smaller scale, and the string is
1339
+ // shared so the two cannot drift apart.
1340
+ //
1341
+ // An EXCESS is not a shape core produces at all (sync counts one per id
1342
+ // that resolved, async counts the ids). But MNEMOVERSE_API_URL points
1343
+ // wherever it is pointed, and "reports 9 memories updated" for one id
1344
+ // would otherwise read as nine of the caller's memories rated. Say what it
1345
+ // cannot be rather than pass it off as a per-id result.
1346
+ const idsSent = `${atom_ids.length} id${atom_ids.length === 1 ? "" : "s"} you sent`;
1347
+ const mismatch = count < atom_ids.length
1348
+ ? ` That is fewer than the ${idsSent}: ${atom_ids.length - count} of them ` +
1349
+ `matched nothing in your own domains. ${FEEDBACK_MISS_CAUSES}`
1350
+ : count > atom_ids.length
1351
+ ? ` That is more than the ${idsSent}, so it cannot be a per-id result — ` +
1352
+ `read it as the service's own tally, not as how many of your memories ` +
1353
+ `were rated.`
1354
+ : "";
897
1355
  return {
898
- content: [{ type: "text", text }],
1356
+ content: [
1357
+ {
1358
+ type: "text",
1359
+ // Order: what was sent, what the service reported, what that means
1360
+ // for the ids — then the advice. Putting `pickADirection` before the
1361
+ // mismatch clause interrupted the report with a suggestion and
1362
+ // resumed it afterwards.
1363
+ text: `${sent} The service reports ${noun} updated${effect}${mismatch}${pickADirection}`,
1364
+ },
1365
+ ],
899
1366
  };
900
1367
  });
901
1368
  // --- Tool: memory_stats ---
@@ -932,6 +1399,15 @@ server.registerTool("memory_stats", {
932
1399
  // zero-width character are all visible, Cyrillic stays Cyrillic, and two
933
1400
  // different names can no longer print as one. Assembly lives in
934
1401
  // src/names.ts, where it is unit-tested against those exact inputs.
1402
+ //
1403
+ // The assembly also BOUNDS the list (MAX_DOMAIN_LIST_CHARS), which is what
1404
+ // makes this handler respect the 25K-token cap every other surface already
1405
+ // respected. The line is linear in the number of stores and nothing bounded
1406
+ // it: 4,000 domains rendered past 100,000 characters — deterministically,
1407
+ // with no hostile input involved. Bounding the LIST rather than leaning on
1408
+ // capResult alone is the point: capResult truncates from the END, so the
1409
+ // wall of names would have taken the average-quality line and the rooms
1410
+ // reminder down with it, leaving the answer nothing but names.
935
1411
  const domains = formatDomainList(r?.domains);
936
1412
  const text = [
937
1413
  `Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
@@ -958,7 +1434,18 @@ server.registerTool("memory_stats", {
958
1434
  // arrives over the wire, and spreading a non-iterable object would
959
1435
  // throw here — turning a malformed payload into a dead tool instead
960
1436
  // of the "none reported" it degrades to two lines up.
961
- text: withDomainEscapeLegend(text, ...(Array.isArray(r?.domains) ? r.domains : [])),
1437
+ //
1438
+ // capResult is the second belt, not the mechanism: the domain list is
1439
+ // already bounded above, so this only fires if some future line grows
1440
+ // unboundedly. It stays because this was the ONE tool result with no
1441
+ // cap at all, and "every surface is capped" is worth being an
1442
+ // invariant rather than an argument about which surfaces can grow.
1443
+ // Its hint names a control this no-input tool actually has — none —
1444
+ // rather than the read tool's "use a more specific query".
1445
+ //
1446
+ // Legend AFTER the cap, as everywhere else: it must describe the names
1447
+ // that SURVIVED, and it is appended at the end, where the cap cuts.
1448
+ text: withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])),
962
1449
  },
963
1450
  ],
964
1451
  };
@@ -1069,7 +1556,7 @@ server.registerTool("memory_invite_to_room", {
1069
1556
  });
1070
1557
  // --- Tool: memory_join_room ---
1071
1558
  server.registerTool("memory_join_room", {
1072
- description: "Join a shared memory room using an invite code (starts with 'mnvr_'). Use when the user pastes an invite code or says something like 'join room with code ...'. After joining, use the returned address as the `domain` on memory_write/memory_read to read and write the shared room.",
1559
+ description: "Join a shared memory room using an invite code (starts with 'mnvr_'). Use when the user pastes an invite code or says something like 'join room with code ...'. After joining, use the returned address as the `domain` on memory_read to read the shared room — the result tells you what you may do with it: memory_write to that address is only allowed when your membership scope is read_write; a read-only membership has that write refused; and when the server does not report a scope, whether memory_write would succeed is stated as unknown rather than promised either way.",
1073
1560
  inputSchema: {
1074
1561
  code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
1075
1562
  },
@@ -1098,9 +1585,18 @@ server.registerTool("memory_join_room", {
1098
1585
  ? `You're already a member of ${roomName}.`
1099
1586
  : `Joined ${roomName} (${scope}).`;
1100
1587
  // Don't print broken `domain=""` guidance if no address came back (Copilot).
1101
- const usage = address
1102
- ? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
1103
- : `The server did not return a room address — retry, or check that your API key is set.`;
1588
+ // The write half of this sentence is scope-gated (roomScopeVerdict, above
1589
+ // isRoomDomain): a "read" invite gets told memory_write will be refused
1590
+ // rather than offered it, and a scope the response did not report at all
1591
+ // gets no promise about write either way.
1592
+ const verdict = roomScopeVerdict(r?.scope);
1593
+ const usage = !address
1594
+ ? `The server did not return a room address — retry, or check that your API key is set.`
1595
+ : verdict === "read_write"
1596
+ ? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
1597
+ : verdict === "read"
1598
+ ? `Use it: pass domain="${address}" on memory_read to read it; this membership is read-only, so memory_write to that address will be refused.`
1599
+ : `Use it: pass domain="${address}" on memory_read to read it — the server did not report this membership's write access, so whether memory_write to that address would succeed is unknown.`;
1104
1600
  return {
1105
1601
  content: [
1106
1602
  {
@@ -1113,7 +1609,7 @@ server.registerTool("memory_join_room", {
1113
1609
  });
1114
1610
  // --- Tool: memory_list_rooms ---
1115
1611
  server.registerTool("memory_list_rooms", {
1116
- description: "List the shared memory rooms you can use — the ones you OWN plus the ones you've JOINED — each with the address to pass as `domain` on memory_write / memory_read. Use this to RE-FIND a room in a new session (e.g. 'what rooms do I have?', 'resume the room with Olya') instead of having to create or re-join it.",
1612
+ description: "List the shared memory rooms you can use — the ones you OWN plus the ones you've JOINED — each with the address to pass as `domain` on memory_read, and on memory_write too where your membership scope is read_write; a read-only membership has that write refused. Use this to RE-FIND a room in a new session (e.g. 'what rooms do I have?', 'resume the room with Olya') instead of having to create or re-join it.",
1117
1613
  inputSchema: {},
1118
1614
  annotations: {
1119
1615
  title: "List rooms",
@@ -1134,7 +1630,7 @@ server.registerTool("memory_list_rooms", {
1134
1630
  // Byte-identical to the inline wording this replaces — the builder is
1135
1631
  // shared so the four list surfaces answer the unreadable case with one
1136
1632
  // sentence, not four drifting ones.
1137
- return unreadableListReply("The room list", "a list of your rooms", "you have none");
1633
+ return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
1138
1634
  }
1139
1635
  if (rooms.state === "none") {
1140
1636
  return {
@@ -1167,13 +1663,24 @@ server.registerTool("memory_list_rooms", {
1167
1663
  // src/scope.ts), so that clause was an instruction to make a call that
1168
1664
  // cannot succeed. The address stays visible — it is the room's identity —
1169
1665
  // but the line says what a read against it will do.
1666
+ //
1667
+ // For a live room, the write half is scope-gated the same way
1668
+ // memory_join_room's usage sentence is (roomScopeVerdict, near
1669
+ // isRoomDomain): this used to say "use domain=..." with no operation
1670
+ // named, which read as an unqualified read+write invitation even for a
1671
+ // "read" member — false, since core refuses that member's memory_write.
1672
+ const verdict = roomScopeVerdict(scope);
1170
1673
  const tail = r?.archived
1171
1674
  ? address
1172
1675
  ? ` [archived] — address ${address}, but every read is refused while it is archived`
1173
1676
  : ` [archived] — every read is refused while it is archived`
1174
- : address
1175
- ? ` — use domain="${address}"`
1176
- : "";
1677
+ : !address
1678
+ ? ""
1679
+ : verdict === "read_write"
1680
+ ? ` — use domain="${address}" to read and write it`
1681
+ : verdict === "read"
1682
+ ? ` — use domain="${address}" on memory_read only; this membership is read-only, so memory_write to it will be refused`
1683
+ : ` — use domain="${address}" on memory_read; this membership's write access was not reported`;
1177
1684
  return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
1178
1685
  });
1179
1686
  const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
@@ -1209,7 +1716,7 @@ server.registerTool("vault_list", {
1209
1716
  // 2026-08-08). A genuinely empty array keeps the absence claim below.
1210
1717
  const list = r?.secrets;
1211
1718
  if (!Array.isArray(list)) {
1212
- return unreadableListReply("The secret list", "a list of your Vault secrets", "none are stored");
1719
+ return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
1213
1720
  }
1214
1721
  if (list.length === 0) {
1215
1722
  return {
@@ -1295,10 +1802,33 @@ server.registerTool("vault_list", {
1295
1802
  function probeApiKeyInBackground() {
1296
1803
  if (!API_KEY)
1297
1804
  return;
1805
+ if (BASE_URL_REFUSAL !== undefined) {
1806
+ // THE SECOND CREDENTIAL-BEARING CALL SITE (#99). This probe predates
1807
+ // apiFetch's base-URL guard and calls `fetch` directly, so the guard does
1808
+ // not reach it — and this one fires on every server START, before any tool
1809
+ // is called. Left alone, a user whose MNEMOVERSE_API_URL is spelled
1810
+ // `http://` would leak the key by launching their editor, and the tool-call
1811
+ // guard would then dutifully refuse to leak the key it had already leaked.
1812
+ //
1813
+ // Saying nothing is not an option either: a silent server whose every tool
1814
+ // then fails is the exact invisible failure mode this probe was added to
1815
+ // end. So it reports the skip on stderr — where MCP clients surface
1816
+ // connection logs — in the user's own register.
1817
+ console.error(BASE_URL_REFUSAL.startupLog);
1818
+ return;
1819
+ }
1298
1820
  void (async () => {
1299
1821
  try {
1300
1822
  const res = await fetch(`${API_URL}/memory/stats`, {
1301
1823
  headers: { "X-Api-Key": API_KEY },
1824
+ // Same reason as apiFetch: following a redirect re-sends this header to
1825
+ // the new host (#99, CWE-200), and this request carries the key just as
1826
+ // a tool call does. The rejection lands in the catch below with every
1827
+ // other probe failure — deliberately, since the probe is fire-and-forget
1828
+ // and must never take the server down. Nothing is lost by the silence:
1829
+ // the same redirect meets the first real tool call, where
1830
+ // explainNetworkFailure names it in full.
1831
+ redirect: "error",
1302
1832
  signal: AbortSignal.timeout(10_000),
1303
1833
  });
1304
1834
  // Drain the body so the connection returns to the pool immediately —