@mnemoverse/mcp-memory-server 0.8.0 → 0.8.2

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
@@ -4,7 +4,39 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import { formatReadItem, formatRecentPage, safeInline, } from "./render.js";
7
+ // `NO_MATCH_MESSAGE` is no longer imported here: this file used to pick between
8
+ // it and buildReadEmptyResponse by testing a note string for truthiness, which
9
+ // is how "we could not check" came to be spelled like "the store is there".
10
+ // Assembling the whole answer belongs in one place — src/teaching.ts.
7
11
  import { SERVER_INSTRUCTIONS, buildReadEmptyResponse } from "./teaching.js";
12
+ import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
13
+ import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
14
+ import { domainPhrase, exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
15
+ /**
16
+ * What we know about the scope this read actually covered — a VALUE rather
17
+ * than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
18
+ * the scope) and runs on zero-result paths only — but it is not that path's
19
+ * only probe: the plain unscoped empty read also hands buildReadEmptyResponse
20
+ * a stats call for the first-contact greeting, so that answer makes two
21
+ * probes where 0.8.0 made one, and the scoped/filtered answers make one where
22
+ * 0.8.0 made none. Disclosed in the 0.8.1 CHANGELOG entry.
23
+ *
24
+ * This replaces `domainMissNote`, whose `catch { return ""; }` made three
25
+ * different states share one spelling: the store is there and the query missed,
26
+ * the room is there and the query missed, and the probe failed. The caller then
27
+ * read that string as a boolean, so "we could not check" was rendered exactly
28
+ * like "the store exists" — the collision this release is about, at the point
29
+ * where the whole scoped path converges. The states are now arms of a union
30
+ * (src/scope.ts) and every consumer must answer for each of them.
31
+ *
32
+ * The probe routing lives in src/scope.ts: a room address is checked against the
33
+ * ROOM list and a domain against `/memory/stats`, because stats never contains a
34
+ * room address (CodeRabbit #65). `searched` is the RAW value that went over the
35
+ * wire, so the diagnosis can never describe a different store than the request.
36
+ */
37
+ function probeScope(searched) {
38
+ return probeReadScope(searched, () => apiFetch("/memory/stats"), () => apiFetch("/memory/rooms"), safeInline);
39
+ }
8
40
  // Version is read at runtime from package.json so there is exactly one place
9
41
  // to bump on each release. Works both from `dist/` during local dev and from
10
42
  // `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
@@ -69,7 +101,13 @@ async function apiFetch(path, options = {}) {
69
101
  * result stays well-formed. Otherwise an emoji or non-BMP character at the
70
102
  * boundary can produce a lone surrogate and corrupt downstream JSON encoding.
71
103
  */
72
- function capResult(text, moreHint = "Use a more specific query or smaller top_k to see all results.") {
104
+ function capResult(text,
105
+ // The default recommends ONLY the control that works. A previous draft also
106
+ // said "or smaller top_k" — but top_k is not a hard cap (association
107
+ // expansion can return more, the relevance floor fewer; the same query at
108
+ // 1/5/20 returned 6/7/4 items), which this release's own top_k description
109
+ // already admits. One surface must not recommend the knob another refutes.
110
+ moreHint = "Use a more specific query to see all results.") {
73
111
  if (text.length <= MAX_RESULT_CHARS)
74
112
  return text;
75
113
  let truncated = text.slice(0, MAX_RESULT_CHARS - 200);
@@ -78,26 +116,77 @@ function capResult(text, moreHint = "Use a more specific query or smaller top_k
78
116
  truncated = truncated.slice(0, -1);
79
117
  }
80
118
  // `moreHint` lets no-input tools (the discovery lists) give accurate truncation
81
- // guidance instead of the read-tool default (which points at query/top_k controls a
119
+ // guidance instead of the read-tool default (which points at a query control a
82
120
  // repeated no-arg call cannot use). Existing callers keep the default message.
83
121
  return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
84
122
  }
85
123
  /**
86
- * Sanitize an untrusted string for safe inline rendering in tool output that a
87
- * DIFFERENT principal's LLM will read (CN-032 anti-injection). A room name is
88
- * chosen by the room OWNER but surfaced to a JOINER's assistant on join/invite;
89
- * core only trims whitespace on it, so quotes/colons/newlines pass. Strip to a
90
- * conservative charset and collapse whitespace, then cap length. Same treatment
91
- * `formatAuthorTag` already applies to a server-stamped author.
92
- * Implementation lives in src/render.ts (shared with the item renderers,
93
- * testable there); re-imported here for the 15 other call sites.
124
+ * A 200 whose body does not carry the array core always sends for a listing.
125
+ *
126
+ * Reading such a body as an EMPTY list is the substitution this release exists
127
+ * to remove: `Array.isArray(x) ? x : []` turned "this client could not read
128
+ * the response" into "there is nothing there" — an absence claim on zero
129
+ * evidence. `classifyRooms` (src/scope.ts) and the stats domains guard closed
130
+ * it for rooms and domains; this builder is the same answer for the remaining
131
+ * list surfaces (search results, the feed, the vault), phrased the way the
132
+ * room list already phrases it. The three parts are the subject, what the body
133
+ * is NOT ("a list of your rooms"), and the absence it is NOT evidence of
134
+ * ("you have none") — so every consumer states its own boundary while the
135
+ * sentence stays one sentence everywhere (truth F13, 2026-08-08).
136
+ */
137
+ function unreadableListReply(subject, notA, absence) {
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ // The tail attributes the unreadable 200 to "whatever answered this
143
+ // call", not to "the memory service" — this client cannot establish
144
+ // WHO answered: a gateway, a proxy, or the endpoint a mis-set
145
+ // MNEMOVERSE_API_URL points at produces the same 200 with an
146
+ // unrecognised body (truth re-verification, 2026-08-09). Pinned in
147
+ // test/handlers.test.ts.
148
+ text: `${subject} came back in a shape this client does not recognise — so this ` +
149
+ `is not ${notA}, and it is not evidence that ${absence}. Retry; ` +
150
+ `if it persists, whatever answered this call — the memory service, a ` +
151
+ `gateway or proxy in front of it, or the endpoint a mis-set ` +
152
+ `MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
153
+ `cannot read.`,
154
+ },
155
+ ],
156
+ };
157
+ }
158
+ /**
159
+ * Two renderers, and which one a value gets is a decision, not a style choice.
160
+ *
161
+ * `safeInline` (src/render.ts) SANITISES an untrusted display string for inline
162
+ * rendering in tool output that a DIFFERENT principal's LLM will read (CN-032
163
+ * anti-injection): strip to a conservative charset, collapse whitespace, cap
164
+ * the length. The treatment `formatAuthorTag` applies to a server-stamped
165
+ * author, and the defensive second pass the machine-shaped room fields
166
+ * (address, room_id, role, scope — all charset-validated or enum-shaped in
167
+ * core) get here. It is lossy on purpose, and everything it still renders is a
168
+ * value the reader looks at and never has to retype or compare.
169
+ *
170
+ * `exactLiteral` / `domainPhrase` / `roomNamePhrase` (src/names.ts) print a
171
+ * value as a JSON string literal, or refuse to print it. Domain names because
172
+ * the engine matches them byte-for-byte, so the reader must be able to send
173
+ * the exact bytes back. Room NAMES (0.8.1) because the sanitiser did not make
174
+ * them harmless so much as it made them WRONG: "проект" echoed back as `""` on
175
+ * create, "Zoë" as "Zo" inside quotes that claim to be the name. The literal
176
+ * is one line with quotes, backslashes and invisibles escaped, so it is as
177
+ * injection-safe as the sanitised spelling was — without the renaming.
94
178
  */
95
179
  // --- Server setup ---
96
180
  // The second argument lands verbatim in the connected model's system prompt on
97
181
  // clients that surface MCP instructions — it is the single highest-leverage
98
182
  // teaching surface this server has. Kept in src/teaching.ts so tests can
99
183
  // assert its polarity/length without booting the server.
100
- const server = new McpServer({
184
+ //
185
+ // EXPORTED so a test can connect a real MCP client to this exact server over
186
+ // the SDK's in-memory transport and invoke the tools (test/harness.ts). Nothing
187
+ // on the published CLI path reads this binding — see the note at the bottom of
188
+ // the file for why the export alone is not enough, and what the CLI still does.
189
+ export const server = new McpServer({
101
190
  name: "mnemoverse-memory",
102
191
  version: pkg.version,
103
192
  }, { instructions: SERVER_INSTRUCTIONS });
@@ -127,15 +216,48 @@ server.registerTool("memory_write", {
127
216
  openWorldHint: true,
128
217
  },
129
218
  }, async ({ content, concepts, domain }) => {
219
+ // NO NORMALISATION HERE — deliberately, after a review found two ways it
220
+ // breaks (2026-08-08). Trimming looked like an obvious win: domain names
221
+ // are matched byte-for-byte in core, so " engineering" opens a permanent
222
+ // second store beside "engineering". But:
223
+ //
224
+ // 1. Core REJECTS a non-canonical room address on purpose — 400
225
+ // "Non-canonical room address", so a write "can't be mis-routed and
226
+ // tagged with a spoofed xroom domain" in its own words. Trimming
227
+ // " xroom:room_01ABC" normalises past that guard, and the atom lands
228
+ // in the ROOM's store, visible to every member. Content that never
229
+ // left the caller in 0.8.0 would leave the account in 0.8.1. The
230
+ // address in that shape is one our own output hands the model.
231
+ // 2. For a caller who has been padding a domain for months, trimming
232
+ // silently relocates new writes and orphans the old corpus — and
233
+ // memory_delete_domain does NOT trim, so the same client can no
234
+ // longer even name the shard it created.
235
+ //
236
+ // Both are behaviour changes, so they do not belong in a patch whose
237
+ // whole claim is that it only changes wording. Normalisation returns in
238
+ // 0.9.0 with delete_domain included, room addresses deliberately EXEMPT,
239
+ // and zero-width characters handled (JS trim() does not strip them —
240
+ // verified, contrary to what an earlier comment here asserted).
130
241
  const r = await apiFetch("/memory/write", {
131
242
  method: "POST",
132
- body: JSON.stringify({
133
- content,
134
- concepts: concepts || [],
135
- domain: domain || "general",
136
- }),
243
+ body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
137
244
  });
138
- const importance = (r?.importance ?? 0).toFixed(2);
245
+ // "unknown", not 0.00, when the server didn't send a score — the same rule
246
+ // memory_stats got in this release. A live surface exists that answers
247
+ // {"stored":false} with no reason and no score; printing "0.00" there
248
+ // fabricates the gate's verdict (review, 2026-08-08).
249
+ const importance = typeof r?.importance === "number" ? r.importance.toFixed(2) : "unknown";
250
+ // `Server reason:` is a VERBATIM label, and safeInline made it a lie on
251
+ // every occurrence: core's only rejection reason is
252
+ // "Below importance threshold (0.412 < 0.500)", whose parentheses and `<`
253
+ // are outside the sanitiser's charset — so the relay read "Below importance
254
+ // threshold 0.412 0.500", with the comparison operator and both delimiters
255
+ // deleted and the two numbers left unlabelled and order-only. Quoted
256
+ // exactly instead (src/names.ts). If it will not fit, say THAT rather than
257
+ // drop the clause: omitting it would report a server that gave no reason.
258
+ const reasonQuote = r?.reason
259
+ ? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
260
+ : "";
139
261
  if (r?.stored) {
140
262
  return {
141
263
  content: [
@@ -146,11 +268,61 @@ server.registerTool("memory_write", {
146
268
  ],
147
269
  };
148
270
  }
271
+ // NOT STORED. The old wording ("Filtered — …") named the mechanism but
272
+ // never the outcome, so a caller could read it as a soft success and move
273
+ // on. In dogfooding this ate a CORRECTION to a wrong fact: the stale
274
+ // version stayed as the only record, and looked more authoritative for
275
+ // having no competitor (2026-08-07).
276
+ //
277
+ // The ADVICE was wrong until 2026-08-08, and wrong in a way that made
278
+ // things worse. It told the caller to rewrite the content as a cleaner
279
+ // factual statement — but the gate scores GEOMETRIC NOVELTY against the
280
+ // nearest existing atom in the same domain, not phrasing or factuality.
281
+ // "Below importance threshold" means TOO SIMILAR TO SOMETHING ALREADY
282
+ // STORED. A rewrite of the same fact therefore produces a near-identical
283
+ // embedding, scores the same or lower, and is rejected again — and the old
284
+ // text ended with "write it again", so a compliant agent looped. It was
285
+ // anti-correlated with the mechanism in exactly the case it was written
286
+ // for: a correction, which is by nature similar to what it corrects.
149
287
  return {
150
288
  content: [
151
289
  {
152
290
  type: "text",
153
- text: `Filtered — ${r?.reason ?? "unknown reason"} (importance: ${importance})`,
291
+ // WHAT IS CONDITIONAL HERE, stated exactly, because a previous
292
+ // version of this comment claimed more than the code does.
293
+ //
294
+ // Conditional: the SERVER'S VERDICT and the SCORE. `Server reason:`
295
+ // is printed only when `reason` came back, and `Novelty score` only
296
+ // when a numeric `importance` did — a live surface answers
297
+ // `{"stored":false}` with neither, and quoting a verdict nobody sent
298
+ // would be a claim on zero evidence.
299
+ //
300
+ // Unconditional: the MECHANISM sentence below, and it is not derived
301
+ // from this response. It is a statement about core: `/memory/write`
302
+ // refuses for exactly one reason — the importance gate, scoring
303
+ // geometric novelty against the nearest existing atom in the same
304
+ // domain, with "Below importance threshold (x < y)" as its only text
305
+ // (two branches in memory_engine, one reason). The BATCH endpoint has
306
+ // other failure paths; this client does not call it. So for any
307
+ // rejection this client can receive, that sentence is true whether or
308
+ // not the server bothered to say why. The earlier comment here read
309
+ // "the cause is named only when the server named it", which describes
310
+ // a draft that did not carry this sentence at all.
311
+ //
312
+ // NOT PRESENT AT ALL: a prediction about the retry. "Rewording will
313
+ // score the same or lower" was asserted as fact and is probably
314
+ // BACKWARDS — novelty decreases with similarity to the blocking
315
+ // memory, so a reworded sentence is usually LESS similar and scores
316
+ // HIGHER. And the delete-then-write advice an earlier draft carried is
317
+ // impossible for a room write: the blocker is a room atom, which
318
+ // memory_delete cannot touch, as this file's own delete message says
319
+ // (reviews, 2026-08-08).
320
+ text: `NOT STORED — nothing was saved.` +
321
+ (reasonQuote ? ` Server reason: ${reasonQuote}.` : ``) +
322
+ (importance === "unknown" ? `` : ` Novelty score ${importance}.`) +
323
+ ` Writes are gated on how much a memory adds over what is already in the` +
324
+ ` same domain, so a near-duplicate is refused. If the point is genuinely` +
325
+ ` new, write what is DIFFERENT rather than restating the whole fact.`,
154
326
  },
155
327
  ],
156
328
  };
@@ -170,11 +342,11 @@ server.registerTool("memory_read", {
170
342
  .min(1)
171
343
  .max(50)
172
344
  .optional()
173
- .describe("Max results to return (default: 5)"),
345
+ .describe("Requested number of results (default: 5). ⚠️ Not a hard cap: association expansion can return MORE than this, and the relevance floor can return fewer — raising it does not reliably widen the result set. For a complete, exactly-bounded listing use memory_list_recent instead."),
174
346
  domain: z
175
347
  .string()
176
348
  .optional()
177
- .describe("Restrict the search to one domain namespace (e.g. 'project:acme'); omit to search across all domains."),
349
+ .describe("Restrict the search to one domain namespace (e.g. 'project:acme'). Omitting it searches your OWN domains — it does NOT include shared rooms, which are separate stores: to search a room, pass its address here (e.g. 'xroom:room_01ABC'). Find room addresses with memory_list_rooms."),
178
350
  order_by: z
179
351
  .enum(["relevance", "recency"])
180
352
  .optional()
@@ -196,10 +368,13 @@ server.registerTool("memory_read", {
196
368
  .string()
197
369
  .max(200)
198
370
  .optional()
199
- .describe("Drop memories written by this author PRINCIPAL (the server-side " +
200
- "identity, not shown in these results). Useful when your system " +
201
- "knows principals (e.g. via the REST API); a self-exclusion " +
202
- "shortcut is planned server-side."),
371
+ .describe("Drop memories written by this author PRINCIPAL — the server-side " +
372
+ "identity. ⚠️ NOT USABLE FROM HERE YET: the principal is not shown " +
373
+ "in these results, so there is no value you can obtain through " +
374
+ "this tool, and a guess like 'me' silently matches nothing and " +
375
+ "filters nothing. Only pass it if your system knows the exact " +
376
+ "principal from elsewhere (e.g. the REST API). A self-exclusion " +
377
+ "shortcut is planned."),
203
378
  },
204
379
  annotations: {
205
380
  title: "Search Memories",
@@ -209,48 +384,99 @@ server.registerTool("memory_read", {
209
384
  openWorldHint: true,
210
385
  },
211
386
  }, async ({ query, top_k, domain, order_by, since, until, exclude_author }) => {
387
+ // ONE value, used for the request AND for every decision about it.
388
+ //
389
+ // A previous draft sent the raw string but decided the wording from a
390
+ // TRIMMED copy, and the two disagreed in the release's own founding case:
391
+ // a read on " engineering" searched the padded store (correct) while the
392
+ // diagnosis checked "engineering", found it, and stayed silent — so the
393
+ // one note that would have said "a stray space makes a different store"
394
+ // was suppressed exactly when a stray space had made a different store.
395
+ // For a whitespace-only domain it went the other way and claimed the
396
+ // search had covered the caller's own domains when it had covered none
397
+ // (reviews, 2026-08-08).
398
+ //
399
+ // `|| undefined` is what 0.8.0 sent and must stay: core filters on
400
+ // `domain is not None`, not on truthiness, so passing "" through would
401
+ // become `WHERE domain = ''` — a store that cannot exist — turning a
402
+ // search of every domain into a guaranteed miss. That is data movement,
403
+ // not wording, and it does not belong in a patch.
404
+ const searched = searchedScope(domain);
212
405
  const r = await apiFetch("/memory/read", {
213
406
  method: "POST",
214
- body: JSON.stringify({
215
- query,
216
- top_k: top_k || 5,
217
- domain: domain || undefined,
218
- include_associations: true,
219
- // #404 temporal dimension — omitted entirely when unused so the
220
- // request body stays byte-identical for existing callers.
221
- ...(order_by ? { order_by } : {}),
222
- ...(since ? { since } : {}),
223
- ...(until ? { until } : {}),
224
- ...(exclude_author ? { exclude_author } : {}),
225
- }),
407
+ body: JSON.stringify(readRequestBody({ query, top_k, domain, order_by, since, until, exclude_author })),
226
408
  });
227
- const items = Array.isArray(r?.items) ? r.items : [];
409
+ // `items` must be a REAL array before anything below may speak. Core's
410
+ // read response always carries one on a 200, so a body without it is not
411
+ // core's answer — and the old `Array.isArray(r?.items) ? r.items : []`
412
+ // fed exactly that body to the entire zero-result machinery: head
413
+ // sentence, scope probe, diagnosis. An absence claim derived from a body
414
+ // this client could not read — the substitution classifyRooms removed for
415
+ // rooms, one level up from the probes (truth F13, 2026-08-08).
416
+ const items = r?.items;
417
+ if (!Array.isArray(items)) {
418
+ return unreadableListReply("The search result", "a list of matches", "nothing matched");
419
+ }
228
420
  if (items.length === 0 && (since || until || exclude_author)) {
229
421
  // A bounded/filtered read that finds nothing is NOT a bad query —
230
- // the truthful answer is "nothing new for these filters" (mirrors
231
- // memory_list_recent's empty copy; no stats probe, no broaden hint).
422
+ // the truthful answer is "nothing new for these filters" (the feed's
423
+ // filtered head uses the same sentence; no stats probe, no broaden
424
+ // hint). Unscoped, it also has to name the rooms it never looked in.
425
+ const scopeNote = readScopeNote(await probeScope(searched));
232
426
  return {
233
427
  content: [
234
428
  {
235
429
  type: "text",
236
- text: "Nothing matching within the given time/author filters.",
430
+ // The scope is IN the sentence, not appended after it. This
431
+ // branch was already the honest one in 0.8.0 — it names its
432
+ // filters — and it is the model the feed's copy now follows.
433
+ //
434
+ // The legend wraps the WHOLE message and is added at most once:
435
+ // `scopeNote` may itself have named a store (a case-twin), and one
436
+ // explanation of the escaping per answer is the point of it.
437
+ text: withDomainEscapeLegend(`Nothing in ${scopeLabel(searched)} matches within the given time/author filters.` +
438
+ futureSinceNote(since, Date.now()) +
439
+ scopeNote, searched),
237
440
  },
238
441
  ],
239
442
  };
240
443
  }
241
444
  if (items.length === 0) {
242
- // Zero results: ONE stats call (made only on this path) distinguishes a
243
- // truly empty store (first contact — greet with how to save the first
244
- // memory) from "no match for this query" (hint to broaden). Stats
245
- // failure falls open to the plain no-match message. Domain-scoped reads
246
- // never greet and never probe — the stats call measures the PERSONAL
247
- // store, not the scoped domain. See src/teaching.ts.
248
- const text = await buildReadEmptyResponse(() => apiFetch("/memory/stats"),
249
- // trim(): a whitespace-only domain is not a real filter — treating it
250
- // as scoped would silently suppress the first-contact greeting.
251
- domain != null && domain.trim() !== "");
445
+ // Zero results. The scope is probed FIRST and the whole answer is then
446
+ // assembled from that ONE value in src/teaching.ts — head sentence and
447
+ // disclosure together, per state.
448
+ //
449
+ // ORDER MATTERS and is now structural: whether the caller has rooms we
450
+ // could not search decides whether the first-contact greeting is even
451
+ // true, so the room knowledge is an INPUT to the answer rather than a flag
452
+ // consulted beside it. `total_atoms` counts the personal org only, so a
453
+ // joiner with three full rooms and no personal writes would otherwise be
454
+ // told "nothing has been saved yet" and contradicted by the note right
455
+ // underneath (review, 2026-08-08).
456
+ //
457
+ // Nothing is appended here any more. While the head came from teaching.ts
458
+ // and the tail was concatenated at this line, the two could disagree — a
459
+ // head promising "that is not the whole picture:" with an empty tail after
460
+ // it was a live bug for an account whose only room was archived.
461
+ const scope = await probeScope(searched);
462
+ const text = await buildReadEmptyResponse(() => apiFetch("/memory/stats"), scope);
252
463
  return {
253
- content: [{ type: "text", text }],
464
+ content: [
465
+ {
466
+ type: "text",
467
+ // NO legend wrapper here, on purpose — `withDomainEscapeLegend(
468
+ // text, searched)` stood on this line and was dead code that
469
+ // looked load-bearing (tests-lens F9, 2026-08-08): every arm of
470
+ // buildReadEmptyResponse either names no store at all (fixed
471
+ // sentences), or names it inside a note that appends its own
472
+ // legend (the case-twin diagnosis, src/scope.ts) — and the
473
+ // unscoped path passes `searched === undefined`, which can never
474
+ // need one. So there was no input on which the wrapper fired.
475
+ // Pinned by "the plain-empty read is legended by its notes" in
476
+ // test/handlers.test.ts.
477
+ text,
478
+ },
479
+ ],
254
480
  };
255
481
  }
256
482
  // Rendering lives in src/render.ts (testable): each line carries the
@@ -260,25 +486,40 @@ server.registerTool("memory_read", {
260
486
  // the old render never delivered them, making both uncallable from
261
487
  // read results).
262
488
  const lines = items.map((item, i) => formatReadItem(item, i));
489
+ // `?? 0` prints a FABRICATED `(0ms)` when the server sent no timing — the
490
+ // same class as "Associations: 0" for an unknown count, which memory_stats
491
+ // fixed in this release. Untouched here and recorded in CHANGELOG's "Known
492
+ // and NOT fixed here" with the other three.
263
493
  const searchMs = (r?.search_time_ms ?? 0).toFixed(0);
264
494
  const text = lines.join("\n\n") + `\n\n(${searchMs}ms)`;
265
495
  return {
266
496
  content: [
267
497
  {
268
498
  type: "text",
269
- text: capResult(text),
499
+ // Each line's `@"domain"` tag is an exact literal; the legend that
500
+ // explains an escape belongs to the answer, not to twenty tags.
501
+ //
502
+ // Legend AFTER the cap, never before. capResult truncates from the
503
+ // END, and the legend is appended at the end — so applied first it
504
+ // was the first thing the cap ate, on exactly the pages long enough
505
+ // to need both: a hundred escaped tags left with nothing saying that
506
+ // \u00a0 is ONE character, not six (truth F6, 2026-08-08). Applied
507
+ // to the CAPPED text the legend survives; and since it fires only
508
+ // when an escaped literal is still on the page, a cap that removed
509
+ // every escaped name drops the legend with it.
510
+ text: withDomainEscapeLegend(capResult(text), ...items.map((it) => it?.domain)),
270
511
  },
271
512
  ],
272
513
  };
273
514
  });
274
515
  // --- Tool: memory_list_recent ---
275
516
  server.registerTool("memory_list_recent", {
276
- 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 — nothing is skipped, unlike a search that only returns semantic matches.",
517
+ 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.",
277
518
  inputSchema: {
278
519
  domain: z
279
520
  .string()
280
521
  .optional()
281
- .describe("Restrict to one domain (e.g. a shared room address 'xroom:...'); omit for all your domains."),
522
+ .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."),
282
523
  since: z
283
524
  .string()
284
525
  .optional()
@@ -291,7 +532,7 @@ server.registerTool("memory_list_recent", {
291
532
  .string()
292
533
  .max(200)
293
534
  .optional()
294
- .describe("Drop entries written by this author PRINCIPAL — the 'everyone but me' read in a shared room. Same parameter as on memory_read."),
535
+ .describe("Drop entries written by this author PRINCIPAL. ⚠️ NOT USABLE FROM HERE YET — the principal is never shown in these results, so there is no value you can get through this tool; a guess like 'me' filters nothing, silently. Same caveat as on memory_read."),
295
536
  limit: z
296
537
  .number()
297
538
  .int()
@@ -313,28 +554,34 @@ server.registerTool("memory_list_recent", {
313
554
  openWorldHint: true,
314
555
  },
315
556
  }, async ({ domain, since, until, exclude_author, limit, cursor }) => {
557
+ // ONE value for the request and for every decision about it — see the note
558
+ // at the top of memory_read.
559
+ const searched = searchedScope(domain);
316
560
  let r;
317
561
  try {
318
562
  r = await apiFetch("/memory/recent", {
319
563
  method: "POST",
320
- body: JSON.stringify({
321
- domain: domain || undefined,
322
- since: since || undefined,
323
- until: until || undefined,
324
- exclude_author: exclude_author || undefined,
325
- limit: limit || 20,
326
- cursor: cursor || undefined,
327
- }),
564
+ body: JSON.stringify(recentRequestBody({ domain, since, until, exclude_author, limit, cursor })),
328
565
  });
329
566
  }
330
567
  catch (e) {
331
- // Graceful degradation while the server side rolls out: a 404 from
332
- // core means the /memory/recent endpoint is not deployed yet — say
333
- // so instead of surfacing a raw HTTP error.
334
- const endpointAbsent = e instanceof Error &&
568
+ // Graceful degradation while the server side rolls out: a 404 with no
569
+ // error `code` in the body is what an undeployed /memory/recent looks
570
+ // like, so degrade to a usable alternative instead of surfacing a raw
571
+ // HTTP error.
572
+ //
573
+ // NAMED FOR WHAT IT TESTS. This was `endpointAbsent`, which asserted a
574
+ // deployment fact the check cannot establish: every engine 404 carries a
575
+ // `code`, so a real room-404 is excluded, but a gateway, a proxy or a
576
+ // wrong MNEMOVERSE_API_URL produces the same bare 404 and is
577
+ // indistinguishable from here. The MESSAGE below still states the
578
+ // deployment cause outright, which is more than this boolean knows —
579
+ // listed in CHANGELOG's "Known and NOT fixed here" rather than papered
580
+ // over with a hedge.
581
+ const bare404 = e instanceof Error &&
335
582
  e.message.startsWith("Mnemoverse API error 404:") &&
336
583
  !e.message.includes('"code"');
337
- if (endpointAbsent) {
584
+ if (bare404) {
338
585
  return {
339
586
  content: [
340
587
  {
@@ -347,15 +594,64 @@ server.registerTool("memory_list_recent", {
347
594
  }
348
595
  throw e;
349
596
  }
350
- const items = Array.isArray(r?.items) ? r.items : [];
597
+ // Same guard as memory_read: a 200 without an items array is UNREADABLE,
598
+ // not empty — and the feed's empty heads below are precisely the absence
599
+ // claims that must not be derived from it (truth F13, 2026-08-08).
600
+ const items = r?.items;
601
+ if (!Array.isArray(items)) {
602
+ return unreadableListReply("The recent-entries feed", "an empty feed", "there is nothing to list");
603
+ }
351
604
  if (items.length === 0) {
605
+ // THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
606
+ // filter that narrowed the window, not by `since` alone.
607
+ //
608
+ // Two prior shapes of this branch were wrong the same way. In 0.8.0 it
609
+ // printed "Nothing new since your watermark." with no scope in it — the
610
+ // sentence src/scope.ts was written to eliminate. The first fix put the
611
+ // scope into the clause but kept choosing BETWEEN the two heads by
612
+ // looking at `since` only, so {until} alone and {exclude_author} alone
613
+ // fell to "No memories in ${where} yet." — an emptiness claim about a
614
+ // store holding a thousand atoms outside the window — and {since, until}
615
+ // kept the watermark phrasing, which pretends the read reached the
616
+ // present when `until` stopped it years short (review, 2026-08-08).
617
+ //
618
+ // Four heads, one rule: the emptiness claim ("yet") is allowed only
619
+ // when NOTHING narrowed the window; the watermark phrasing only when
620
+ // `since` was the whole narrowing; any other filter combination gets the
621
+ // sentence memory_read's filtered branch uses — the filters are named as
622
+ // what bounded the result, and nothing is claimed beyond them.
623
+ //
624
+ // A `cursor` outranks all three. It is not a filter over the store — it
625
+ // is a POSITION in a listing whose earlier pages the caller has already
626
+ // read, so every other head is false here: "No memories in ${where}
627
+ // yet." is an absence claim about a store the caller has just SEEN
628
+ // entries from, and the watermark phrasing pretends a continuation that
629
+ // stopped mid-listing was a clean catch-up. The engine hands out a
630
+ // cursor only when more entries existed at that moment, so an empty
631
+ // continued page means the listing moved under the caller — entries
632
+ // removed between page fetches — and the head speaks about the
633
+ // continuation only, never about what the store holds (follow-up to the
634
+ // head-selection fix, 2026-08-08). Decided by the same truthiness the
635
+ // request used: an empty-string cursor never reaches the wire
636
+ // (src/requests.ts), so it may not pick the sentence either.
637
+ const scopeNote = readScopeNote(await probeScope(searched));
638
+ const where = scopeLabel(searched);
639
+ const head = cursor
640
+ ? `Nothing further in ${where} past this cursor — entries may have been ` +
641
+ `removed since the previous page was fetched.` +
642
+ (since || until || exclude_author
643
+ ? ` The given time/author filters still bounded this page.`
644
+ : ``)
645
+ : since && !until && !exclude_author
646
+ ? `Nothing new in ${where} since your watermark.`
647
+ : since || until || exclude_author
648
+ ? `Nothing in ${where} matches within the given time/author filters.`
649
+ : `No memories in ${where} yet.`;
352
650
  return {
353
651
  content: [
354
652
  {
355
653
  type: "text",
356
- text: since
357
- ? "Nothing new since your watermark."
358
- : "No memories here yet.",
654
+ text: withDomainEscapeLegend(head + futureSinceNote(since, Date.now()) + scopeNote, searched),
359
655
  },
360
656
  ],
361
657
  };
@@ -364,14 +660,22 @@ server.registerTool("memory_list_recent", {
364
660
  content: [
365
661
  {
366
662
  type: "text",
367
- text: capResult(formatRecentPage(items, r?.next_cursor), "Lower `limit` or add a `domain` for smaller pages."),
663
+ // The page body comes from src/render.ts; the escape legend is
664
+ // applied HERE, to the CAPPED text. formatRecentPage used to append
665
+ // it itself, which put it before capResult — and capResult truncates
666
+ // from the end, so the one sentence explaining the escapes was the
667
+ // first casualty on every page long enough to be capped (truth F6,
668
+ // 2026-08-08). Same order as memory_read's result page, same
669
+ // automatic drop: a cap that removed every escaped name removes the
670
+ // reason for the legend too.
671
+ text: withDomainEscapeLegend(capResult(formatRecentPage(items, r?.next_cursor), "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)),
368
672
  },
369
673
  ],
370
674
  };
371
675
  });
372
676
  // --- Tool: memory_feedback ---
373
677
  server.registerTool("memory_feedback", {
374
- 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.",
678
+ 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.",
375
679
  inputSchema: {
376
680
  atom_ids: z
377
681
  .array(z.string())
@@ -400,13 +704,63 @@ server.registerTool("memory_feedback", {
400
704
  body: JSON.stringify({ atom_ids, outcome }),
401
705
  });
402
706
  const count = r?.updated_count ?? 0;
707
+ // "Feedback recorded for 0 memories." is one character away from the
708
+ // success line and reads like one. But the DIAGNOSIS matters as much as
709
+ // the fact: an earlier version of this branch blamed deletion, which is
710
+ // usually the wrong cause (review, 2026-08-08).
711
+ //
712
+ // Core resolves the feedback org from a `domain` argument that defaults to
713
+ // "general" — and THIS TOOL EXPOSES NO domain PARAMETER. So the ordinary
714
+ // way to get zero is to rate atoms that live somewhere else: read a room,
715
+ // take the ids off the `id:` lines, rate them, and every one silently
716
+ // misses. The atoms exist, the ids are valid, and telling the caller they
717
+ // were deleted sends them to look for a problem that isn't there.
718
+ if (count === 0) {
719
+ return {
720
+ content: [
721
+ {
722
+ type: "text",
723
+ text:
724
+ // No frequency claim. "Most often that means…" was a statistic
725
+ // we do not have (review, 2026-08-08); the causes are listed as
726
+ // possibilities, with the one the caller cannot otherwise guess
727
+ // first because it is invisible from the tool surface.
728
+ "No feedback was recorded — none of those ids matched a memory in your own " +
729
+ "domains. Possible causes: the ids came from a shared room (this tool takes " +
730
+ "no domain argument and cannot reach room atoms, so rating them is a no-op); " +
731
+ "the memory was deleted; or the id came from somewhere other than a " +
732
+ "memory_read result.",
733
+ },
734
+ ],
735
+ };
736
+ }
737
+ // Echo the DIRECTION, not just the count: the same four words for +1 and
738
+ // -1 gave a caller no evidence the loop did anything, which is why nobody
739
+ // calls it twice.
740
+ //
741
+ // The effect clause is per-direction. Promising "this shifts how they
742
+ // rank" for outcome 0 would be a claim we cannot make — dogfooding saw a
743
+ // neutral rating move a score UP by about five points, so neither "no
744
+ // change" nor "ranks higher" is safe to assert (CodeRabbit, #65).
745
+ const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
746
+ const text = outcome > 0
747
+ ? `Recorded +${outcome} (helpful) for ${noun} — they should surface sooner next time.`
748
+ : outcome < 0
749
+ ? `Recorded ${outcome} (unhelpful) for ${noun} — they should fade.`
750
+ // Zero claims only what is knowable from here: the rating was
751
+ // recorded, and ±1 send a clear signal. Two earlier wordings each
752
+ // asserted engine semantics that were false: "use +1/-1 when you want
753
+ // the ranking to move" implied 0 leaves ranking alone (dogfooding saw
754
+ // a neutral rating move a score UP by about five points), and "logged
755
+ // as a recall that was neither useful nor wrong" described a record
756
+ // the engine does not keep — core files outcome 0 on the POSITIVE
757
+ // branch of the valence update (memory_engine.py `_feedback_inner`:
758
+ // sign is +1 for outcome >= 0) and stores a positive-direction
759
+ // valence step plus outcome_count += 1. So 0 is not a neutral log
760
+ // entry, and the printed line must not present it as one.
761
+ : `Recorded 0 for ${noun}. Use +1 (helpful) or -1 (harmful/wrong) to express a clear direction.`;
403
762
  return {
404
- content: [
405
- {
406
- type: "text",
407
- text: `Feedback recorded for ${count} memor${count === 1 ? "y" : "ies"}.`,
408
- },
409
- ],
763
+ content: [{ type: "text", text }],
410
764
  };
411
765
  });
412
766
  // --- Tool: memory_stats ---
@@ -422,20 +776,59 @@ server.registerTool("memory_stats", {
422
776
  },
423
777
  }, async () => {
424
778
  const r = await apiFetch("/memory/stats");
425
- const domains = Array.isArray(r?.domains) && r.domains.length > 0
426
- ? r.domains.join(", ")
427
- : "general";
779
+ // A field the server did not send is UNKNOWN, not zero. Rendering it as
780
+ // "0" is the same class of lie as an empty search claiming emptiness:
781
+ // "Associations: 0" reads as "this memory has learned nothing", which is
782
+ // a strong and possibly false statement about the product itself.
783
+ const num = (v) => (typeof v === "number" ? String(v) : "unknown");
784
+ const dec = (v) => (typeof v === "number" ? v.toFixed(2) : "unknown");
785
+ // THE surface two other tool descriptions send the reader to "to confirm
786
+ // the exact domain name before a delete" — so it has to be able to answer
787
+ // that, and until now it could not.
788
+ //
789
+ // Quoting was tried, then withdrawn as a fix that wasn't one: safeInline
790
+ // collapses and trims whitespace BEFORE the quotes go on, so " engineering"
791
+ // and "engineering" printed identically and the list read like a tool bug.
792
+ // The conclusion drawn then — "revealing whitespace needs escaping rather
793
+ // than quoting" — was right; this is that escaping. Each name is a JSON
794
+ // string literal, so a leading space, a no-break space, a newline and a
795
+ // zero-width character are all visible, Cyrillic stays Cyrillic, and two
796
+ // different names can no longer print as one. Assembly lives in
797
+ // src/names.ts, where it is unit-tested against those exact inputs.
798
+ const domains = formatDomainList(r?.domains);
428
799
  const text = [
429
- `Memories: ${r?.total_atoms ?? 0} (${r?.episodes ?? 0} episodes, ${r?.prototypes ?? 0} prototypes)`,
430
- `Associations: ${r?.hebbian_edges ?? 0} Hebbian edges`,
800
+ `Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
801
+ // The gloss names the real mechanism. Core's `hebbian_edges` counts
802
+ // concept-concept edges (api/schemas.py: "Number of Hebbian
803
+ // concept-concept edges"), and every path that learns one links two
804
+ // CONCEPTS: `strengthen` walks pairs within one atom's concept list at
805
+ // write time and again on feedback, `co_activate` links query concepts
806
+ // to result concepts on use. An earlier gloss said "links between
807
+ // memories that get used together" — wrong unit (memories, not
808
+ // concepts) and wrong trigger (an edge records co-occurrence, not two
809
+ // memories being used together).
810
+ `Associations: ${num(r?.hebbian_edges)} Hebbian edges — concept-to-concept links learned from concepts that occur together as memories are stored and used`,
431
811
  `Domains: ${domains}`,
432
- `Avg quality: valence ${(r?.avg_valence ?? 0).toFixed(2)}, importance ${(r?.avg_importance ?? 0).toFixed(2)}`,
812
+ `Avg quality: valence ${dec(r?.avg_valence)} (how well recalls turned out, -1..1), importance ${dec(r?.avg_importance)} (0..1)`,
813
+ "",
814
+ "Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
433
815
  ].join("\n");
434
- return { content: [{ type: "text", text }] };
816
+ return {
817
+ content: [
818
+ {
819
+ type: "text",
820
+ // Array.isArray, not `?? []`: `domains` is typed as a string[] but
821
+ // arrives over the wire, and spreading a non-iterable object would
822
+ // throw here — turning a malformed payload into a dead tool instead
823
+ // of the "none reported" it degrades to two lines up.
824
+ text: withDomainEscapeLegend(text, ...(Array.isArray(r?.domains) ? r.domains : [])),
825
+ },
826
+ ],
827
+ };
435
828
  });
436
829
  // --- Tool: memory_delete ---
437
830
  server.registerTool("memory_delete", {
438
- description: "Permanently delete ONE memory by its atom_id — irreversible, the memory is gone for good. Use it to keep the memory trustworthy: prune a memory that is obsolete, superseded by a newer decision, or that you stored wrongly — and whenever the user asks to forget something. Get the atom_id from a memory_read result. To clear an entire topic at once, use memory_delete_domain instead.",
831
+ description: "Permanently delete ONE memory by its atom_id — irreversible, the memory is gone for good. Use it to keep the memory trustworthy: prune a memory that is obsolete, superseded by a newer decision, or that you stored wrongly — and whenever the user asks to forget something. Get the atom_id from a memory_read result. Reaches your own domains only: memories in shared rooms CANNOT be deleted through this tool. To clear an entire topic at once, use memory_delete_domain instead.",
439
832
  inputSchema: {
440
833
  atom_id: z
441
834
  .string()
@@ -450,16 +843,41 @@ server.registerTool("memory_delete", {
450
843
  openWorldHint: true,
451
844
  },
452
845
  }, async ({ atom_id }) => {
453
- // Core API returns { deleted: <count>, atom_id }. count == 0 means
454
- // the atom didn't exist (or was already removed). count >= 1 means
455
- // it was deleted.
846
+ // Core API returns { deleted: <count>, atom_id }, so today a falsy
847
+ // `deleted` means the count came back 0 — nothing in the caller's own store
848
+ // carried that id.
849
+ //
850
+ // The check below is `!r?.deleted`, which is falsy-not-zero: a MISSING
851
+ // field lands in the same branch as a real 0. `apiFetch` turns a 204 or an
852
+ // empty body into `{}` (see its own note that FastAPI DELETE handlers may
853
+ // switch to 204), so under that response a successful delete would report
854
+ // "nothing was deleted". Unknown rendered as zero, then zero rendered as an
855
+ // absence claim — the same family as `updated_count ?? 0` in
856
+ // memory_feedback and `deleted ?? 0` in memory_delete_domain. Left as it
857
+ // stands and listed in CHANGELOG's "Known and NOT fixed here"; it is a
858
+ // behaviour fix, not a wording one.
456
859
  const r = await apiFetch(`/memory/atoms/${encodeURIComponent(atom_id)}`, { method: "DELETE" });
457
860
  if (!r?.deleted) {
861
+ // NOT "no such memory". Deletion is scoped to the caller's OWN store —
862
+ // core has no room-scoped delete at all — so a room atom's id lands here
863
+ // even when the memory lives on in its room. This release made room ids
864
+ // MORE visible in read results, so an agent is now MORE likely to bring
865
+ // one here, ask to forget it, and be told it never existed while it stays
866
+ // (review, 2026-08-08). Every other empty branch in this file learned to
867
+ // state its boundary; the destructive one has to as well.
868
+ //
869
+ // The boundary claim is the ONLY claim: "this delete never reached it"
870
+ // is knowable from here (room stores are out of this tool's reach by
871
+ // construction). "it still exists" — a previous wording — is not: the
872
+ // room owner may have deleted it since, and this handler has no way to
873
+ // check (review, 2026-08-08).
458
874
  return {
459
875
  content: [
460
876
  {
461
877
  type: "text",
462
- text: `No memory found with id ${atom_id}.`,
878
+ text: `Nothing was deleted: no memory with id ${atom_id} in your own domains. ` +
879
+ `Note that memories in shared rooms CANNOT be deleted through this tool — ` +
880
+ `if that id came from a room, this delete never reached it.`,
463
881
  },
464
882
  ],
465
883
  };
@@ -475,7 +893,7 @@ server.registerTool("memory_delete", {
475
893
  });
476
894
  // --- Tool: memory_delete_domain ---
477
895
  server.registerTool("memory_delete_domain", {
478
- description: "Permanently delete EVERY memory in one domain — irreversible, and far more sweeping than memory_delete. This is a bulk wipe: run it only when the user asks for it (e.g. 'forget everything about project X') or has explicitly confirmed a wipe you proposed — never on your own judgment alone. First run memory_stats to confirm the exact domain name, then pass it together with confirm=true (a deliberate safety interlock). For a single wrong or stale memory, memory_delete is the right tool.",
896
+ description: "Permanently delete EVERY memory in one domain — irreversible, and far more sweeping than memory_delete. This is a bulk wipe: run it only when the user asks for it (e.g. 'forget everything about project X') or has explicitly confirmed a wipe you proposed — never on your own judgment alone. First run memory_stats to confirm the exact domain name, then pass it together with confirm=true (a deliberate safety interlock). Names match byte-for-byte, including case, and shared rooms cannot be wiped through this tool. For a single wrong or stale memory, memory_delete is the right tool.",
479
897
  inputSchema: {
480
898
  domain: z
481
899
  .string()
@@ -499,20 +917,59 @@ server.registerTool("memory_delete_domain", {
499
917
  // reaches this handler, so no runtime re-check is needed here.
500
918
  async ({ domain }) => {
501
919
  const r = await apiFetch(`/memory/domain/${encodeURIComponent(domain)}`, { method: "DELETE" });
920
+ // `?? 0` again: a missing `deleted` field becomes 0 and then becomes the
921
+ // "NOTHING was deleted" claim below. Core sends the count, so this is not
922
+ // reachable through core today — recorded with the other two in CHANGELOG's
923
+ // "Known and NOT fixed here".
502
924
  const count = r?.deleted ?? 0;
503
- const domainName = r?.domain ?? domain;
925
+ // The name of a store on a DESTRUCTIVE confirmation, so it is printed
926
+ // exactly or not at all. safeInline named a different store than the one
927
+ // acted on: wiping `" project x"` confirmed `"project x"`, and the very next
928
+ // clause tells the reader that names match byte-for-byte. When the name
929
+ // cannot be reproduced the sentences fall back to naming nothing, which
930
+ // still leaves them true.
931
+ //
932
+ // NOT called `wiped`, which is what it was: on the zero branch nothing was
933
+ // wiped, and the value is whatever the server echoed — core echoes the path
934
+ // parameter back, so it equals what we sent, but a server that echoed
935
+ // something else would have this handler name a store the caller never
936
+ // passed, one clause before telling them names match byte-for-byte. The
937
+ // fallback to `domain` is the value that actually went over the wire.
938
+ const reportedDomain = r?.domain ?? domain;
939
+ const reportedName = domainPhrase(reportedDomain, "the domain you passed");
940
+ // Zero is NOT a successful wipe, and this line used to read like one.
941
+ // Core echoes back the caller's own string, so "Deleted 0 memories from
942
+ // domain 'Project X'" implies a domain that existed and is now empty. The
943
+ // casing trap runs in this direction too: the user says "forget everything
944
+ // about project X", the agent passes "Project X", gets a success-shaped
945
+ // line, reports done — and the atoms live on under "project x" (review,
946
+ // 2026-08-08). This release built the diagnosis for exactly that and had
947
+ // applied it only to reads.
948
+ if (count === 0) {
949
+ return {
950
+ content: [
951
+ {
952
+ type: "text",
953
+ text: withDomainEscapeLegend(`NOTHING was deleted — no memories matched domain ${reportedName} in your own ` +
954
+ `store. Names match byte-for-byte, so check the spelling and case against ` +
955
+ `memory_stats before reporting this as done. Shared rooms cannot be wiped ` +
956
+ `through this tool at all.`, reportedDomain),
957
+ },
958
+ ],
959
+ };
960
+ }
504
961
  return {
505
962
  content: [
506
963
  {
507
964
  type: "text",
508
- text: `Deleted ${count} ${count === 1 ? "memory" : "memories"} from domain "${domainName}".`,
965
+ text: withDomainEscapeLegend(`Deleted ${count} ${count === 1 ? "memory" : "memories"} from domain ${reportedName}.`, reportedDomain),
509
966
  },
510
967
  ],
511
968
  };
512
969
  });
513
970
  // --- Tool: memory_create_room ---
514
971
  server.registerTool("memory_create_room", {
515
- description: "Create a SHARED memory room — a space you and OTHER people's assistants can both read and write, across Claude/ChatGPT/Cursor. Use when the user wants to share context or collaborate with someone else (e.g. 'make a room for me and Olya'). Returns the room's address; pass that address as the `domain` on memory_write/memory_read to use it. To bring someone in, call memory_invite_to_room next.",
972
+ description: "Create a SHARED memory room — a space OTHER people's assistants can read, and write too when their invite granted read_write (the default scope), across Claude/ChatGPT/Cursor. Use when the user wants to share context or collaborate with someone else (e.g. 'make a room for me and Olya'). Returns the room's address; pass that address as the `domain` on memory_write/memory_read to use it. To bring someone in, call memory_invite_to_room next.",
516
973
  inputSchema: {
517
974
  name: z
518
975
  .string()
@@ -537,24 +994,33 @@ server.registerTool("memory_create_room", {
537
994
  method: "POST",
538
995
  body: JSON.stringify({ name, description }),
539
996
  });
540
- const roomName = safeInline(r?.name ?? name);
997
+ // The name is echoed back to the caller who CHOSE it, so it is printed
998
+ // exactly (src/names.ts): safeInline turned `memory_create_room({name:
999
+ // "проект"})` into `Created shared room ""` — an echo claiming the caller
1000
+ // named their room the empty string.
1001
+ const rawName = r?.name ?? name;
1002
+ const roomName = roomNamePhrase(rawName);
541
1003
  const address = safeInline(r?.address);
542
1004
  const roomId = safeInline(r?.room_id);
543
1005
  // If core returned no usable id (empty body / sanitized away), don't print
544
1006
  // broken `domain=""` guidance — say so instead (Copilot).
545
1007
  const text = address
546
- ? `Created shared room "${roomName}". Address: ${address}\n` +
1008
+ ? `Created shared room ${roomName}. Address: ${address}\n` +
547
1009
  `Use it now: pass domain="${address}" on memory_write / memory_read.\n` +
548
1010
  (roomId
549
1011
  ? `To add someone: call memory_invite_to_room with room_id="${roomId}".`
550
1012
  : "")
551
- : `Room "${roomName}" was created but the server did not return a usable address — ` +
1013
+ : `Room ${roomName} was created but the server did not return a usable address — ` +
552
1014
  `retry, or check that your API key is set.`;
553
1015
  return {
554
1016
  content: [
555
1017
  {
556
1018
  type: "text",
557
- text: capResult(text),
1019
+ // Legend AFTER the cap (same rule as memory_read/memory_list_recent):
1020
+ // capResult cuts from the end, so a legend applied first would be the
1021
+ // first casualty; applied to the capped text it also drops itself when
1022
+ // the cap removed the only escaped name.
1023
+ text: withDomainEscapeLegend(capResult(text), rawName),
558
1024
  },
559
1025
  ],
560
1026
  };
@@ -623,14 +1089,18 @@ server.registerTool("memory_join_room", {
623
1089
  method: "POST",
624
1090
  body: JSON.stringify({ code }),
625
1091
  });
626
- // CN-032: the room name/scope are OWNER-chosen but rendered into the
627
- // JOINER's LLM context here — sanitize before inlining (anti prompt-injection).
628
- const roomName = safeInline(r?.name) || "the room";
1092
+ // The room name is OWNER-chosen and rendered into the JOINER's LLM context
1093
+ // (CN-032) — and it is printed EXACTLY (src/names.ts): the literal is one
1094
+ // line with quotes and invisibles escaped, so it cannot forge an
1095
+ // instruction block, and "Zoë" stops being quoted as "Zo" in a sentence
1096
+ // that presents it as the name. `scope` stays sanitised: server-shaped
1097
+ // enum, display-only.
1098
+ const roomName = roomNamePhrase(r?.name);
629
1099
  const scope = safeInline(r?.scope) || "member";
630
1100
  const address = safeInline(r?.address);
631
1101
  const prefix = r?.already_member
632
- ? `You're already a member of "${roomName}".`
633
- : `Joined "${roomName}" (${scope}).`;
1102
+ ? `You're already a member of ${roomName}.`
1103
+ : `Joined ${roomName} (${scope}).`;
634
1104
  // Don't print broken `domain=""` guidance if no address came back (Copilot).
635
1105
  const usage = address
636
1106
  ? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room.`
@@ -639,7 +1109,8 @@ server.registerTool("memory_join_room", {
639
1109
  content: [
640
1110
  {
641
1111
  type: "text",
642
- text: capResult(`${prefix}\n${usage}`),
1112
+ // Legend after the cap — same ordering rule as everywhere else.
1113
+ text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
643
1114
  },
644
1115
  ],
645
1116
  };
@@ -656,9 +1127,20 @@ server.registerTool("memory_list_rooms", {
656
1127
  openWorldHint: true,
657
1128
  },
658
1129
  }, async () => {
659
- const rooms = await apiFetch("/memory/rooms");
660
- const list = Array.isArray(rooms) ? rooms : [];
661
- if (list.length === 0) {
1130
+ // The same classifier the scope notes use, for the same reason: this handler
1131
+ // did `Array.isArray(rooms) ? rooms : []` and then asserted "You have no
1132
+ // shared rooms yet" — a claim about the account derived from a body it could
1133
+ // not read. It is the third consumer of this payload and the third place the
1134
+ // substitution was made; all three now go through one function that maps an
1135
+ // unreadable body to `unknown`, never to `none`.
1136
+ const rooms = classifyRooms(await apiFetch("/memory/rooms"), safeInline);
1137
+ if (rooms.state === "unknown") {
1138
+ // Byte-identical to the inline wording this replaces — the builder is
1139
+ // shared so the four list surfaces answer the unreadable case with one
1140
+ // sentence, not four drifting ones.
1141
+ return unreadableListReply("The room list", "a list of your rooms", "you have none");
1142
+ }
1143
+ if (rooms.state === "none") {
662
1144
  return {
663
1145
  content: [
664
1146
  {
@@ -669,27 +1151,44 @@ server.registerTool("memory_list_rooms", {
669
1151
  ],
670
1152
  };
671
1153
  }
672
- // Room name is OWNER-chosen and surfaced to THIS assistant — sanitize it (CN-032),
673
- // like memory_join_room already does. address/role/scope are server-shaped but pass
674
- // through the same guard defensively.
1154
+ // ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
1155
+ // is the inventory, and the `[archived]` tag says which ones cannot be read.
1156
+ const list = rooms.rooms;
1157
+ // Room name is OWNER-chosen — printed EXACTLY (src/names.ts), like every
1158
+ // other room-name surface as of 0.8.1: the sanitiser listed "проект" and
1159
+ // "план" as two "(unnamed room)" entries and quoted "Zoë" as "Zo".
1160
+ // address/role/scope are server-shaped and keep the defensive sanitiser.
675
1161
  const lines = list.map((r) => {
676
- const name = safeInline(r?.name) || "(unnamed room)";
1162
+ const name = roomNamePhrase(r?.name);
677
1163
  const roomId = safeInline(r?.room_id);
678
1164
  // Always surface the canonical address: fall back to xroom:<room_id> when the server
679
1165
  // omits `address`, so the domain guidance this tool promises is never silently dropped.
680
1166
  const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
681
1167
  const role = safeInline(r?.role);
682
1168
  const scope = safeInline(r?.scope);
683
- const archived = r?.archived ? " [archived]" : "";
684
- const use = address ? ` — use domain="${address}"` : "";
685
- return `- "${name}" (${role}${scope ? `, ${scope}` : ""})${archived}${use}`;
1169
+ // No "use domain=..." on an archived room: core refuses EVERY read of one
1170
+ // with a 403, for owner and member alike (see the archived-only note in
1171
+ // src/scope.ts), so that clause was an instruction to make a call that
1172
+ // cannot succeed. The address stays visible — it is the room's identity —
1173
+ // but the line says what a read against it will do.
1174
+ const tail = r?.archived
1175
+ ? address
1176
+ ? ` [archived] — address ${address}, but every read is refused while it is archived`
1177
+ : ` [archived] — every read is refused while it is archived`
1178
+ : address
1179
+ ? ` — use domain="${address}"`
1180
+ : "";
1181
+ return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
686
1182
  });
687
1183
  const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
688
1184
  return {
689
1185
  content: [
690
1186
  {
691
1187
  type: "text",
692
- text: capResult(text, "The room list was truncated — some rooms are not shown."),
1188
+ // Legend after the cap: this is the one room surface long enough to
1189
+ // actually overflow, and the legend must describe the names that
1190
+ // SURVIVED the cut, not the ones it removed.
1191
+ text: withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name)),
693
1192
  },
694
1193
  ],
695
1194
  };
@@ -707,7 +1206,15 @@ server.registerTool("vault_list", {
707
1206
  },
708
1207
  }, async () => {
709
1208
  const r = await apiFetch("/vault/secrets");
710
- const list = Array.isArray(r?.secrets) ? r.secrets : [];
1209
+ // A 200 missing the `secrets` array used to print "No secrets are stored
1210
+ // in your Vault yet." — an absence claim about the Vault derived from a
1211
+ // body this client could not read. The family fix (classifyRooms, the
1212
+ // domains guard) had stopped one tool short of here (truth F13,
1213
+ // 2026-08-08). A genuinely empty array keeps the absence claim below.
1214
+ const list = r?.secrets;
1215
+ if (!Array.isArray(list)) {
1216
+ return unreadableListReply("The secret list", "a list of your Vault secrets", "none are stored");
1217
+ }
711
1218
  if (list.length === 0) {
712
1219
  return {
713
1220
  content: [
@@ -735,12 +1242,47 @@ server.registerTool("vault_list", {
735
1242
  };
736
1243
  });
737
1244
  // --- Start ---
1245
+ /**
1246
+ * Opening the stdio transport at import time is what made every user-visible
1247
+ * sentence in this file untestable: a test that imports this module to reach a
1248
+ * handler instead starts a server on the test runner's stdin/stdout. So the copy
1249
+ * was guarded by regexes over this source text — and three review rounds each
1250
+ * found false sentences a behavioural test would have caught immediately, twice
1251
+ * in a guard that turned out to be theatre.
1252
+ *
1253
+ * The seam is one boolean, and its DEFAULT is today's behaviour.
1254
+ *
1255
+ * WHY AN ENV OPT-OUT AND NOT `import.meta.url === process.argv[1]`. That
1256
+ * comparison is the usual "am I the entry point?" idiom and it is the wrong tool
1257
+ * here, because this package ships as an npm `bin`. Under `npx` — the canonical
1258
+ * install, pinned in src/configs/source.json and in every README snippet — the
1259
+ * thing on argv[1] is the generated shim, not this file; on Windows it is a
1260
+ * `.cmd`/`.ps1` wrapper, and even the POSIX shim is a symlink whose realpath
1261
+ * resolution differs between package managers. Every one of those makes the
1262
+ * comparison FALSE for a real user, and a false comparison there does not throw:
1263
+ * the process would exit 0 having started no server, and the client would report
1264
+ * a silent connection failure with nothing in the logs. That is a worse defect
1265
+ * than the one being fixed. The env var inverts the risk — it can only misfire
1266
+ * for something that deliberately sets it, and no released version reads it, so
1267
+ * no existing config can carry it.
1268
+ *
1269
+ * The check is `=== "1"`, deliberately narrow rather than truthy: the failure
1270
+ * mode of a wide check is a startup that silently does nothing, so an
1271
+ * unrecognised value must fall through to starting the server.
1272
+ *
1273
+ * BYTE-IDENTICAL CLI BEHAVIOUR: with the variable unset (or set to anything
1274
+ * other than "1"), `main()` is called exactly as before, with the same catch,
1275
+ * the same message and the same exit code. The only other change to this module
1276
+ * is the `export` on `server`, which is inert when the file is run as a program.
1277
+ */
738
1278
  async function main() {
739
1279
  const transport = new StdioServerTransport();
740
1280
  await server.connect(transport);
741
1281
  }
742
- main().catch((err) => {
743
- console.error("MCP server error:", err);
744
- process.exit(1);
745
- });
1282
+ if (process.env.MNEMOVERSE_MCP_NO_AUTOSTART !== "1") {
1283
+ main().catch((err) => {
1284
+ console.error("MCP server error:", err);
1285
+ process.exit(1);
1286
+ });
1287
+ }
746
1288
  //# sourceMappingURL=index.js.map