@mnemoverse/mcp-memory-server 0.10.1 → 0.11.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
@@ -2,62 +2,16 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { z } from "zod";
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.
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 { exactLiteral, formatDomainList, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
5
+ import { SERVER_INSTRUCTIONS } from "./teaching.js";
6
+ import { refusePlaceholderKey } from "./requests.js";
7
+ import { registerMemoryTools } from "./tools.js";
8
+ import { registerMemoryPrompts } from "./prompts.js";
9
+ import { registerMemoryResources } from "./resources.js";
15
10
  // Every non-2xx becomes an instruction to the calling model instead of a raw
16
11
  // wire echo — see the header of src/errors.ts for why, and for what each status
17
12
  // actually means in this engine. So does a 2xx whose body this client cannot
18
13
  // parse, which is a third case and not a variant of either of the other two.
19
14
  import { ApiError, NetworkError, UnreadableBodyError } from "./errors.js";
20
- /**
21
- * What we know about the scope this read actually covered — a VALUE rather
22
- * than a sentence-or-empty-string. Costs one GET (rooms or stats, chosen by
23
- * the scope) and runs on zero-result paths only — but it is not that path's
24
- * only probe: the plain unscoped empty read also hands buildReadEmptyResponse
25
- * a stats call for the first-contact greeting, so that answer makes two
26
- * probes where 0.8.0 made one, and the scoped/filtered answers make one where
27
- * 0.8.0 made none. Disclosed in the 0.8.1 CHANGELOG entry.
28
- *
29
- * This replaces `domainMissNote`, whose `catch { return ""; }` made three
30
- * different states share one spelling: the store is there and the query missed,
31
- * the room is there and the query missed, and the probe failed. The caller then
32
- * read that string as a boolean, so "we could not check" was rendered exactly
33
- * like "the store exists" — the collision this release is about, at the point
34
- * where the whole scoped path converges. The states are now arms of a union
35
- * (src/scope.ts) and every consumer must answer for each of them.
36
- *
37
- * The probe routing lives in src/scope.ts: a room address is checked against the
38
- * ROOM list and a domain against `/memory/stats`, because stats never contains a
39
- * room address (CodeRabbit #65). `searched` is the RAW value that went over the
40
- * wire, so the diagnosis can never describe a different store than the request.
41
- */
42
- /**
43
- * How long a HONESTY PROBE may take before the answer goes out without it (#73).
44
- *
45
- * The probes are a courtesy: they let a zero-result answer say what it did NOT
46
- * cover. They are not the answer. Without a deadline they inherit undici's
47
- * default (~300s), so one slow engine endpoint turns an empty read — which
48
- * 0.8.0 answered instantly, having probed nothing — into a multi-minute stall.
49
- * `probeReadScope` already treats a failed probe as "unknown" and says so, so
50
- * an abort degrades into the honest fallback rather than an error.
51
- *
52
- * 4s is chosen against measured production latency: `/memory/read` averaged
53
- * 1,330 ms with a 10.2 s maximum (core observability, 2026-08), and these two
54
- * are cheaper GETs. Long enough not to fire on a normal slow day, short enough
55
- * that a hung endpoint costs seconds instead of minutes.
56
- */
57
- const PROBE_TIMEOUT_MS = 4000;
58
- function probeScope(searched) {
59
- return probeReadScope(searched, () => apiFetch("/memory/stats", { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }), () => apiFetch("/memory/rooms", { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }), safeInline);
60
- }
61
15
  // Version is read at runtime from package.json so there is exactly one place
62
16
  // to bump on each release. Works both from `dist/` during local dev and from
63
17
  // `node_modules/@mnemoverse/mcp-memory-server/dist/` after an npm install.
@@ -68,10 +22,6 @@ const pkg = require("../package.json");
68
22
  const DEFAULT_API_URL = "https://core.mnemoverse.com/api/v1";
69
23
  const API_URL = process.env.MNEMOVERSE_API_URL || DEFAULT_API_URL;
70
24
  const API_KEY = process.env.MNEMOVERSE_API_KEY || "";
71
- // Hard cap on tool result size — required by Claude Connectors Directory
72
- // (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
73
- // Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
74
- const MAX_RESULT_CHARS = 24_000 * 4;
75
25
  /**
76
26
  * Is `MNEMOVERSE_API_URL` an address this client may attach the API key to —
77
27
  * and if not, what does the user have to change (#99, CWE-319)?
@@ -178,6 +128,16 @@ function refuseInsecureBaseUrl(raw) {
178
128
  /** The verdict on this process's base URL, computed once. `undefined` means the
179
129
  * key may go out; anything else is what each surface says instead. */
180
130
  const BASE_URL_REFUSAL = refuseInsecureBaseUrl(API_URL);
131
+ /**
132
+ * The verdict on this process's API key, computed once next to
133
+ * BASE_URL_REFUSAL because both are config-only guards checked at the same
134
+ * two call sites below (apiFetch, the startup probe) before anything is
135
+ * sent. `undefined` means the key is not certainly a docs placeholder; it
136
+ * may still be wrong, but that is the engine's 401 to diagnose, not a guess
137
+ * this client can make from shape alone. See `refusePlaceholderKey` in
138
+ * src/requests.ts for exactly what counts as "certainly".
139
+ */
140
+ const PLACEHOLDER_KEY_REFUSAL = refusePlaceholderKey(API_KEY);
181
141
  /**
182
142
  * Fetch from the Mnemoverse core API with authentication.
183
143
  *
@@ -207,6 +167,15 @@ async function apiFetch(path, options = {}) {
207
167
  "and starts with mk_live_. Do not retry until it is set; every memory " +
208
168
  "tool will fail the same way until then.");
209
169
  }
170
+ if (PLACEHOLDER_KEY_REFUSAL !== undefined) {
171
+ // After the empty-key check, for the same reason that one runs first:
172
+ // "set the variable" and "replace the placeholder" are different fixes,
173
+ // and a key that is present but certainly a docs example gets its own
174
+ // sentence rather than the emptiness one. Decided from CONFIGURATION
175
+ // alone, exactly like BASE_URL_REFUSAL right below, so it belongs here
176
+ // and not in a diagnosis of a response this call never sends.
177
+ throw new Error(PLACEHOLDER_KEY_REFUSAL.toolCall);
178
+ }
210
179
  if (BASE_URL_REFUSAL !== undefined) {
211
180
  // Alongside the key check, and after it: with no key configured there is
212
181
  // nothing to expose, and "set the variable" is the more useful first thing
@@ -311,101 +280,6 @@ async function apiFetch(path, options = {}) {
311
280
  });
312
281
  }
313
282
  }
314
- /**
315
- * Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
316
- * Required by Claude Connectors Directory submission policy.
317
- *
318
- * Defensive against splitting UTF-16 surrogate pairs: if the character right
319
- * before the cut point is a high surrogate (U+D800–U+DBFF), drop it so the
320
- * result stays well-formed. Otherwise an emoji or non-BMP character at the
321
- * boundary can produce a lone surrogate and corrupt downstream JSON encoding.
322
- */
323
- function capResult(text,
324
- // The default recommends ONLY the control that works. A previous draft also
325
- // said "or smaller top_k" — but top_k is not a hard cap (association
326
- // expansion can return more, the relevance floor fewer; the same query at
327
- // 1/5/20 returned 6/7/4 items), which this release's own top_k description
328
- // already admits. One surface must not recommend the knob another refutes.
329
- moreHint = "Use a more specific query to see all results.") {
330
- if (text.length <= MAX_RESULT_CHARS)
331
- return text;
332
- let truncated = text.slice(0, MAX_RESULT_CHARS - 200);
333
- const lastCode = truncated.charCodeAt(truncated.length - 1);
334
- if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
335
- truncated = truncated.slice(0, -1);
336
- }
337
- // `moreHint` lets no-input tools (the discovery lists) give accurate truncation
338
- // guidance instead of the read-tool default (which points at a query control a
339
- // repeated no-arg call cannot use). Existing callers keep the default message.
340
- return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
341
- }
342
- /**
343
- * A 2xx whose body does not carry what core always sends for this operation.
344
- *
345
- * Reading such a body as an EMPTY list is the substitution this release exists
346
- * to remove: `Array.isArray(x) ? x : []` turned "this client could not read
347
- * the response" into "there is nothing there" — an absence claim on zero
348
- * evidence. `classifyRooms` (src/scope.ts) and the stats domains guard closed
349
- * it for rooms and domains; this builder is the same answer for the remaining
350
- * list surfaces (search results, the feed, the vault), phrased the way the
351
- * room list already phrases it. The three parts are the subject, what the body
352
- * is NOT ("a list of your rooms"), and the absence it is NOT evidence of
353
- * ("you have none") — so every consumer states its own boundary while the
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.
366
- */
367
- function unreadableAnswerReply(subject, notA, absence, extra = "") {
368
- return {
369
- content: [
370
- {
371
- type: "text",
372
- // The tail attributes the unreadable 200 to "whatever answered this
373
- // call", not to "the memory service" — this client cannot establish
374
- // WHO answered: a gateway, a proxy, or the endpoint a mis-set
375
- // MNEMOVERSE_API_URL points at produces the same 200 with an
376
- // unrecognised body (truth re-verification, 2026-08-09). Pinned in
377
- // test/handlers.test.ts.
378
- text: `${subject} came back in a shape this client does not recognise — so this ` +
379
- `is not ${notA}, and it is not evidence that ${absence}.${extra} Retry; ` +
380
- `if it persists, whatever answered this call — the memory service, a ` +
381
- `gateway or proxy in front of it, or the endpoint a mis-set ` +
382
- `MNEMOVERSE_API_URL points at — is answering in a shape this client ` +
383
- `cannot read.`,
384
- },
385
- ],
386
- };
387
- }
388
- /**
389
- * Two renderers, and which one a value gets is a decision, not a style choice.
390
- *
391
- * `safeInline` (src/render.ts) SANITISES an untrusted display string for inline
392
- * rendering in tool output that a DIFFERENT principal's LLM will read (CN-032
393
- * anti-injection): strip to a conservative charset, collapse whitespace, cap
394
- * the length. The treatment `formatAuthorTag` applies to a server-stamped
395
- * author, and the defensive second pass the machine-shaped room fields
396
- * (address, room_id, role, scope — all charset-validated or enum-shaped in
397
- * core) get here. It is lossy on purpose, and everything it still renders is a
398
- * value the reader looks at and never has to retype or compare.
399
- *
400
- * `exactLiteral` / `domainPhrase` / `roomNamePhrase` (src/names.ts) print a
401
- * value as a JSON string literal, or refuse to print it. Domain names because
402
- * the engine matches them byte-for-byte, so the reader must be able to send
403
- * the exact bytes back. Room NAMES (0.8.1) because the sanitiser did not make
404
- * them harmless so much as it made them WRONG: "проект" echoed back as `""` on
405
- * create, "Zoë" as "Zo" inside quotes that claim to be the name. The literal
406
- * is one line with quotes, backslashes and invisibles escaped, so it is as
407
- * injection-safe as the sanitised spelling was — without the renaming.
408
- */
409
283
  // --- Server setup ---
