@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
224 lines (223 loc) • 11.6 kB
TypeScript
/**
* 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.
*/
/**
* 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 declare function scopeLabel(searched: string | undefined): string;
/**
* "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 declare function futureSinceNote(since: string | undefined, nowMs: number): string;
/**
* 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 declare function noSuchDomainNote(domain: string, knownDomains: readonly string[]): string;
/** The subset of a room record anything here reads. Every field is normalised
* to a string-or-absent by {@link asRoom} before it is stored. */
export interface RoomSummary {
room_id?: string;
name?: string;
address?: string;
role?: string;
scope?: string;
archived?: boolean;
}
/**
* Why a probe could not answer. Both are "we do not know", and they are kept
* apart because the reader's next move differs: a fetch failure is worth
* retrying, a shape this client cannot read is not.
*/
export type UncheckedReason = "fetch-failed" | "unrecognised-shape";
/**
* What we know about the shared rooms an UNSCOPED read did not cover.
*
* `note` is the disclosure such a read must append, and it is present on
* exactly the states that have something to disclose. Other consumers of the
* same payload (memory_list_rooms) read the facts and ignore it.
*/
export type RoomScope = {
/** The probe answered, and the caller has no rooms at all. Nothing was
* missed, so there is nothing to disclose — hence no `note` field. */
readonly state: "none";
} | {
/** At least one room can still be read by passing its address. */
readonly state: "live";
/** Every room in the list, live and archived — for memory_list_rooms. */
readonly rooms: readonly RoomSummary[];
readonly live: readonly RoomSummary[];
readonly archived: number;
readonly note: string;
} | {
/** Rooms exist and NONE of them can be read: every one is archived. */
readonly state: "archived-only";
readonly rooms: readonly RoomSummary[];
readonly archived: number;
readonly note: string;
} | {
/** We could not look. Not evidence in either direction. */
readonly state: "unknown";
readonly reason: UncheckedReason;
readonly note: string;
};
/** What we know about the ONE store a scoped read named. */
export type NamedScope = {
/** The scope exists and we are in it, so an empty result is a real miss. */
readonly state: "present";
readonly name: string;
} | {
/** No store with that byte-exact name in the caller's own bucket. */
readonly state: "no-such-domain";
readonly name: string;
readonly note: string;
} | {
/** That address is not among the rooms this client can see. */
readonly state: "no-such-room";
readonly name: string;
readonly note: string;
} | {
/** We could not look — so nothing may be concluded about the name. */
readonly state: "unchecked";
readonly name: string;
readonly probed: "domains" | "rooms";
readonly reason: UncheckedReason;
readonly note: string;
};
/**
* Where a read looked, and what we know about what it therefore did not cover.
*
* The two halves are ONE value on purpose. A scoped read must not probe the room
* list (a room address is checked against the rooms, a domain against the
* domains — CodeRabbit #65), and an unscoped read must always probe it; as two
* parameters those invariants were merely unwritten, and both were broken at
* different points in this release. Here a `named` scope has no room knowledge
* to be wrong about, and an `own-domains` scope cannot exist without it.
*/
export type ReadScope = {
readonly kind: "named";
readonly named: NamedScope;
} | {
readonly kind: "own-domains";
readonly rooms: RoomScope;
};
/** The disclosure for either kind of scope — "" only where the type says there
* is genuinely nothing to disclose. */
export declare function readScopeNote(scope: ReadScope): string;
/**
* 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 declare function classifyRooms(payload: unknown, sanitize: (s: string | undefined | null) => string): RoomScope;
/** 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 declare function probeRoomScope(fetchRooms: () => Promise<unknown>, sanitize: (s: string | undefined | null) => string): Promise<RoomScope>;
/**
* 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 declare function probeReadScope(searched: string | undefined, fetchStats: () => Promise<unknown>, fetchRooms: () => Promise<unknown>, sanitize: (s: string | undefined | null) => string): Promise<ReadScope>;