@mnemoverse/mcp-memory-server 0.9.0 → 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
@@ -12,6 +12,11 @@ import { SERVER_INSTRUCTIONS, buildReadEmptyResponse } from "./teaching.js";
12
12
  import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
13
13
  import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
14
14
  import { exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
15
+ // Every non-2xx becomes an instruction to the calling model instead of a raw
16
+ // wire echo — see the header of src/errors.ts for why, and for what each status
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";
15
20
  /**
16
21
  * What we know about the scope this read actually covered — a VALUE rather
17
22
  * than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
@@ -58,19 +63,121 @@ function probeScope(searched) {
58
63
  // `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
59
64
  const require = createRequire(import.meta.url);
60
65
  const pkg = require("../package.json");
61
- 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;
62
70
  const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
63
71
  // Hard cap on tool result size — required by Claude Connectors Directory
64
72
  // (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
65
73
  // Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
66
74
  const MAX_RESULT_CHARS = 24_000 * 4;
67
- // The API key is validated lazily — inside apiFetch, on the first tool call —
68
- // rather than at startup. This lets the server START WITHOUT a key so that
69
- // `tools/list` and other introspection work key-free. MCP directories and
70
- // registries (e.g. Glama) boot the server to enumerate and score its tools,
71
- // and clients may browse capabilities before sign-in; a startup exit on a
72
- // missing key blocks all of that. A tool *invocation* without a key returns a
73
- // 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);
74
181
  /**
75
182
  * Fetch from the Mnemoverse core API with authentication.
76
183
  *
@@ -82,31 +189,127 @@ const MAX_RESULT_CHARS = 24_000 * 4;
82
189
  * handlers may switch to 204 in the future even though today they return
83
190
  * a JSON body.
84
191
  *
85
- * @throws Error with message `Mnemoverse API error {status}: {body}` on non-2xx.
192
+ * @throws {@link ApiError} on non-2xx — whose message is the agent-facing
193
+ * explanation from src/errors.ts, with the raw body kept after it. The MCP SDK
194
+ * turns a thrown Error into `{isError: true, content: [{type: "text", text:
195
+ * error.message}]}`, so this message IS what the calling model reads; that is
196
+ * why it is written as instructions rather than as a wire dump.
86
197
  */
87
198
  async function apiFetch(path, options = {}) {
199
+ const method = (options.method ?? "GET").toUpperCase();
88
200
  if (!API_KEY) {
89
- throw new Error("MNEMOVERSE_API_KEY is required for this operation. Get a free key at " +
90
- "https://console.mnemoverse.com and set it in your MCP client config.");
201
+ // The one failure that needs no request to diagnose — and the only one
202
+ // where the fix is "set the variable" rather than "replace its value".
203
+ // Kept distinct from the 401 sentence for exactly that reason.
204
+ throw new Error("Mnemoverse: no API key is configured, so this tool cannot run. Tell the " +
205
+ "user to set MNEMOVERSE_API_KEY in their MCP client config — a free key " +
206
+ "takes about 30 seconds at https://console.mnemoverse.com/dashboard/keys " +
207
+ "and starts with mk_live_. Do not retry until it is set; every memory " +
208
+ "tool will fail the same way until then.");
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
+ }
219
+ let res;
220
+ try {
221
+ res = await fetch(`${API_URL}${path}`, {
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",
233
+ headers: {
234
+ "Content-Type": "application/json",
235
+ "X-Api-Key": API_KEY,
236
+ ...(options.headers || {}),
237
+ },
238
+ });
239
+ }
240
+ catch (cause) {
241
+ // `TypeError: fetch failed` is what a model used to read here, which is as
242
+ // uninstructive as the raw 401 body this change is about. Every existing
243
+ // caller that swallows a failure (the scope probes, the empty-read stats
244
+ // call) catches without inspecting the type, so wrapping changes nothing
245
+ // for them — it only changes what surfaces when nobody catches.
246
+ throw new NetworkError(method, path, cause);
91
247
  }
92
- const res = await fetch(`${API_URL}${path}`, {
93
- ...options,
94
- headers: {
95
- "Content-Type": "application/json",
96
- "X-Api-Key": API_KEY,
97
- ...(options.headers || {}),
98
- },
99
- });
100
248
  if (!res.ok) {
101
- const text = await res.text();
102
- throw new Error(`Mnemoverse API error ${res.status}: ${text}`);
249
+ // `fetch()` resolves once HEADERS arrive; a connection reset during the
250
+ // body read rejects HERE, not in the catch above, and used to surface as
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.
258
+ let text;
259
+ try {
260
+ text = await res.text();
261
+ }
262
+ catch (cause) {
263
+ throw new UnreadableBodyError({ status: res.status, method, path, cause });
264
+ }
265
+ throw new ApiError({
266
+ status: res.status,
267
+ body: text,
268
+ method,
269
+ path,
270
+ // Only the rate-limit middleware sets it, and only on the 429 that
271
+ // waiting actually clears — so its PRESENCE is evidence, not decoration.
272
+ retryAfter: res.headers.get("retry-after"),
273
+ });
103
274
  }
104
275
  // 204 No Content or empty body — return an empty object cast as T so
105
276
  // call sites using optional chaining still work without crashing.
106
277
  if (res.status === 204 || res.headers.get("content-length") === "0") {
107
278
  return {};
108
279
  }
109
- return (await res.json());
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;
295
+ try {
296
+ body = await res.text();
297
+ }
298
+ catch (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
+ });
312
+ }
110
313
  }