410
284
  // The second argument lands verbatim in the connected model's system prompt on
411
285
  // clients that surface MCP instructions — it is the single highest-leverage
@@ -420,1330 +294,14 @@ export const server = new McpServer({
420
294
  name: "mnemoverse-memory",
421
295
  version: pkg.version,
422
296
  }, { instructions: SERVER_INSTRUCTIONS });
423
- /**
424
- * True for a shared-room address (`xroom:<room_id>`).
425
- *
426
- * Mirrors core's own predicate (`memory_engine._is_room_domain`), which is a
427
- * plain prefix test — deliberately, so a padded or otherwise non-canonical
428
- * address does NOT normalise into a room. Byte-for-byte, no trimming: this
429
- * file's write handler documents at length why trimming a domain here once
430
- * nearly wrote into a room visible to other accounts.
431
- *
432
- * Used only to choose which TRUE sentence to print about a refusal. It never
433
- * changes what is sent, so a wrong answer here misinforms and cannot misroute.
434
- */
435
- function isRoomDomain(domain) {
436
- return typeof domain === "string" && domain.startsWith("xroom:");
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
- }
461
- // --- Tool: memory_write ---
462
- server.registerTool("memory_write", {
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.",
464
- inputSchema: {
465
- content: z
466
- .string()
467
- .min(1)
468
- .max(10000)
469
- .describe("The memory to store as a self-contained statement, e.g. 'User prefers TypeScript strict mode' or 'Decided to deploy the API on Cloudflare Workers (2026-06)'."),
470
- concepts: z
471
- .array(z.string())
472
- .optional()
473
- .describe("Key concepts for linking related memories (e.g. ['deploy', 'friday', 'staging'])"),
474
- domain: z
475
- .string()
476
- .optional()
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."),
483
- },
484
- annotations: {
485
- title: "Store Memory",
486
- readOnlyHint: false,
487
- destructiveHint: false,
488
- idempotentHint: false,
489
- openWorldHint: true,
490
- },
491
- }, async ({ content, concepts, domain }) => {
492
- // NO NORMALISATION HERE — deliberately, after a review found two ways it
493
- // breaks (2026-08-08). Trimming looked like an obvious win: domain names
494
- // are matched byte-for-byte in core, so " engineering" opens a permanent
495
- // second store beside "engineering". But:
496
- //
497
- // 1. Core REJECTS a non-canonical room address on purpose — 400
498
- // "Non-canonical room address", so a write "can't be mis-routed and
499
- // tagged with a spoofed xroom domain" in its own words. Trimming
500
- // " xroom:room_01ABC" normalises past that guard, and the atom lands
501
- // in the ROOM's store, visible to every member. Content that never
502
- // left the caller in 0.8.0 would leave the account in 0.8.1. The
503
- // address in that shape is one our own output hands the model.
504
- // 2. For a caller who has been padding a domain for months, trimming
505
- // silently relocates new writes and orphans the old corpus, and (at
506
- // the time this was written) memory_delete_domain did NOT trim, so
507
- // the same client could no longer even name the shard it created —
508
- // that tool was withdrawn to an administrative REST-only operation
509
- // on 2026-08-20, so this specific consequence no longer applies, but
510
- // the orphaned-corpus risk from silently relocating writes does.
511
- //
512
- // Both are behaviour changes, so they do not belong in a patch whose
513
- // whole claim is that it only changes wording. Normalisation, if it ever
514
- // returns, needs room addresses deliberately EXEMPT and zero-width
515
- // characters handled (JS trim() does not strip them — verified, contrary
516
- // to what an earlier comment here asserted).
517
- const r = await apiFetch("/memory/write", {
518
- method: "POST",
519
- body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
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
- }
544
- // "unknown", not 0.00, when the server didn't send a score — the same rule
545
- // memory_stats got in this release. A live surface exists that answers
546
- // {"stored":false} with no reason and no score; printing "0.00" there
547
- // fabricates the gate's verdict (review, 2026-08-08).
548
- const importance = typeof r?.importance === "number" ? r.importance.toFixed(2) : "unknown";
549
- // `Server reason:` is a VERBATIM label, and safeInline made it a lie on
550
- // every occurrence: core's only rejection reason is
551
- // "Below importance threshold (0.412 < 0.500)", whose parentheses and `<`
552
- // are outside the sanitiser's charset — so the relay read "Below importance
553
- // threshold 0.412 0.500", with the comparison operator and both delimiters
554
- // deleted and the two numbers left unlabelled and order-only. Quoted
555
- // exactly instead (src/names.ts). If it will not fit, say THAT rather than
556
- // drop the clause: omitting it would report a server that gave no reason.
557
- const reasonQuote = r?.reason
558
- ? (exactLiteral(r.reason, 400)?.literal ?? "(too long to quote exactly)")
559
- : "";
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) {
563
- return {
564
- content: [
565
- {
566
- type: "text",
567
- text: `Stored (importance: ${importance}). ID: ${r.atom_id ?? "unknown"}`,
568
- },
569
- ],
570
- };
571
- }
572
- // NOT STORED. The old wording ("Filtered — …") named the mechanism but
573
- // never the outcome, so a caller could read it as a soft success and move
574
- // on. In dogfooding this ate a CORRECTION to a wrong fact: the stale
575
- // version stayed as the only record, and looked more authoritative for
576
- // having no competitor (2026-08-07).
577
- //
578
- // The ADVICE was wrong until 2026-08-08, and wrong in a way that made
579
- // things worse. It told the caller to rewrite the content as a cleaner
580
- // factual statement — but the gate scores GEOMETRIC NOVELTY against the
581
- // nearest existing atom in the same domain, not phrasing or factuality.
582
- // "Below importance threshold" means TOO SIMILAR TO SOMETHING ALREADY
583
- // STORED. A rewrite of the same fact therefore produces a near-identical
584
- // embedding, scores the same or lower, and is rejected again — and the old
585
- // text ended with "write it again", so a compliant agent looped. It was
586
- // anti-correlated with the mechanism in exactly the case it was written
587
- // for: a correction, which is by nature similar to what it corrects.
588
- return {
589
- content: [
590
- {
591
- type: "text",
592
- // WHAT IS CONDITIONAL HERE, stated exactly, because a previous
593
- // version of this comment claimed more than the code does.
594
- //
595
- // Conditional: the SERVER'S VERDICT and the SCORE. `Server reason:`
596
- // is printed only when `reason` came back, and `Novelty score` only
597
- // when a numeric `importance` did — a live surface answers
598
- // `{"stored":false}` with neither, and quoting a verdict nobody sent
599
- // would be a claim on zero evidence.
600
- //
601
- // Unconditional: the MECHANISM sentence below, and it is not derived
602
- // from this response. It is a statement about core: `/memory/write`
603
- // refuses for exactly one reason — the importance gate, scoring
604
- // geometric novelty against the nearest existing atom in the same
605
- // domain, with "Below importance threshold (x < y)" as its only text
606
- // (two branches in memory_engine, one reason). The BATCH endpoint has
607
- // other failure paths; this client does not call it. So for any
608
- // rejection this client can receive, that sentence is true whether or
609
- // not the server bothered to say why. The earlier comment here read
610
- // "the cause is named only when the server named it", which describes
611
- // a draft that did not carry this sentence at all.
612
- //
613
- // NOT PRESENT AT ALL: a prediction about the retry. "Rewording will
614
- // score the same or lower" was asserted as fact and is probably
615
- // BACKWARDS — novelty decreases with similarity to the blocking
616
- // memory, so a reworded sentence is usually LESS similar and scores
617
- // HIGHER. And delete-then-write advice an earlier draft carried was
618
- // impossible for a room write even when memory_delete still existed:
619
- // the blocker is a room atom, which that tool could not touch either
620
- // (reviews, 2026-08-08). Deletion is administrative-only now
621
- // (2026-08-20), so this message never suggests it at all.
622
- text: `NOT STORED — nothing was saved.` +
623
- (reasonQuote ? ` Server reason: ${reasonQuote}.` : ``) +
624
- (importance === "unknown"
625
- ? ``
626
- : ` Novelty score ${importance}. That score is a first-generation` +
627
- ` metric under active development and known to be unreliable —` +
628
- ` identical content has measured ~0.08 in Russian against ~0.55 in` +
629
- ` English — so read it as a rough hint about similarity, not as a` +
630
- ` judgement of whether this memory was worth keeping.`) +
631
- (isRoomDomain(domain)
632
- ? // ROOM RULE (core#482, 2026-08-13). A room is a message bus:
633
- // the second agent's job is to receive a restatement of what
634
- // the first was told, so a briefing or a status summary STORES
635
- // here. Only a write the embedder cannot distinguish from one
636
- // already present is refused. Telling a caller to "write the
637
- // delta" in a room would be advice against the room's purpose.
638
- ` Restatements are allowed in rooms — this one was refused only` +
639
- ` because it is indistinguishable by embedding from a message` +
640
- ` already there. Similarity is judged on roughly the first 500` +
641
- ` tokens, so a long message that OPENS like an earlier one can` +
642
- ` land here even when its body differs: lead with what is new.`
643
- : ` Writes are gated on how much a memory adds over what is already in the` +
644
- ` same domain, so a near-duplicate is refused. If the point is genuinely` +
645
- ` new, write what is DIFFERENT rather than restating the whole fact.`),
646
- },
647
- ],
648
- };
649
- });
650
- // --- Tool: memory_read ---
651
- server.registerTool("memory_read", {
652
- description: "Search your long-term memory before answering anything that may have come up before — user preferences, past decisions, project setup, people, or earlier context. This memory is shared: it persists across sessions and across every AI tool the user has connected (Claude, ChatGPT, Cursor, VS Code). ALWAYS check here first when you're unsure whether you already know something; no need to call it for general world knowledge you already hold. Returns matches ranked by relevance (or newest-first with order_by: 'recency'); each result carries an id you can pass to memory_feedback. A wrong or stale memory is corrected by writing a fresh one with memory_write, not by deleting it.",
653
- inputSchema: {
654
- query: z
655
- .string()
656
- .min(1)
657
- .max(5000)
658
- .describe("Natural-language description of what you're looking for, e.g. 'database choice for the API' or 'user's preferred testing framework'."),
659
- top_k: z
660
- .number()
661
- .int()
662
- .min(1)
663
- .max(50)
664
- .optional()
665
- .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."),
666
- domain: z
667
- .string()
668
- .optional()
669
- .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."),
670
- order_by: z
671
- .enum(["relevance", "recency"])
672
- .optional()
673
- .describe("'relevance' (default) = ranking order. 'recency' = the matched " +
674
- "set re-sorted newest-first. For a complete newest-first feed " +
675
- "with no search at all, use memory_list_recent instead."),
676
- since: z
677
- .string()
678
- .max(40)
679
- .optional()
680
- .describe("Only memories created at/after this ISO-8601 instant (naive = " +
681
- "UTC) — e.g. your last-seen watermark in a shared room."),
682
- until: z
683
- .string()
684
- .max(40)
685
- .optional()
686
- .describe("Only memories created at/before this ISO-8601 instant."),
687
- exclude_author: z
688
- .string()
689
- .max(200)
690
- .optional()
691
- .describe("Drop memories written by this author PRINCIPAL — the server-side " +
692
- "identity. ⚠️ NOT USABLE FROM HERE YET: the principal is not shown " +
693
- "in these results, so there is no value you can obtain through " +
694
- "this tool, and a guess like 'me' silently matches nothing and " +
695
- "filters nothing. Only pass it if your system knows the exact " +
696
- "principal from elsewhere (e.g. the REST API). A self-exclusion " +
697
- "shortcut is planned."),
698
- },
699
- annotations: {
700
- title: "Search Memories",
701
- readOnlyHint: true,
702
- destructiveHint: false,
703
- idempotentHint: true,
704
- openWorldHint: true,
705
- },
706
- }, async ({ query, top_k, domain, order_by, since, until, exclude_author }) => {
707
- // ONE value, used for the request AND for every decision about it.
708
- //
709
- // A previous draft sent the raw string but decided the wording from a
710
- // TRIMMED copy, and the two disagreed in the release's own founding case:
711
- // a read on " engineering" searched the padded store (correct) while the
712
- // diagnosis checked "engineering", found it, and stayed silent — so the
713
- // one note that would have said "a stray space makes a different store"
714
- // was suppressed exactly when a stray space had made a different store.
715
- // For a whitespace-only domain it went the other way and claimed the
716
- // search had covered the caller's own domains when it had covered none
717
- // (reviews, 2026-08-08).
718
- //
719
- // `|| undefined` is what 0.8.0 sent and must stay: core filters on
720
- // `domain is not None`, not on truthiness, so passing "" through would
721
- // become `WHERE domain = ''` — a store that cannot exist — turning a
722
- // search of every domain into a guaranteed miss. That is data movement,
723
- // not wording, and it does not belong in a patch.
724
- const searched = searchedScope(domain);
725
- const r = await apiFetch("/memory/read", {
726
- method: "POST",
727
- body: JSON.stringify(readRequestBody({ query, top_k, domain, order_by, since, until, exclude_author })),
728
- });
729
- // `items` must be a REAL array before anything below may speak. Core's
730
- // read response always carries one on a 200, so a body without it is not
731
- // core's answer — and the old `Array.isArray(r?.items) ? r.items : []`
732
- // fed exactly that body to the entire zero-result machinery: head
733
- // sentence, scope probe, diagnosis. An absence claim derived from a body
734
- // this client could not read — the substitution classifyRooms removed for
735
- // rooms, one level up from the probes (truth F13, 2026-08-08).
736
- const items = r?.items;
737
- if (!Array.isArray(items)) {
738
- return unreadableAnswerReply("The search result", "a list of matches", "nothing matched");
739
- }
740
- if (items.length === 0 && (since || until || exclude_author)) {
741
- // A bounded/filtered read that finds nothing is NOT a bad query —
742
- // the truthful answer is "nothing new for these filters" (the feed's
743
- // filtered head uses the same sentence; no stats probe, no broaden
744
- // hint). Unscoped, it also has to name the rooms it never looked in.
745
- const scopeNote = readScopeNote(await probeScope(searched));
746
- return {
747
- content: [
748
- {
749
- type: "text",
750
- // The scope is IN the sentence, not appended after it. This
751
- // branch was already the honest one in 0.8.0 — it names its
752
- // filters — and it is the model the feed's copy now follows.
753
- //
754
- // The legend wraps the WHOLE message and is added at most once:
755
- // `scopeNote` may itself have named a store (a case-twin), and one
756
- // explanation of the escaping per answer is the point of it.
757
- text: withDomainEscapeLegend(`Nothing in ${scopeLabel(searched)} matches within the given time/author filters.` +
758
- futureSinceNote(since, Date.now()) +
759
- scopeNote, searched),
760
- },
761
- ],
762
- };
763
- }
764
- if (items.length === 0) {
765
- // Zero results. The scope is probed FIRST and the whole answer is then
766
- // assembled from that ONE value in src/teaching.ts — head sentence and
767
- // disclosure together, per state.
768
- //
769
- // ORDER MATTERS and is now structural: whether the caller has rooms we
770
- // could not search decides whether the first-contact greeting is even
771
- // true, so the room knowledge is an INPUT to the answer rather than a flag
772
- // consulted beside it. `total_atoms` counts the personal org only, so a
773
- // joiner with three full rooms and no personal writes would otherwise be
774
- // told "nothing has been saved yet" and contradicted by the note right
775
- // underneath (review, 2026-08-08).
776
- //
777
- // Nothing is appended here any more. While the head came from teaching.ts
778
- // and the tail was concatenated at this line, the two could disagree — a
779
- // head promising "that is not the whole picture:" with an empty tail after
780
- // it was a live bug for an account whose only room was archived.
781
- const scope = await probeScope(searched);
782
- const text = await buildReadEmptyResponse(
783
- // THE THIRD PROBE, and the one the first pass at #73 missed. An
784
- // UNSCOPED empty read takes this path: probeScope only fetches the
785
- // room list (scope.ts: `if (!searched) return { kind: "own-domains" }`),
786
- // and the stats call for the first-contact greeting is issued here.
787
- // Deadlining only the two inside probeScope left the commonest empty
788
- // read of all still able to hang for undici's ~300s default — the exact
789
- // symptom #73 names.
790
- () => apiFetch("/memory/stats", {
791
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
792
- }), scope);
793
- return {
794
- content: [
795
- {
796
- type: "text",
797
- // NO legend wrapper here, on purpose — `withDomainEscapeLegend(
798
- // text, searched)` stood on this line and was dead code that
799
- // looked load-bearing (tests-lens F9, 2026-08-08): every arm of
800
- // buildReadEmptyResponse either names no store at all (fixed
801
- // sentences), or names it inside a note that appends its own
802
- // legend (the case-twin diagnosis, src/scope.ts) — and the
803
- // unscoped path passes `searched === undefined`, which can never
804
- // need one. So there was no input on which the wrapper fired.
805
- // Pinned by "the plain-empty read is legended by its notes" in
806
- // test/handlers.test.ts.
807
- text,
808
- },
809
- ],
810
- };
811
- }
812
- // Rendering lives in src/render.ts (testable): each line carries the
813
- // CN-001 author tag, the created_at date (#404 R1 — a reader cannot
814
- // reason about recency it cannot see) and the full atom id (the tool
815
- // description always promised ids for memory_feedback/memory_delete;
816
- // the old render never delivered them, making both uncallable from
817
- // read results).
818
- const lines = items.map((item, i) => formatReadItem(item, i));
819
- // `?? 0` prints a FABRICATED `(0ms)` when the server sent no timing — the
820
- // same class as "Associations: 0" for an unknown count, which memory_stats
821
- // fixed in this release. Untouched here and recorded in CHANGELOG's "Known
822
- // and NOT fixed here" with the other three.
823
- const searchMs = (r?.search_time_ms ?? 0).toFixed(0);
824
- const text = lines.join("\n\n") + `\n\n(${searchMs}ms)`;
825
- return {
826
- content: [
827
- {
828
- type: "text",
829
- // Each line's `@"domain"` tag is an exact literal; the legend that
830
- // explains an escape belongs to the answer, not to twenty tags.
831
- //
832
- // Legend AFTER the cap, never before. capResult truncates from the
833
- // END, and the legend is appended at the end — so applied first it
834
- // was the first thing the cap ate, on exactly the pages long enough
835
- // to need both: a hundred escaped tags left with nothing saying that
836
- // \u00a0 is ONE character, not six (truth F6, 2026-08-08). Applied
837
- // to the CAPPED text the legend survives; and since it fires only
838
- // when an escaped literal is still on the page, a cap that removed
839
- // every escaped name drops the legend with it.
840
- text: withDomainEscapeLegend(capResult(text), ...items.map((it) => it?.domain)),
841
- },
842
- ],
843
- };
844
- });
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.)";
905
- server.registerTool("memory_list_recent", {
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.",
907
- inputSchema: {
908
- domain: z
909
- .string()
910
- .optional()
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`)."),
912
- since: z
913
- .string()
914
- .optional()
915
- .describe("Only entries created at/after this ISO-8601 instant (naive = UTC) — your novelty watermark."),
916
- until: z
917
- .string()
918
- .optional()
919
- .describe("Only entries created at/before this ISO-8601 instant (inclusive). Pair with `since` to read a closed window — 'what happened on Monday' — instead of paging back from now."),
920
- exclude_author: z
921
- .string()
922
- .max(200)
923
- .optional()
924
- .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."),
925
- limit: z
926
- .number()
927
- .int()
928
- .min(1)
929
- .max(100)
930
- .optional()
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."),
932
- cursor: z
933
- .string()
934
- .max(512)
935
- .optional()
936
- .describe("Opaque cursor from a previous page's 'More older entries exist' line — continues the listing without skips or duplicates."),
937
- },
938
- annotations: {
939
- title: "List Recent Memories",
940
- readOnlyHint: true,
941
- destructiveHint: false,
942
- idempotentHint: true,
943
- openWorldHint: true,
944
- },
945
- }, async ({ domain, since, until, exclude_author, limit, cursor }) => {
946
- // ONE value for the request and for every decision about it — see the note
947
- // at the top of memory_read.
948
- const searched = searchedScope(domain);
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.
1066
- //
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;
1088
- }
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;
1092
- }
1093
- const items = accepted;
1094
- if (items.length === 0) {
1095
- // THE SENTENCE ITSELF carries the scope — and it is selected by EVERY
1096
- // filter that narrowed the window, not by `since` alone.
1097
- //
1098
- // Two prior shapes of this branch were wrong the same way. In 0.8.0 it
1099
- // printed "Nothing new since your watermark." with no scope in it — the
1100
- // sentence src/scope.ts was written to eliminate. The first fix put the
1101
- // scope into the clause but kept choosing BETWEEN the two heads by
1102
- // looking at `since` only, so {until} alone and {exclude_author} alone
1103
- // fell to "No memories in ${where} yet." — an emptiness claim about a
1104
- // store holding a thousand atoms outside the window — and {since, until}
1105
- // kept the watermark phrasing, which pretends the read reached the
1106
- // present when `until` stopped it years short (review, 2026-08-08).
1107
- //
1108
- // Four heads, one rule: the emptiness claim ("yet") is allowed only
1109
- // when NOTHING narrowed the window; the watermark phrasing only when
1110
- // `since` was the whole narrowing; any other filter combination gets the
1111
- // sentence memory_read's filtered branch uses — the filters are named as
1112
- // what bounded the result, and nothing is claimed beyond them.
1113
- //
1114
- // A `cursor` outranks all three. It is not a filter over the store — it
1115
- // is a POSITION in a listing whose earlier pages the caller has already
1116
- // read, so every other head is false here: "No memories in ${where}
1117
- // yet." is an absence claim about a store the caller has just SEEN
1118
- // entries from, and the watermark phrasing pretends a continuation that
1119
- // stopped mid-listing was a clean catch-up. The engine hands out a
1120
- // cursor only when more entries existed at that moment, so an empty
1121
- // continued page means the listing moved under the caller — entries
1122
- // removed between page fetches — and the head speaks about the
1123
- // continuation only, never about what the store holds (follow-up to the
1124
- // head-selection fix, 2026-08-08). Decided by the same truthiness the
1125
- // request used: an empty-string cursor never reaches the wire
1126
- // (src/requests.ts), so it may not pick the sentence either.
1127
- const scopeNote = readScopeNote(await probeScope(searched));
1128
- const where = scopeLabel(searched);
1129
- const head = cursor
1130
- ? `Nothing further in ${where} past this cursor — entries may have been ` +
1131
- `removed since the previous page was fetched.` +
1132
- (since || until || exclude_author
1133
- ? ` The given time/author filters still bounded this page.`
1134
- : ``)
1135
- : since && !until && !exclude_author
1136
- ? `Nothing new in ${where} since your watermark.`
1137
- : since || until || exclude_author
1138
- ? `Nothing in ${where} matches within the given time/author filters.`
1139
- : `No memories in ${where} yet.`;
1140
- return {
1141
- content: [
1142
- {
1143
- type: "text",
1144
- text: withDomainEscapeLegend(head + futureSinceNote(since, Date.now()) + scopeNote, searched),
1145
- },
1146
- ],
1147
- };
1148
- }
1149
- return {
1150
- content: [
1151
- {
1152
- type: "text",
1153
- // The page body comes from src/render.ts; the escape legend is
1154
- // applied HERE, to the CAPPED text. formatRecentPage used to append
1155
- // it itself, which put it before capResult — and capResult truncates
1156
- // from the end, so the one sentence explaining the escapes was the
1157
- // first casualty on every page long enough to be capped (truth F6,
1158
- // 2026-08-08). Same order as memory_read's result page, same
1159
- // automatic drop: a cap that removed every escaped name removes the
1160
- // reason for the legend too.
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)),
1171
- },
1172
- ],
1173
- };
1174
- });
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.";
1192
- server.registerTool("memory_feedback", {
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.",
1200
- inputSchema: {
1201
- atom_ids: z
1202
- .array(z.string())
1203
- .min(1)
1204
- .describe("IDs of memories to give feedback on (from memory_read results)"),
1205
- outcome: z
1206
- .number()
1207
- .min(-1)
1208
- .max(1)
1209
- .describe("How helpful was this? 1.0 = very helpful, 0 = neutral, -1.0 = harmful/wrong"),
1210
- },
1211
- annotations: {
1212
- title: "Rate Memory Helpfulness",
1213
- readOnlyHint: false,
1214
- // Feedback permanently mutates the memory's valence and importance
1215
- // scores on the backend — per MCP spec, that is a destructive update
1216
- // to the stored state (cf. ToolAnnotations.destructiveHint), even
1217
- // though the caller intends it as quality signal rather than delete.
1218
- destructiveHint: true,
1219
- idempotentHint: false,
1220
- openWorldHint: true,
1221
- },
1222
- }, async ({ atom_ids, outcome }) => {
1223
- const r = await apiFetch("/memory/feedback", {
1224
- method: "POST",
1225
- body: JSON.stringify({ atom_ids, outcome }),
1226
- });
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
- }
1288
- // "Feedback recorded for 0 memories." is one character away from the
1289
- // success line and reads like one. But the DIAGNOSIS matters as much as
1290
- // the fact: an earlier version of this branch blamed deletion, which is
1291
- // usually the wrong cause (review, 2026-08-08).
1292
- //
1293
- // Core resolves the feedback org from a `domain` argument that defaults to
1294
- // "general" — and THIS TOOL EXPOSES NO domain PARAMETER. So the ordinary
1295
- // way to get zero is to rate atoms that live somewhere else: read a room,
1296
- // take the ids off the `id:` lines, rate them, and every one silently
1297
- // misses. The atoms exist, the ids are valid, and telling the caller they
1298
- // were deleted sends them to look for a problem that isn't there.
1299
- if (count === 0) {
1300
- return {
1301
- content: [
1302
- {
1303
- type: "text",
1304
- text: "No feedback was recorded — none of those ids matched a memory in your own " +
1305
- `domains. ${FEEDBACK_MISS_CAUSES}`,
1306
- },
1307
- ],
1308
- };
1309
- }
1310
- // WHOSE NUMBER THIS IS (#68). `updated_count` is the count of memories the
1311
- // service says it touched — and that is true only while core runs
1312
- // `feedback_async = False`. Under async it returns the number of ids
1313
- // SUBMITTED, not applied (core schemas.py:826-838, memory_engine.py:4898-4902),
1314
- // and nothing in the response says which mode ran. A server-side config
1315
- // flip would therefore turn a confident sentence here false on every
1316
- // installed client, silently. So the number is reported as the SERVICE'S
1317
- // report rather than asserted as an outcome this client verified. The
1318
- // hosted connector took the same correction on 2026-08-13
1319
- // (mnemoverse-mcp-remote#38); one number, one degree of confidence,
1320
- // whichever surface a model reaches it through.
1321
- const noun = `${count} memor${count === 1 ? "y" : "ies"}`;
1322
- const effect = outcome > 0
1323
- ? " — they should surface sooner next time."
1324
- : outcome < 0
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
- : "";
1355
- return {
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
- ],
1366
- };
1367
- });
1368
- // --- Tool: memory_stats ---
1369
- server.registerTool("memory_stats", {
1370
- description: "Get an overview of the stored memory: total count, episodes vs consolidated prototypes, number of learned associations, the list of domains, and average quality scores. This memory is shared across all AI tools the user has connected to Mnemoverse. Use it to orient yourself, to confirm the exact domain name before writing to it, or when the user asks what you remember. Read-only — changes nothing.",
1371
- inputSchema: {},
1372
- annotations: {
1373
- title: "Memory Statistics",
1374
- readOnlyHint: true,
1375
- destructiveHint: false,
1376
- idempotentHint: true,
1377
- openWorldHint: true,
1378
- },
1379
- }, async () => {
1380
- const r = await apiFetch("/memory/stats");
1381
- // A field the server did not send is UNKNOWN, not zero. Rendering it as
1382
- // "0" is the same class of lie as an empty search claiming emptiness:
1383
- // "Associations: 0" reads as "this memory has learned nothing", which is
1384
- // a strong and possibly false statement about the product itself.
1385
- const num = (v) => (typeof v === "number" ? String(v) : "unknown");
1386
- const dec = (v) => (typeof v === "number" ? v.toFixed(2) : "unknown");
1387
- // THE surface this tool's own description sends the reader to, to
1388
- // confirm the exact domain name before writing to it — so it has to be
1389
- // able to answer that. (Until 2026-08-20 memory_delete_domain also sent
1390
- // readers here for the same reason, before deletion was withdrawn to an
1391
- // administrative REST-only operation; see CHANGELOG.)
1392
- //
1393
- // Quoting was tried, then withdrawn as a fix that wasn't one: safeInline
1394
- // collapses and trims whitespace BEFORE the quotes go on, so " engineering"
1395
- // and "engineering" printed identically and the list read like a tool bug.
1396
- // The conclusion drawn then — "revealing whitespace needs escaping rather
1397
- // than quoting" — was right; this is that escaping. Each name is a JSON
1398
- // string literal, so a leading space, a no-break space, a newline and a
1399
- // zero-width character are all visible, Cyrillic stays Cyrillic, and two
1400
- // different names can no longer print as one. Assembly lives in
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.
1411
- const domains = formatDomainList(r?.domains);
1412
- const text = [
1413
- `Memories: ${num(r?.total_atoms)} (${num(r?.episodes)} episodes, ${num(r?.prototypes)} prototypes)`,
1414
- // The gloss names the real mechanism. Core's `hebbian_edges` counts
1415
- // concept-concept edges (api/schemas.py: "Number of Hebbian
1416
- // concept-concept edges"), and every path that learns one links two
1417
- // CONCEPTS: `strengthen` walks pairs within one atom's concept list at
1418
- // write time and again on feedback, `co_activate` links query concepts
1419
- // to result concepts on use. An earlier gloss said "links between
1420
- // memories that get used together" — wrong unit (memories, not
1421
- // concepts) and wrong trigger (an edge records co-occurrence, not two
1422
- // memories being used together).
1423
- `Associations: ${num(r?.hebbian_edges)} Hebbian edges — concept-to-concept links learned from concepts that occur together as memories are stored and used`,
1424
- `Domains: ${domains}`,
1425
- `Avg quality: valence ${dec(r?.avg_valence)} (how well recalls turned out, -1..1), importance ${dec(r?.avg_importance)} (0..1)`,
1426
- "",
1427
- "Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
1428
- ].join("\n");
1429
- return {
1430
- content: [
1431
- {
1432
- type: "text",
1433
- // Array.isArray, not `?? []`: `domains` is typed as a string[] but
1434
- // arrives over the wire, and spreading a non-iterable object would
1435
- // throw here — turning a malformed payload into a dead tool instead
1436
- // of the "none reported" it degrades to two lines up.
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 : [])),
1449
- },
1450
- ],
1451
- };
1452
- });
1453
- // --- Tool: memory_create_room ---
1454
- server.registerTool("memory_create_room", {
1455
- 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.",
1456
- inputSchema: {
1457
- name: z
1458
- .string()
1459
- .min(1)
1460
- .max(200)
1461
- .describe("Room name, unique within your account (e.g. 'me-and-olya')."),
1462
- description: z
1463
- .string()
1464
- .max(2000)
1465
- .optional()
1466
- .describe("Optional description of the room."),
1467
- },
1468
- annotations: {
1469
- title: "Create shared room",
1470
- readOnlyHint: false,
1471
- destructiveHint: false,
1472
- idempotentHint: false,
1473
- openWorldHint: true,
1474
- },
1475
- }, async ({ name, description }) => {
1476
- const r = await apiFetch("/memory/rooms", {
1477
- method: "POST",
1478
- body: JSON.stringify({ name, description }),
1479
- });
1480
- // The name is echoed back to the caller who CHOSE it, so it is printed
1481
- // exactly (src/names.ts): safeInline turned `memory_create_room({name:
1482
- // "проект"})` into `Created shared room ""` — an echo claiming the caller
1483
- // named their room the empty string.
1484
- const rawName = r?.name ?? name;
1485
- const roomName = roomNamePhrase(rawName);
1486
- const address = safeInline(r?.address);
1487
- const roomId = safeInline(r?.room_id);
1488
- // If core returned no usable id (empty body / sanitized away), don't print
1489
- // broken `domain=""` guidance — say so instead (Copilot).
1490
- const text = address
1491
- ? `Created shared room ${roomName}. Address: ${address}\n` +
1492
- `Use it now: pass domain="${address}" on memory_write / memory_read.\n` +
1493
- (roomId
1494
- ? `To add someone: call memory_invite_to_room with room_id="${roomId}".`
1495
- : "")
1496
- : `Room ${roomName} was created but the server did not return a usable address — ` +
1497
- `retry, or check that your API key is set.`;
1498
- return {
1499
- content: [
1500
- {
1501
- type: "text",
1502
- // Legend AFTER the cap (same rule as memory_read/memory_list_recent):
1503
- // capResult cuts from the end, so a legend applied first would be the
1504
- // first casualty; applied to the capped text it also drops itself when
1505
- // the cap removed the only escaped name.
1506
- text: withDomainEscapeLegend(capResult(text), rawName),
1507
- },
1508
- ],
1509
- };
1510
- });
1511
- // --- Tool: memory_invite_to_room ---
1512
- server.registerTool("memory_invite_to_room", {
1513
- description: "Mint a one-time invite for a room you own and get a ready-to-forward message. The user sends that message to the person they want to add (any messenger); the recipient opens the link or tells THEIR assistant the code to join. Use after memory_create_room, or whenever the user says 'invite <someone>' to an existing room.",
1514
- inputSchema: {
1515
- room_id: z
1516
- .string()
1517
- .min(1)
1518
- .max(100)
1519
- .describe("The room's id (room_...), from memory_create_room."),
1520
- scope: z
1521
- .enum(["read", "read_write"])
1522
- .optional()
1523
- .describe("Role the invitee gets — 'read' or 'read_write' (default read_write)."),
1524
- expires_in_days: z
1525
- .number()
1526
- .int()
1527
- .min(1)
1528
- .max(90)
1529
- .optional()
1530
- .describe("Days until the invite expires (default 7)."),
1531
- },
1532
- annotations: {
1533
- title: "Invite to room",
1534
- readOnlyHint: false,
1535
- destructiveHint: false,
1536
- idempotentHint: false,
1537
- openWorldHint: true,
1538
- },
1539
- }, async ({ room_id, scope, expires_in_days }) => {
1540
- const r = await apiFetch(`/memory/rooms/${encodeURIComponent(room_id)}/invites`, {
1541
- method: "POST",
1542
- body: JSON.stringify({ scope, expires_in_days }),
1543
- });
1544
- return {
1545
- content: [
1546
- {
1547
- type: "text",
1548
- // Shown to the room OWNER (who minted it), not a foreign principal, so
1549
- // the core-generated share_message is fine as-is; capResult only bounds
1550
- // its length for the Connectors-Directory 25K cap.
1551
- text: capResult(`Invite ready. Forward this message to the person you're inviting:\n\n` +
1552
- `${r?.share_message ?? r?.join_url ?? "(no message returned)"}`),
1553
- },
1554
- ],
1555
- };
1556
- });
1557
- // --- Tool: memory_join_room ---
1558
- server.registerTool("memory_join_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.",
1560
- inputSchema: {
1561
- code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
1562
- },
1563
- annotations: {
1564
- title: "Join room",
1565
- readOnlyHint: false,
1566
- destructiveHint: false,
1567
- idempotentHint: true,
1568
- openWorldHint: true,
1569
- },
1570
- }, async ({ code }) => {
1571
- const r = await apiFetch("/memory/rooms/join", {
1572
- method: "POST",
1573
- body: JSON.stringify({ code }),
1574
- });
1575
- // The room name is OWNER-chosen and rendered into the JOINER's LLM context
1576
- // (CN-032) — and it is printed EXACTLY (src/names.ts): the literal is one
1577
- // line with quotes and invisibles escaped, so it cannot forge an
1578
- // instruction block, and "Zoë" stops being quoted as "Zo" in a sentence
1579
- // that presents it as the name. `scope` stays sanitised: server-shaped
1580
- // enum, display-only.
1581
- const roomName = roomNamePhrase(r?.name);
1582
- const scope = safeInline(r?.scope) || "member";
1583
- const address = safeInline(r?.address);
1584
- const prefix = r?.already_member
1585
- ? `You're already a member of ${roomName}.`
1586
- : `Joined ${roomName} (${scope}).`;
1587
- // Don't print broken `domain=""` guidance if no address came back (Copilot).
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.`;
1600
- return {
1601
- content: [
1602
- {
1603
- type: "text",
1604
- // Legend after the cap — same ordering rule as everywhere else.
1605
- text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
1606
- },
1607
- ],
1608
- };
1609
- });
1610
- // --- Tool: memory_list_rooms ---
1611
- server.registerTool("memory_list_rooms", {
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.",
1613
- inputSchema: {},
1614
- annotations: {
1615
- title: "List rooms",
1616
- readOnlyHint: true,
1617
- destructiveHint: false,
1618
- idempotentHint: true,
1619
- openWorldHint: true,
1620
- },
1621
- }, async () => {
1622
- // The same classifier the scope notes use, for the same reason: this handler
1623
- // did `Array.isArray(rooms) ? rooms : []` and then asserted "You have no
1624
- // shared rooms yet" — a claim about the account derived from a body it could
1625
- // not read. It is the third consumer of this payload and the third place the
1626
- // substitution was made; all three now go through one function that maps an
1627
- // unreadable body to `unknown`, never to `none`.
1628
- const rooms = classifyRooms(await apiFetch("/memory/rooms"), safeInline);
1629
- if (rooms.state === "unknown") {
1630
- // Byte-identical to the inline wording this replaces — the builder is
1631
- // shared so the four list surfaces answer the unreadable case with one
1632
- // sentence, not four drifting ones.
1633
- return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
1634
- }
1635
- if (rooms.state === "none") {
1636
- return {
1637
- content: [
1638
- {
1639
- type: "text",
1640
- text: "You have no shared rooms yet. Create one with memory_create_room, " +
1641
- "or join one with memory_join_room using an invite code.",
1642
- },
1643
- ],
1644
- };
1645
- }
1646
- // ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
1647
- // is the inventory, and the `[archived]` tag says which ones cannot be read.
1648
- const list = rooms.rooms;
1649
- // Room name is OWNER-chosen — printed EXACTLY (src/names.ts), like every
1650
- // other room-name surface as of 0.8.1: the sanitiser listed "проект" and
1651
- // "план" as two "(unnamed room)" entries and quoted "Zoë" as "Zo".
1652
- // address/role/scope are server-shaped and keep the defensive sanitiser.
1653
- const lines = list.map((r) => {
1654
- const name = roomNamePhrase(r?.name);
1655
- const roomId = safeInline(r?.room_id);
1656
- // Always surface the canonical address: fall back to xroom:<room_id> when the server
1657
- // omits `address`, so the domain guidance this tool promises is never silently dropped.
1658
- const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
1659
- const role = safeInline(r?.role);
1660
- const scope = safeInline(r?.scope);
1661
- // No "use domain=..." on an archived room: core refuses EVERY read of one
1662
- // with a 403, for owner and member alike (see the archived-only note in
1663
- // src/scope.ts), so that clause was an instruction to make a call that
1664
- // cannot succeed. The address stays visible — it is the room's identity —
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);
1673
- const tail = r?.archived
1674
- ? address
1675
- ? ` [archived] — address ${address}, but every read is refused while it is archived`
1676
- : ` [archived] — every read is refused while it is archived`
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`;
1684
- return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
1685
- });
1686
- const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
1687
- return {
1688
- content: [
1689
- {
1690
- type: "text",
1691
- // Legend after the cap: this is the one room surface long enough to
1692
- // actually overflow, and the legend must describe the names that
1693
- // SURVIVED the cut, not the ones it removed.
1694
- text: withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name)),
1695
- },
1696
- ],
1697
- };
1698
- });
1699
- // --- Tool: vault_list ---
1700
- server.registerTool("vault_list", {
1701
- description: "List the secrets stored in your Mnemoverse Vault — by ALIAS and purpose only; the secret VALUE is never returned or shown to you, and no tool on this server returns it. Use this to check WHICH secrets the user has stored and under what alias (e.g. the user says 'do I have a GitHub token saved?'). Only YOUR account's secrets are listed.",
1702
- inputSchema: {},
1703
- annotations: {
1704
- title: "List vault secrets",
1705
- readOnlyHint: true,
1706
- destructiveHint: false,
1707
- idempotentHint: true,
1708
- openWorldHint: true,
1709
- },
1710
- }, async () => {
1711
- const r = await apiFetch("/vault/secrets");
1712
- // A 200 missing the `secrets` array used to print "No secrets are stored
1713
- // in your Vault yet." — an absence claim about the Vault derived from a
1714
- // body this client could not read. The family fix (classifyRooms, the
1715
- // domains guard) had stopped one tool short of here (truth F13,
1716
- // 2026-08-08). A genuinely empty array keeps the absence claim below.
1717
- const list = r?.secrets;
1718
- if (!Array.isArray(list)) {
1719
- return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
1720
- }
1721
- if (list.length === 0) {
1722
- return {
1723
- content: [
1724
- {
1725
- type: "text",
1726
- text: "No secrets are stored in your Vault yet.",
1727
- },
1728
- ],
1729
- };
1730
- }
1731
- const lines = list.map((s) => {
1732
- const alias = safeInline(s?.alias) || "(no alias)";
1733
- const context = safeInline(s?.context);
1734
- return context ? `- ${alias} — ${context}` : `- ${alias}`;
1735
- });
1736
- const text = `Your Vault secrets (${list.length}) — alias and purpose only, never the value:\n` +
1737
- lines.join("\n");
1738
- return {
1739
- content: [
1740
- {
1741
- type: "text",
1742
- text: capResult(text, "The secret list was truncated — some secrets are not shown."),
1743
- },
1744
- ],
1745
- };
1746
- });
297
+ // The tools themselves live in src/tools.ts, shared with every other server
298
+ // that exposes Mnemoverse memory over MCP (ADR-025). This server's part is
299
+ // apiFetch above: the API key, the base URL and their refusals.
300
+ registerMemoryTools(server, { apiFetch });
301
+ // Three named entry points to those tools (src/prompts.ts); they call nothing.
302
+ registerMemoryPrompts(server);
303
+ // One saved memory by id, memory://item/{memory_id} (src/resources.ts).
304
+ registerMemoryResources(server, { apiFetch });
1747
305
  // --- Start ---
1748
306
  /**
1749
307
  * Opening the stdio transport at import time is what made every user-visible
@@ -1802,6 +360,16 @@ server.registerTool("vault_list", {
1802
360
  function probeApiKeyInBackground() {
1803
361
  if (!API_KEY)
1804
362
  return;
363
+ if (PLACEHOLDER_KEY_REFUSAL !== undefined) {
364
+ // Same reasoning as the BASE_URL_REFUSAL skip right below, for the same
365
+ // reason it is checked first inside apiFetch: this probe is a second
366
+ // credential-bearing call site outside apiFetch, so a config-only
367
+ // refusal decided at import time has to be checked here too, or a docs
368
+ // placeholder key would go out over the wire once per server start even
369
+ // though every tool call already refuses to send it.
370
+ console.error(PLACEHOLDER_KEY_REFUSAL.startupLog);
371
+ return;
372
+ }
1805
373
  if (BASE_URL_REFUSAL !== undefined) {
1806
374
  // THE SECOND CREDENTIAL-BEARING CALL SITE (#99). This probe predates
1807
375
  // apiFetch's base-URL guard and calls `fetch` directly, so the guard does