UNPKG

@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
/** * 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>;