111
314
  /**
112
315
  * Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
@@ -137,7 +340,7 @@ moreHint = "Use a more specific query to see all results.") {
137
340
  return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
138
341
  }
139
342
  /**
140
- * 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.
141
344
  *
142
345
  * Reading such a body as an EMPTY list is the substitution this release exists
143
346
  * to remove: `Array.isArray(x) ? x : []` turned "this client could not read
@@ -149,8 +352,19 @@ moreHint = "Use a more specific query to see all results.") {
149
352
  * is NOT ("a list of your rooms"), and the absence it is NOT evidence of
150
353
  * ("you have none") — so every consumer states its own boundary while the
151
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.
152
366
  */
153
- function unreadableListReply(subject, notA, absence) {
367
+ function unreadableAnswerReply(subject, notA, absence, extra = "") {
154
368
  return {
155
369
  content: [
156
370
  {
@@ -162,7 +376,7 @@ function unreadableListReply(subject, notA, absence) {
162
376
  // unrecognised body (truth re-verification, 2026-08-09). Pinned in
163
377
  // test/handlers.test.ts.
164
378
  text: `${subject} came back in a shape this client does not recognise — so this ` +
165
- `is not ${notA}, and it is not evidence that ${absence}. Retry; ` +
379
+ `is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
166
380
  `if it persists, whatever answered this call — the memory service, a ` +
167
381
  `gateway or proxy in front of it, or the endpoint a mis-set ` +
168
382
  `MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
@@ -221,6 +435,29 @@ export const server = new McpServer({
221
435
  function isRoomDomain(domain) {
222
436
  return typeof domain === "string" && domain.startsWith("xroom:");
223
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
+ }
224
461
  // --- Tool: memory_write ---
225
462
  server.registerTool("memory_write", {
226
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.",
@@ -237,7 +474,12 @@ server.registerTool("memory_write", {
237
474
  domain: z
238
475
  .string()
239
476
  .optional()
240
- .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."),
241
483
  },
242
484
  annotations: {
243
485
  title: "Store Memory",
@@ -276,6 +518,29 @@ server.registerTool("memory_write", {
276
518
  method: "POST",
277
519
  body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
278
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
+ }
279
544
  // "unknown", not 0.00, when the server didn't send a score — the same rule
280
545
  // memory_stats got in this release. A live surface exists that answers
281
546
  // {"stored":false} with no reason and no score; printing "0.00" there
@@ -292,7 +557,9 @@ server.registerTool("memory_write", {
292
557
  const reasonQuote = r?.reason
293
558
  ? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
294
559
  : "";
295
- 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) {
296
563
  return {
297
564
  content: [
298
565
  {
@@ -468,7 +735,7 @@ server.registerTool("memory_read", {
468
735
  // rooms, one level up from the probes (truth F13, 2026-08-08).
469
736
  const items = r?.items;
470
737
  if (!Array.isArray(items)) {
471
- return unreadableListReply("The search result", "a list of matches", "nothing matched");
738
+ return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
472
739
  }
473
740
  if (items.length === 0 && (since || until || exclude_author)) {
474
741
  // A bounded/filtered read that finds nothing is NOT a bad query —
@@ -576,13 +843,72 @@ server.registerTool("memory_read", {
576
843
  };
577
844
  });
578
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.)";
579
905
  server.registerTool("memory_list_recent", {
580
- 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.",
581
907
  inputSchema: {
582
908
  domain: z
583
909
  .string()
584
910
  .optional()
585
- .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`)."),
586
912
  since: z
587
913
  .string()
588
914
  .optional()
@@ -602,7 +928,7 @@ server.registerTool("memory_list_recent", {
602
928
  .min(1)
603
929
  .max(100)
604
930
  .optional()
605
- .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."),
606
932
  cursor: z
607
933
  .string()
608
934
  .max(512)
@@ -620,50 +946,151 @@ server.registerTool("memory_list_recent", {
620
946
  // ONE value for the request and for every decision about it — see the note
621
947
  // at the top of memory_read.
622
948
  const searched = searchedScope(domain);
623
- let r;
624
- try {
625
- r = await apiFetch("/memory/recent", {
626
- method: "POST",
627
- body: JSON.stringify(recentRequestBody({ domain, since, until, exclude_author, limit, cursor })),
628
- });
629
- }
630
- catch (e) {
631
- // Graceful degradation while the server side rolls out: a 404 with no
632
- // error `code` in the body is what an undeployed /memory/recent looks
633
- // like, so degrade to a usable alternative instead of surfacing a raw
634
- // 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.
635
1066
  //
636
- // NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
637
- // deployment fact the check cannot establish: every engine 404 carries a
638
- // `code`, so a real room-404 is excluded, but a gateway, a proxy or a
639
- // wrong MNEMOVERSE_API_URL produces the same bare 404 and is
640
- // indistinguishable from here. The MESSAGE below still states the
641
- // deployment cause outright, which is more than this boolean knows —
642
- // listed in CHANGELOG's "Known and NOT fixed here" rather than papered
643
- // over with a hedge.
644
- const bare404 = e instanceof Error &&
645
- e.message.startsWith("Mnemoverse API error 404:") &&
646
- !e.message.includes('"code"');
647
- if (bare404) {
648
- return {
649
- content: [
650
- {
651
- type: "text",
652
- text: "The memory service does not support the recent-entries feed yet. " +
653
- "Use memory_read with order_by: 'recency' as an approximation.",
654
- },
655
- ],
656
- };
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).
1073
+ //
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;
657
1088
  }
658
- throw e;
659
- }
660
- // Same guard as memory_read: a 200 without an items array is UNREADABLE,
661
- // not empty — and the feed's empty heads below are precisely the absence
662
- // claims that must not be derived from it (truth F13, 2026-08-08).
663
- const items = r?.items;
664
- if (!Array.isArray(items)) {
665
- 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;
666
1092
  }
1093
+ const items = accepted;
667
1094
  if (items.length === 0) {
668
1095
  // THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
669
1096
  // filter that narrowed the window, not by `since` alone.
@@ -731,14 +1158,45 @@ server.registerTool("memory_list_recent", {
731
1158
  // 2026-08-08). Same order as memory_read's result page, same
732
1159
  // automatic drop: a cap that removed every escaped name removes the
733
1160
  // reason for the legend too.
734
- 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)),
735
1171
  },
736
1172
  ],
737
1173
  };
738
1174
  });
739
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.";
740
1192
  server.registerTool("memory_feedback", {
741
- 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.",
742
1200
  inputSchema: {
743
1201
  atom_ids: z
744
1202
  .array(z.string())
@@ -766,7 +1224,67 @@ server.registerTool("memory_feedback", {
766
1224
  method: "POST",
767
1225
  body: JSON.stringify({ atom_ids, outcome }),
768
1226
  });
769
- 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
+ }
770
1288
  // "Feedback recorded for 0 memories." is one character away from the
771
1289
  // success line and reads like one. But the DIAGNOSIS matters as much as
772
1290
  // the fact: an earlier version of this branch blamed deletion, which is
@@ -783,32 +1301,16 @@ server.registerTool("memory_feedback", {
783
1301
  content: [
784
1302
  {
785
1303
  type: "text",
786
- text:
787
- // No frequency claim. "Most often that means…" was a statistic
788
- // we do not have (review, 2026-08-08); the causes are listed as
789
- // possibilities, with the one the caller cannot otherwise guess
790
- // first because it is invisible from the tool surface.
791
- "No feedback was recorded — none of those ids matched a memory in your own " +
792
- "domains. Possible causes: the ids came from a shared room (this tool takes " +
793
- "no domain argument and cannot reach room atoms, so rating them is a no-op); " +
794
- "the memory was deleted; or the id came from somewhere other than a " +
795
- "memory_read result.",
1304
+ text: "No feedback was recorded — none of those ids matched a memory in your own " +
1305
+ `domains. ${FEEDBACK_MISS_CAUSES}`,
796
1306
  },
797
1307
  ],
798
1308
  };
799
1309
  }
800
- // Echo the DIRECTION, not just the count: the same four words for +1 and
801
- // -1 gave a caller no evidence the loop did anything, which is why nobody
802
- // calls it twice.
803
- //
804
- // The effect clause is per-direction. Promising "this shifts how they
805
- // rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
806
- // neutral rating move a score UP by about five points, so neither "no
807
- // change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
808
1310
  // WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
809
1311
  // service says it touched — and that is true only while core runs
810
1312
  // `feedback_async = False`. Under async it returns the number of ids
811
- // 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),
812
1314
  // and nothing in the response says which mode ran. A server-side config
813
1315
  // flip would therefore turn a confident sentence here false on every
814
1316
  // installed client, silently. So the number is reported as the SERVICE'S
@@ -817,27 +1319,50 @@ server.registerTool("memory_feedback", {
817
1319
  // (mnemoverse-mcp-remote#38); one number, one degree of confidence,
818
1320
  // whichever surface a model reaches it through.
819
1321
  const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
820
- const text = outcome > 0
821
- ? `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."
822
1324
  : outcome < 0
823
- ? `Rating sent: ${outcome} (unhelpful). The service reports ${noun} updated — they should fade.`
824
- // Zero claims only what is knowable from here: the rating was sent,
825
- // the service reported a count, and ±1 send a clear signal.
826
- //
827
- // WHAT 0 ACTUALLY DOES, restated against core#493 (merged
828
- // 2026-08-13). The valence step used to be
829
- // `sign(outcome) * |prediction error|`, so outcome 0 took the
830
- // POSITIVE branch and pushed valence UP — which is what dogfooding
831
- // saw, and what the previous version of this comment recorded. That
832
- // is no longer true: core now uses the SIGNED error,
833
- // `pe = outcome - valence` (memory_engine.py:4986-4988), so a 0
834
- // against a positive valence moves it DOWN, toward neutral. Either
835
- // way 0 is not a no-op and the line must not imply one — but the old
836
- // explanation is now backwards, and it ships verbatim inside
837
- // dist/index.js, so it cannot be left to rot in a comment.
838
- : `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
+ : "";
839
1355
  return {
840
- 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
+ ],
841
1366
  };
842
1367
  });
843
1368
  // --- Tool: memory_stats ---
@@ -874,6 +1399,15 @@ server.registerTool("memory_stats", {
874
1399
  // zero-width character are all visible, Cyrillic stays Cyrillic, and two
875
1400
  // different names can no longer print as one. Assembly lives in
876
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.
877
1411
  const domains = formatDomainList(r?.domains);
878
1412
  const text = [
879
1413
  `Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
@@ -900,7 +1434,18 @@ server.registerTool("memory_stats", {
900
1434
  // arrives over the wire, and spreading a non-iterable object would
901
1435
  // throw here — turning a malformed payload into a dead tool instead
902
1436
  // of the "none reported" it degrades to two lines up.
903
- 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 : [])),
904
1449
  },
905
1450
  ],
906
1451
  };
@@ -1011,7 +1556,7 @@ server.registerTool("memory_invite_to_room", {
1011
1556
  });
1012
1557
  // --- Tool: memory_join_room ---
1013
1558
  server.registerTool("memory_join_room", {
1014
- 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.",
1015
1560
  inputSchema: {
1016
1561
  code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
1017
1562
  },
@@ -1040,9 +1585,18 @@ server.registerTool("memory_join_room", {
1040
1585
  ? `You're already a member of ${roomName}.`
1041
1586
  : `Joined ${roomName} (${scope}).`;
1042
1587
  // Don't print broken `domain=""` guidance if no address came back (Copilot).
1043
- const usage = address
1044
- ? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
1045
- : `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.`;
1046
1600
  return {
1047
1601
  content: [
1048
1602
  {
@@ -1055,7 +1609,7 @@ server.registerTool("memory_join_room", {
1055
1609
  });
1056
1610
  // --- Tool: memory_list_rooms ---
1057
1611
  server.registerTool("memory_list_rooms", {
1058
- 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.",
1059
1613
  inputSchema: {},
1060
1614
  annotations: {
1061
1615
  title: "List rooms",
@@ -1076,7 +1630,7 @@ server.registerTool("memory_list_rooms", {
1076
1630
  // Byte-identical to the inline wording this replaces — the builder is
1077
1631
  // shared so the four list surfaces answer the unreadable case with one
1078
1632
  // sentence, not four drifting ones.
1079
- 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");
1080
1634
  }
1081
1635
  if (rooms.state === "none") {
1082
1636
  return {
@@ -1109,13 +1663,24 @@ server.registerTool("memory_list_rooms", {
1109
1663
  // src/scope.ts), so that clause was an instruction to make a call that
1110
1664
  // cannot succeed. The address stays visible — it is the room's identity —
1111
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);
1112
1673
  const tail = r?.archived
1113
1674
  ? address
1114
1675
  ? ` [archived] — address ${address}, but every read is refused while it is archived`
1115
1676
  : ` [archived] — every read is refused while it is archived`
1116
- : address
1117
- ? ` — use domain="${address}"`
1118
- : "";
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`;
1119
1684
  return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
1120
1685
  });
1121
1686
  const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
@@ -1151,7 +1716,7 @@ server.registerTool("vault_list", {
1151
1716
  // 2026-08-08). A genuinely empty array keeps the absence claim below.
1152
1717
  const list = r?.secrets;
1153
1718
  if (!Array.isArray(list)) {
1154
- 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");
1155
1720
  }
1156
1721
  if (list.length === 0) {
1157
1722
  return {
@@ -1237,10 +1802,33 @@ server.registerTool("vault_list", {
1237
1802
  function probeApiKeyInBackground() {
1238
1803
  if (!API_KEY)
1239
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
+ }
1240
1820
  void (async () => {
1241
1821
  try {
1242
1822
  const res = await fetch(`${API_URL}/memory/stats`, {
1243
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",
1244
1832
  signal: AbortSignal.timeout(10_000),
1245
1833
  });
1246
1834
  // Drain the body so the connection returns to the pool immediately —