@mnemoverse/mcp-memory-server
Version:
Hosted AI agent memory over MCP that learns from outcomes — feedback re-ranks recall — with shared rooms for multi-agent work; one key or OAuth across Claude Code, Cursor, VS Code & ChatGPT, no infra to run
463 lines • 25.3 kB
JavaScript
/**
* Search-scope honesty — the note that turns "nothing found" from an assertion
* about the WORLD into a statement about where we actually looked.
*
* Why this module exists (incident 2026-08-07). Two agents lost a working day
* to the same silent failure: a message was written into a shared room, and an
* unscoped `memory_list_recent` answered "Nothing new since your watermark."
* The write was fine, the index was fine, and the read was fine — an UNSCOPED
* read simply never covers rooms.
*
* That is not a filter oversight; it is how the store is built. A room is a
* separate storage tenant (core ADR-019): with no `domain`, the request runs
* against the caller's OWN org, and room atoms live in the room's org. So the
* unscoped feed cannot see them and never could.
*
* The defect is therefore not the scoping — it is the WORDING. "Nothing new"
* is a claim about absence, and absence is only knowable within the scope you
* searched. An empty result from an unscoped read must therefore say what it did
* not cover: which rooms went unsearched and how to read them, that every room
* it has is archived and reachable by no path, or that it could not find out.
* Silence is correct in exactly one case — the caller has no rooms, so nothing
* was missed — and that case is a distinct state below rather than the falsy
* spelling of the other three.
*
* Kept out of index.ts so the copy can be unit-tested without booting the
* stdio transport, matching render.ts and teaching.ts.
*/
import { domainPhrase, exactLiteral, roomNamePhrase, withDomainEscapeLegend, } from "./names.js";
// "naive = UTC" is the convention core's schema and both `since` descriptions
// state; it used to be implemented here and nowhere else, which is how the
// renderer came to read the same wire value as local time. One implementation,
// two readers (src/time.ts).
import { parseAsUtc } from "./time.js";
/**
* How to NAME the scope inside a sentence, so the sentence is true on its own
* rather than corrected by a paragraph underneath it.
*
* "Nothing new since your watermark." was left untouched by the first two
* passes of this release — the very sentence this module's header names as the
* lie that cost two agents a day. A note was appended to it instead, so the
* assembled answer read as two voices: one asserting you were caught up, the
* next saying that assertion was meaningless. The sibling branch in memory_read
* had it right all along by naming its filters inside the sentence (review,
* 2026-08-08).
*
* The name inside that sentence then went through safeInline, which made the
* sentence false in the case that matters most: a read on `" engineering"` — the
* padded second store this release is about — answered `Nothing in
* "engineering"`, naming a DIFFERENT store, and a whitespace-only scope printed
* as `""`, i.e. as no scope at all. It is now the exact literal (src/names.ts),
* and when a name cannot be reproduced the sentence names nothing rather than
* naming something else.
*
* A room address stays unnamed ("that room") — unchanged, and deliberately: the
* address is already in the caller's hand, and this sentence has no need to
* print another principal's display string. Where a room's NAME is printed at
* all ({@link liveRoomsNote} below, the room tools in index.ts) it goes through
* `roomNamePhrase` (src/names.ts) — exactly, or declared unprintable — never
* through the lossy sanitiser, which renamed "проект" to "(unnamed room)".
*
* Lives here rather than in index.ts so the sentence can be unit-tested: this is
* the module about saying where we looked, and index.ts opens a stdio transport
* on import.
*/
export function scopeLabel(searched) {
if (!searched)
return "your own domains";
if (searched.startsWith("xroom:"))
return "that room";
return domainPhrase(searched);
}
/**
* "Nothing new since your watermark" is also what you get for a watermark in
* the FUTURE — a mixed-up timezone or a bad relative-date calculation reads
* exactly like a clean bill of health (dogfood, 2026-08-07). If the caller's
* `since` is ahead of now, say so and show the current time; that is the whole
* diagnosis, and the caller cannot reach it from "nothing new".
*
* The explaining clause claims a fact about TIME, never about any store. It
* used to read "nothing has been written after it YET" — an absence claim over
* EVERY store, printed directly above the scope disclosure admitting a room
* went unsearched, and premised on an admittedly-approximate client clock
* (review, 2026-08-08). The window is empty because it asks for entries newer
* than a moment that has not happened; what any store holds is not this note's
* to assert.
*
* `nowMs` is injected so the note is testable without freezing the clock.
*/
export function futureSinceNote(since, nowMs) {
if (!since)
return "";
const t = parseAsUtc(since);
if (t === null || t <= nowMs)
return "";
return (`\n\nNote: that watermark is in the FUTURE — it asks for entries newer than a ` +
`moment that has not happened yet, so an empty result here says nothing about ` +
`whether you are caught up. ` +
`This client's clock reads ${new Date(nowMs).toISOString().slice(0, 16)}Z ` +
`(the server's may differ slightly). Check the watermark you passed — a timezone ` +
`slip is the usual cause.`);
}
/**
* The disclosure for the {@link NamedScope} arm `no-such-domain`: the caller
* scoped a read to a domain that is not in `/memory/stats.domains`.
*
* WHAT IT DOES, which is not what it was called. Until 0.8.1 this was
* `nearestDomainNote`, and the name outlived the behaviour twice over: the
* "nearest"/prefix guess it was named for was deleted as unsound (see the body),
* and even before that it was never a nearest-neighbour search. What remains is
* two branches, and neither is a proximity search:
*
* 1. an EXACT case-insensitive twin, when one exists in the known list and
* both names can be printed reproducibly — the casing slip that silently
* forks a namespace, which is the one miss a reader can act on
* (dogfood, 2026-08-07);
* 2. otherwise a NAME-FREE statement that no store OF THE CALLER'S OWN
* carries that exact name — bounded to its evidence: `/memory/stats.domains`
* covers the caller's own org bucket only, so rooms and other principals'
* stores are invisible to it and the sentence may not claim them (review,
* 2026-08-08; the destructive sibling in index.ts already said "in your
* own store"). It is always safe to say and must never go quiet — silence
* here is byte-identical to "the store is there, your query merely
* missed".
*
* Deliberately conservative: only case differs. A whitespace or zero-width twin
* (`" engineering"` beside `"engineering"`) is NOT diagnosed here — extending the
* rule is a behaviour decision, and `memory_stats` closes that loop by printing
* both names exactly. Recorded in CHANGELOG's "Known and NOT fixed here".
*/
export function noSuchDomainNote(domain, knownDomains) {
// The RAW name, exactly as searched. This used to trim — a second copy of
// the mistake the caller had already made: a read on " engineering" searched
// the padded store, then this checked "engineering", matched it, and the one
// sentence that would have explained the miss was suppressed precisely when a
// stray space had caused it (review, 2026-08-08).
const wanted = domain;
if (!wanted)
return "";
const lower = wanted.toLowerCase();
// Match on the RAW values, print them through the EXACT renderer.
//
// This branch names two stores and invites the reader to act on one of them,
// so it may only print a name it can reproduce. It used to render through
// safeInline and guard the branch with `sanitize(x) === x`, which was correct
// in spirit and expensive in practice: safeInline's charset is ASCII, so
// every Cyrillic name — ordinary in this workspace — failed the guard and the
// most useful sentence this module has went silent for a whole alphabet. (An
// even earlier draft printed through the sanitiser without the guard, and
// asserted facts about ":acme", a name that exists nowhere.)
//
// src/names.ts prints exactly or returns null, so the guard is now the
// renderer itself and "проект" can be named as "проект". Both sides are
// checked: naming a twin we cannot reproduce would send the reader to a
// store whose name we just invented.
const wantedLiteral = exactLiteral(wanted);
const twin = wantedLiteral
? knownDomains
.map((d) => ({ name: d, exact: exactLiteral(d) }))
.find((c) => c.name !== wanted && c.name.toLowerCase() === lower && c.exact !== null)
: undefined;
if (wantedLiteral && twin?.exact) {
return withDomainEscapeLegend(`\n\nDomain names are matched exactly, including case: ${wantedLiteral.literal} is not ` +
`the same store as ${twin.exact.literal}, which does exist. Did you mean that one?`, wanted, twin.name);
}
// NO "closest match" — the branch the old function NAME advertised, deleted
// rather than repaired. It named the FIRST element of an unordered
// `SELECT DISTINCT domain` having any prefix relation in either direction —
// so it was not a nearest neighbour, it could differ between two identical
// calls, and a one-character domain became the confidently-named "closest"
// match for every name starting with that letter. This module's own contract
// says a wrong guess is worse than silence, because the reader trusts it.
//
// The absence claim is narrowed twice: names match byte-for-byte, so a store
// whose name carries a leading space or a zero-width character is real but
// unreachable from a clean spelling — hence "that exact name", never "no such
// store". And it is bounded to the caller's OWN stores, because that is all
// the evidence covers: the known list is `/memory/stats.domains`, which
// reports one org bucket and sees no room and no other principal's store.
//
// NO pointer to memory_stats here, and the reason has CHANGED. A previous
// draft ended "— memory_stats quotes each name so you can see them", and that
// was removed because it could not work: the sanitiser collapsed and trimmed
// whitespace BEFORE the quotes went on, so " engineering" and "engineering"
// printed identically, and the reader was sent to a check that could not
// reveal the thing (reviews, 2026-08-08). memory_stats now prints exact
// literals (src/names.ts), so that objection is gone — the check works.
// Whether this sentence should carry the pointer again is a copy decision and
// not part of the renderer change; its absence stays pinned by
// test/scope.test.ts until someone decides. What must not come back is a
// pointer to a surface that cannot answer.
return (`\n\nNo store of your own has that exact name. Names match byte-for-byte, so a stray space, ` +
`a different case, or an invisible character makes a separate store — one that ` +
`exists and holds its own memories.`);
}
/** How many rooms to name before collapsing into a count. */
const MAX_LISTED = 5;
/** The disclosure for either kind of scope — "" only where the type says there
* is genuinely nothing to disclose. */
export function readScopeNote(scope) {
const known = scope.kind === "named" ? scope.named : scope.rooms;
return "note" in known ? known.note : "";
}
/**
* One room record, with every field narrowed to the type it claims.
*
* Returns null when the value cannot be read as a room — which makes the WHOLE
* payload `unknown` rather than silently contributing a room-shaped blank. A
* non-boolean `archived` is rejected for the same reason: we would have to guess
* whether the room is readable, and the guess decides whether the answer says
* "re-run with domain set" or "nothing in there is reachable".
*
* It is also a latent crash fixed: `safeInline` does `(s ?? "").replace(…)`, so
* a numeric `name` on the wire used to throw inside a renderer.
*/
function asRoom(value) {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
const r = value;
const { archived } = r;
if (archived !== undefined && archived !== null && typeof archived !== "boolean") {
return null;
}
const str = (v) => (typeof v === "string" ? v : undefined);
return {
room_id: str(r.room_id),
name: str(r.name),
address: str(r.address),
role: str(r.role),
scope: str(r.scope),
archived: archived === true,
};
}
/** The opening clause every unscoped disclosure shares. */
const SCOPE_PREFIX = `\n\nScope: your own domains only. Shared rooms are separate stores and are NOT ` +
`included in an unscoped read`;
/**
* The rooms an unscoped read did not cover, named so they can be read.
*
* Archived rooms are deliberately NOT mentioned here. A counting clause was
* added and removed inside this release because it only ever rendered for
* owners — core filters archived rooms out of the member query entirely and
* hard-codes `archived=false` on joined rows — and because it promised recovery
* "until it is unarchived" when core has archive with no inverse (reviews,
* 2026-08-08). Both objections still stand for the MIXED case, which is this
* one: there is a readable room to point at, and that is what a reader can act
* on. The case where every room is archived is a different state with a
* different sentence (see below), because there silence is not an option: it
* either dangles a colon or lets the answer claim the memory is empty.
*
* The NAME is printed exactly (src/names.ts, `roomNamePhrase`) even though it is
* OWNER-chosen and display-only. This note used to render it through the
* injected sanitiser, which did not make hostile names harmless so much as it
* made ordinary names WRONG: two live rooms named "проект" and "план" both
* printed "(unnamed room)" in the very note that asks the reader to pick one
* (review, 2026-08-08). The exact literal keeps the anti-injection property —
* one line, quotes and backslashes and invisibles escaped — and a name that
* cannot be printed within the cap is declared unprintable rather than altered.
* The escape legend rides on the note itself, because this note only appears on
* UNSCOPED answers, where the assembling call sites pass no room names as
* legend candidates (they have `searched === undefined` in hand, not the list).
*
* `sanitize` (render.ts's safeInline) still guards the machine-shaped fields —
* `address` and `room_id`, which core charset-validates — as a defensive pass,
* unchanged.
*/
function liveRoomsNote(live, sanitize) {
const listed = live.slice(0, MAX_LISTED);
const shown = listed.map((r) => {
const name = roomNamePhrase(r.name);
const roomId = sanitize(r.room_id);
const address = sanitize(r.address) || (roomId ? `xroom:${roomId}` : "");
return address ? ` - ${name} — domain="${address}"` : ` - ${name}`;
});
const rest = live.length - shown.length;
const more = rest > 0 ? `\n …and ${rest} more (memory_list_rooms)` : "";
return withDomainEscapeLegend(`${SCOPE_PREFIX} — ${live.length} room${live.length === 1 ? "" : "s"} ` +
`went unsearched:\n${shown.join("\n")}${more}\n` +
`Re-run with domain set to one of these to read it.`, ...listed.map((r) => r.name));
}
/**
* Every room the caller has is archived — the state that used to be spelled as
* an empty note plus `roomsFound: true`, i.e. as a promise of a disclosure that
* had been filtered out one function earlier.
*
* What it may claim, and why each clause is safe: an archived room refuses every
* read for owner and member alike (core resolves the room, then returns 403
* "Room is archived"); nothing in the client, the data plane or the portal plane
* clears the flag, so this connector genuinely has no operation that reopens
* one; and archiving is a soft flag, so the content is still there. The word
* "unarchive" is avoided on purpose — the withdrawn draft promised recovery
* "until it is unarchived", and no such operation exists to wait for.
*
* The rooms are counted, not named: there is no address that would do the reader
* any good, and an owner-chosen name is only worth surfacing when it is
* actionable.
*/
function archivedOnlyNote(archived) {
const plural = archived === 1 ? "" : "s";
return (`${SCOPE_PREFIX}. You have ${archived} shared room${plural}, ` +
`${archived === 1 ? "which is" : "all of which are"} archived. An archived room ` +
`refuses every read, for its owner as much as for a member, and this client has no ` +
`operation that reopens one. Archiving did not delete what is in there, so a memory ` +
`you remember writing can be real and still be unreachable from here.`);
}
/** We could not look. Says so, and still states the boundary — dropping the
* caveat because the probe failed is the silence this module exists to end. */
function uncheckedRoomsNote(reason) {
const cause = reason === "fetch-failed"
? "the room list could not be fetched just now"
: "the room list came back in a shape this client does not recognise";
return (`${SCOPE_PREFIX}; ${cause}, so this answer cannot say whether any room went ` +
`unsearched. Check memory_list_rooms and re-run with domain set.`);
}
/**
* Classify a `/memory/rooms` body. Total: every payload maps to exactly one
* state, and a payload that is not a list of rooms maps to `unknown` — never to
* `none`, which is the substitution that turned a contract violation into "you
* have no rooms".
*/
export function classifyRooms(payload, sanitize) {
if (!Array.isArray(payload)) {
return { state: "unknown", reason: "unrecognised-shape", note: uncheckedRoomsNote("unrecognised-shape") };
}
const rooms = [];
for (const entry of payload) {
const room = asRoom(entry);
if (room === null) {
return {
state: "unknown",
reason: "unrecognised-shape",
note: uncheckedRoomsNote("unrecognised-shape"),
};
}
rooms.push(room);
}
const live = rooms.filter((r) => !r.archived);
const archived = rooms.length - live.length;
if (live.length > 0) {
return { state: "live", rooms, live, archived, note: liveRoomsNote(live, sanitize) };
}
if (archived > 0) {
return { state: "archived-only", rooms, archived, note: archivedOnlyNote(archived) };
}
return { state: "none" };
}
/** Fetch the caller's rooms and classify them. Never throws — a failure is a
* state, not an exception, because every caller has to render it. */
export async function probeRoomScope(fetchRooms, sanitize) {
try {
return classifyRooms(await fetchRooms(), sanitize);
}
catch {
return { state: "unknown", reason: "fetch-failed", note: uncheckedRoomsNote("fetch-failed") };
}
}
/**
* Not in the room list — and the reason is not only "wrong address or not a
* member", which is what this used to assert. A room the caller merely JOINED
* disappears from the list the moment its owner archives it (core filters
* `is_archived = FALSE` out of the member query), so for exactly the invited
* teammate this release is about, the old two-cause sentence was false.
*/
const NO_SUCH_ROOM_NOTE = `\n\nThat room is not in your list — the address may be wrong, you may not be a ` +
`member, or it may be archived: a room you only JOINED disappears from the list once ` +
`its owner archives it. memory_list_rooms shows the ones you can read.`;
/** "We could not look" for a named scope, which the old code spelled as `""` —
* i.e. exactly like "the store is there and your query merely missed". */
function uncheckedNamedNote(probed, reason) {
const cause = reason === "fetch-failed"
? "could not be fetched just now"
: "came back in a shape this client does not recognise";
return probed === "rooms"
? `\n\nWhether you are a member of that room could not be checked — the room list ` +
`${cause}. So this empty result is not evidence that the address is wrong or that ` +
`you are not in it. memory_list_rooms shows the rooms you can read.`
: `\n\nWhether a store with that exact name exists could not be checked — the domain ` +
`list ${cause}. So this empty result is not evidence that the name is wrong; names ` +
`are matched byte-for-byte, and memory_stats lists them exactly.`;
}
function unchecked(name, probed, reason) {
return { state: "unchecked", name, probed, reason, note: uncheckedNamedNote(probed, reason) };
}
/**
* Probe for the one store a scoped read named.
*
* A ROOM ADDRESS IS CHECKED AGAINST THE ROOM LIST, never against `/memory/stats`
* — that exclusion is the whole reason this release exists. `memory_stats`
* reports the caller's own org bucket only, and not one of an account's twelve
* live rooms appears in its domain list, so probing it for an `xroom:` address
* would confidently answer "No store of your own has that exact name" about a
* room the caller is a member of: the false-absence claim being fixed,
* reintroduced by the fix (CodeRabbit, #65).
*
* The argument is the RAW value that was sent, never a trimmed copy. A read on
* `" engineering"` searched the padded store while a trimmed diagnosis checked
* `"engineering"`, found it, and stayed silent — suppressing the one note that
* explains the miss precisely when a stray space had caused it.
*
* Private because `name` must be non-empty, which only {@link probeReadScope}
* can guarantee: `searchedScope` maps `""` to `undefined`, so an empty domain is
* not a scope at all and takes the own-domains path.
*/
async function probeNamedScope(name, fetchStats, fetchRooms, sanitize) {
if (name.startsWith("xroom:")) {
let payload;
try {
payload = await fetchRooms();
}
catch {
return unchecked(name, "rooms", "fetch-failed");
}
const rooms = classifyRooms(payload, sanitize);
if (rooms.state === "unknown")
return unchecked(name, "rooms", rooms.reason);
const all = rooms.state === "none" ? [] : rooms.rooms;
const member = all.some((r) => r.address === name || `xroom:${r.room_id}` === name);
return member
? { state: "present", name }
: { state: "no-such-room", name, note: NO_SUCH_ROOM_NOTE };
}
let payload;
try {
payload = await fetchStats();
}
catch {
return unchecked(name, "domains", "fetch-failed");
}
const domains = payload?.domains;
// Core's response model declares `domains: list[str]` with no default, so it
// is always present and always an array of strings on a 200. A missing key or
// a non-string element therefore means the body is not core's, and the only
// honest reading is that we did not get to look — not that the store is absent.
if (!Array.isArray(domains) || domains.some((d) => typeof d !== "string")) {
return unchecked(name, "domains", "unrecognised-shape");
}
const known = domains;
if (known.includes(name))
return { state: "present", name };
return { state: "no-such-domain", name, note: noSuchDomainNote(name, known) };
}
/**
* Build the {@link ReadScope} for one read: where it looked, and what we know
* about what it therefore did not cover. One GET per call — the room list for
* an unscoped read or an `xroom:` address, `/memory/stats` for a plain domain
* — and callers reach it on zero-result paths only. That is MORE probing than
* 0.8.0 did, not "exactly as before" (an earlier version of this comment):
* 0.8.0 made no probe at all on a domain-scoped, room-scoped or filtered read
* and none anywhere in the feed; only the plain unscoped read probed stats.
* The added reads are disclosed in the 0.8.1 CHANGELOG entry, rate cost
* included.
*
* `searched` must be the value that went over the wire — `searchedScope(domain)`
* — so that the diagnosis and the request can never describe different stores.
* A falsy value takes the own-domains path because that is what the request did:
* core filters on `domain is not None`, and `searchedScope` drops `""`.
*/
export async function probeReadScope(searched, fetchStats, fetchRooms, sanitize) {
if (!searched) {
return { kind: "own-domains", rooms: await probeRoomScope(fetchRooms, sanitize) };
}
return { kind: "named", named: await probeNamedScope(searched, fetchStats, fetchRooms, sanitize) };
}
//# sourceMappingURL=scope.js.map