@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
194 lines • 11.7 kB
JavaScript
/**
* Teaching surface — the strings and branch logic that teach a connected model
* how to USE this memory, kept in their own module so tests can import them
* without booting the stdio server (src/index.ts starts a transport on import).
*
* Product frame, first set 2026-08-01 and revised 2026-09-29: the guidance is
* never gated on the user explicitly asking, but it is also never a push. The
* Claude connector directory rejects descriptions that "tell Claude how to
* behave" or "direct Claude to pull behavioral instructions from external
* sources", and its submission form asks the publisher to confirm that "tool
* descriptions contain no instructions about model behavior, other tools, or
* external instruction sources". So this surface says what each tool does and
* when it applies, and proactivity lives in rules the user adds to their own
* agent: the setup_memory prompt (src/prompts.ts) renders them for CLAUDE.md,
* AGENTS.md, Cursor rules or a chat's preferences, and the host plugins carry
* the same skill. Honesty constraints stay: no over-claiming, and the
* never-store-secrets safety line is kept on write surfaces.
*/
/**
* Server-level instructions, passed to the McpServer constructor and landed
* verbatim in the connected model's system prompt by MCP clients.
*
* Constraints (tested in test/teaching-surface.test.ts):
* - 500–800 chars; some clients (ChatGPT) weight the FIRST ~512 chars, so the
* core habit (read before answering, write on learning) is front-loaded.
* - No brakes ("only when the user explicitly asks") and no pushes
* ("proactively", "always", "don't wait to be asked", "as a habit"):
* test/descriptions.test.ts bans both on every advertised surface.
* - Names the setup_memory prompt, the only pointer to guidance, and it points
* inside this server, never to a URL (2026-09-29).
* - Mentions every tool family: read/write/stats/feedback, rooms, vault.
* - No delete tool on this surface (2026-08-20): deletion was withdrawn to an
* administrative REST-only operation, so the instructions point a model at
* writing a corrected memory instead of asking to delete one.
*
* 2026-09-29 rewrite (793 chars): the ownership and habit framing and "don't
* wait to be asked" are gone (see the product frame above); the text says what
* each tool family is for, and ends on the setup_memory prompt. To fit it under
* the cap, the room-tool sentence ("Rooms: memory_create_room,
* memory_invite_to_room, memory_join_room") and "deletion is administrative"
* were dropped: the room rule itself
* and "write a fresh one" are kept verbatim, and the fact that deletion is not
* a tool stays stated in memory_read's description and in the README.
*/
export const SERVER_INSTRUCTIONS = "Long-term memory kept across sessions and AI tools on this account. memory_read searches earlier preferences, decisions and project context when an answer may depend on them. memory_write saves a durable fact, preference or decision. memory_list_recent is newest-first; memory_stats shows counts and domains; memory_feedback records which recalled memories helped an answer, which reorders later reads; memory_graph shows links. Shared rooms are SEPARATE stores: to read one, pass its address as domain; unscoped reads never cover rooms. To correct a memory, write a fresh one. vault_list names secrets by alias, never values. Never store passwords, API keys, payment data, MFA codes, government IDs, or health records. The setup_memory prompt explains memory rules for CLAUDE.md or AGENTS.md.";
/** The pre-existing zero-result message — kept as the fail-open fallback. */
export const NO_MATCH_MESSAGE = "No memories found for this query.";
/** Hint for an UNSCOPED no-match against a non-empty store: widen the query.
* (No drop-the-filter clause — none was set; advising to remove a filter that
* does not exist nudges the model into confabulating state.) */
export const NO_MATCH_HINT = " Try a broader query.";
/**
* Hint for a DOMAIN-SCOPED no-match: widen the query, or try another domain —
* and NEVER drop the scope.
*
* This doc used to read "here a filter genuinely exists, so suggesting to drop
* it is honest and actionable", which described a draft that no longer exists:
* the clause "…or drop the domain filter to search all domains" was deleted, and
* a test now forbids it. The advice was actively harmful when the domain is a
* room — an unscoped read does not cover rooms at all, so dropping the filter is
* the one move guaranteed to lose the content the reader is hunting, and it is
* the step that cost two agents a day on 2026-08-07.
*/
export const NO_MATCH_SCOPED_HINT = " Try a broader query, or a different domain.";
/**
* The honest line for an account whose OWN domains are empty but whose rooms
* are not — an invited teammate whose every memory lives in shared rooms.
*
* Without this, such a caller was greeted with EMPTY_STORE_WELCOME: "your
* memory is empty, nothing has been saved yet, which is why this search
* returned nothing" — three false clauses, immediately contradicted by the
* scope note appended underneath, in the same payload (review, 2026-08-08).
* That reader is precisely the person from the incident this release is about.
*
* ENDS WITH A COLON, so it is only ever emitted together with the disclosure
* that follows it. That pairing used to be two independent decisions and they
* disagreed: this line was chosen from a flag counting ALL rooms while the
* disclosure was built from the LIVE ones, so an account whose only room was
* archived received the colon and nothing after it. Both now come out of the
* same {@link RoomScope} arm, and the arm that has nothing to disclose has no
* `note` field to forget.
*/
export const EMPTY_PERSONAL_STORE_WITH_ROOMS = "Nothing in your own domains — they hold no memories yet. That is not the whole picture:";
/**
* The same situation with the room probe UNANSWERED. Says the one true thing —
* the personal store measured zero and the rest could not be checked — and
* deliberately does NOT say "nothing has been saved yet", which was reachable
* here and is a claim about a scope this client failed to reach.
*/
export const EMPTY_PERSONAL_STORE_ROOMS_UNCHECKED = "Nothing in your own domains — they hold no memories yet. Whether that is the whole picture could not be checked:";
/**
* First-contact greeting: shown ONLY when a read comes back empty, the personal
* store holds zero memories, AND the room probe answered that there are no rooms
* — i.e. the very first read of this account's life, established rather than
* assumed. Seeds the ANSWER, not the store: it says what this store is and
* how a memory gets into it. It states and does not instruct (2026-09-29):
* directory reviews treat instructions inside a tool result like instructions
* in a description.
*/
export const EMPTY_STORE_WELCOME = "Your long-term memory is empty — nothing has been saved yet, which is why this search returned nothing. " +
"Memories saved with memory_write persist across sessions and across every AI tool this user has connected, " +
'and memory_read finds them later; a memory is a self-contained statement such as "User prefers TypeScript strict mode".';
/**
* Compile-time exhaustiveness. Adding a state to `RoomScope` / `NamedScope`
* without answering for it here is a type error, which is the point: the state
* this release exists to fix ("we could not check") was reachable precisely
* because it had no arm of its own and fell into the falsy one. Returns the
* neutral message rather than throwing, so an unreachable branch can never take
* a handler down.
*/
function noStateLeftUnhandled(state) {
void state;
return NO_MATCH_MESSAGE;
}
/** The disclosure a room state owes the reader — "" only for the state whose
* type says there is nothing to disclose. */
function roomsTail(rooms) {
return "note" in rooms ? rooms.note : "";
}
/**
* Assemble the WHOLE answer for a zero-result read: the head sentence and the
* disclosure that belongs with it, in one place.
*
* They are assembled together on purpose. While the head came from here and the
* tail was concatenated by the caller, the two could be — and were — derived
* from different facts: a head promising "that is not the whole picture:" with
* an empty tail after it, and a first-contact greeting under a tail admitting
* the room list could not be fetched. Every arm below returns a complete answer,
* so a head cannot outlive the evidence for it.
*
* A NAMED scope never greets and never probes stats: that probe measures the
* PERSONAL store, so on a scoped read it could claim "the store is empty" about
* a domain whose memories the query merely missed. Its four arms are the four
* things we can know about the name.
*
* An UNSCOPED scope makes ONE stats call, on this path only:
* - stats unreachable / no number → the plain no-match message + the disclosure
* - total_atoms > 0 → no-match + broaden hint + the disclosure
* - total_atoms === 0 → one head per room state, each with its own
* disclosure; only `rooms.state === "none"` may claim the memory is empty,
* because only there has the claim been established.
*/
export async function buildReadEmptyResponse(fetchStats, scope) {
if (scope.kind === "named") {
const named = scope.named;
switch (named.state) {
case "present":
// The store is there; the query missed. The hint suggests a wider query
// or another domain and never suggests dropping the scope — when the
// domain is a room, dropping it is the one move that guarantees the
// content stays hidden (the 2026-08-07 incident).
return NO_MATCH_MESSAGE + NO_MATCH_SCOPED_HINT;
case "no-such-domain":
case "no-such-room":
// A SPECIFIC diagnosis replaces the generic advice; it does not follow
// it. Printed the other way round, a model reading top-down went off to
// widen a query against a store that does not exist.
return NO_MATCH_MESSAGE + named.note;
case "unchecked":
// Not a diagnosis — an admission. So the generic advice stays, and the
// admission is added rather than substituted. This is the case that used
// to be spelled exactly like "present".
return NO_MATCH_MESSAGE + NO_MATCH_SCOPED_HINT + named.note;
default:
return noStateLeftUnhandled(named);
}
}
const rooms = scope.rooms;
let totalAtoms;
try {
totalAtoms = (await fetchStats())?.total_atoms;
}
catch {
return NO_MATCH_MESSAGE + roomsTail(rooms);
}
if (typeof totalAtoms !== "number")
return NO_MATCH_MESSAGE + roomsTail(rooms);
if (totalAtoms > 0)
return NO_MATCH_MESSAGE + NO_MATCH_HINT + roomsTail(rooms);
// The personal bucket measured zero. `total_atoms` counts ONE org, so what
// that means for the account depends entirely on the rooms.
switch (rooms.state) {
case "none":
return EMPTY_STORE_WELCOME;
case "live":
case "archived-only":
return EMPTY_PERSONAL_STORE_WITH_ROOMS + rooms.note;
case "unknown":
return EMPTY_PERSONAL_STORE_ROOMS_UNCHECKED + rooms.note;
default:
return noStateLeftUnhandled(rooms);
}
}
//# sourceMappingURL=teaching.js.map