@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
269 lines (268 loc) • 15.6 kB
TypeScript
/**
* Naming a thing so the reader can reproduce it — the counterpart to
* `safeInline`, and deliberately NOT the same tool.
*
* `safeInline` (src/render.ts) is a SANITISER: it maps every character outside
* `[\w .@:+/-]` to a space, collapses runs of whitespace, trims the ends and
* truncates. That is the right treatment for a display string chosen by a
* DIFFERENT principal — an agent name — where the only job is to make a
* hostile value harmless to paste into a model's context and nothing about the
* value needs to stay recognisable. (Room names looked like that class and are
* not — see the threat model at the bottom.)
*
* It is the WRONG treatment for any value the reader is expected to compare or
* send back, and it was being used for exactly that (reviews, 2026-08-08):
*
* - `" engineering"` and `"engineering"` rendered identically, so the
* `@domain` tag added in this release could point a reader at the wrong
* store, and `memory_stats` could not support the byte-exact check that
* `memory_delete_domain`'s own description sends the reader there for.
* - `"проект:acme"` and `"план:acme"` both rendered as `:acme` — one lossy
* transform, two distinct stores, one output string. A whitespace-only
* domain rendered as the empty string, i.e. as no name at all.
* - Core's only write-rejection reason, `"Below importance threshold (0.412 <
* 0.500)"`, was relayed under the label `Server reason:` as
* `Below importance threshold 0.412 0.500` — the comparison operator and
* both delimiters deleted from a quote that claims to be verbatim.
*
* Domain names are matched byte-for-byte everywhere in the engine (parameterised
* equality on raw bytes; no trim, no case-fold, no normalisation in the schema,
* the storage layer or a migration), so a name that cannot be reproduced cannot
* be acted on. Hence the rule this module implements: **print a value exactly,
* or do not print it at all.**
*
* ## The renderer
*
* `exactLiteral` returns the value as a JSON string literal, chosen because it
* satisfies both halves of the problem at once and needs no bespoke decoder:
*
* - REVERSIBLE. `JSON.parse(literal) === value`, for every input, including
* lone surrogates. That is the contract, and it is asserted directly in
* test/names.test.ts rather than approximated by sampling characters.
* Spaces stay inside the quotes, so leading/trailing/doubled whitespace is
* visible; control characters become `\n` or a `\uXXXX` escape; non-ASCII
* letters pass through untouched, so Cyrillic survives as itself.
* - INJECTION-SAFE for the surface it lands on. The output is one line: no
* raw newline can forge a new instruction block, `"` and `\` are escaped so
* the quoted context cannot be closed early, and line/paragraph separators
* (U+2028/U+2029, which `JSON.stringify` leaves raw) are escaped here.
*
* On top of `JSON.stringify` this escapes everything invisible or
* whitespace-confusable that it leaves alone: U+007F–U+009F, all format
* characters (zero-width space and joiners, soft hyphen, bidi overrides, BOM),
* line and paragraph separators, and every space separator EXCEPT plain U+0020
* — so a name padded with a no-break space is distinguishable from one padded
* with an ordinary one. Combining marks are left as they are: they are ordinary
* letters in many languages, and the guarantee here is byte-exact round-trip,
* not visual uniqueness.
*
* ## Truncation
*
* A truncated name is not reproducible, so nothing is truncated. When the
* literal would exceed the cap, `exactLiteral` returns `null` and the call site
* must choose a NAME-FREE phrasing ("the domain you passed"). The caps are set
* so this is unreachable for any name the engine can hold — the `atoms.domain`
* column is `VARCHAR(100)` — leaving only pathological values (a hundred
* control characters) unnamed.
*
* ## Threat model, stated because it differs from `safeInline`'s
*
* A `domain` is never another principal's free text. `/memory/stats` reports
* the caller's own org bucket only and never contains a room; an atom that
* lives in a room bucket carries the canonical `xroom:<room_id>` as its domain,
* and core 400s any non-canonical spelling of that while `room_id` itself is
* charset-validated. So every value rendered here is either the caller's own
* string or a machine-shaped address — with one exception. Room NAMES are
* another principal's free text — chosen by an owner, shown to a joiner — and
* they are printed through this module too ({@link roomNamePhrase}, 0.8.1): the
* literal is already the anti-injection treatment (one line; quotes,
* backslashes and invisibles escaped), while the sanitiser it replaces did not
* make hostile names harmless so much as it made ordinary names WRONG — two
* live rooms named "проект" and "план" both rendered "(unnamed room)" in the
* note that asks the reader to pick one, and "Zoë" was quoted as "Zo". A room
* name is display-only (the ADDRESS beside it is what a reader re-sends), so a
* name that cannot be printed within the cap degrades to a phrase saying so —
* never to a truncated or rewritten name, and never to "(unnamed room)", which
* is reserved for a room that genuinely has no name.
*/
export interface ExactLiteral {
/** The value as a JSON string literal, quotes included. */
literal: string;
/**
* True when the literal is more than the value wrapped in quotes — i.e. it
* contains at least one escape, so the text between the quotes is NOT the
* name and must be decoded before it is sent back.
*/
escaped: boolean;
}
/** Longest literal we will print for a domain name in a sentence or a list. */
export declare const MAX_DOMAIN_LITERAL = 256;
/**
* Longest literal we will print as an `@domain` tag on a result line.
*
* Shared, not mirrored: the ` [by "name"]` author tag (`formatAuthorTag`,
* src/render.ts) reuses this SAME constant rather than a second one of its
* own (owner decision I66-4, issue #66, 2026-09-24): one calibrated number,
* not two magic values that happen to agree today and can drift apart later.
*/
export declare const MAX_DOMAIN_TAG_LITERAL = 128;
/**
* The value as a reproducible JSON string literal, or `null` when it cannot be
* printed within `max` — in which case the caller must not name it at all.
*/
export declare function exactLiteral(value: string | null | undefined, max?: number): ExactLiteral | null;
/**
* A domain named inside a sentence: the exact literal, or `fallback` — a phrase
* that names nothing — when it cannot be reproduced.
*/
export declare function domainPhrase(name: string | null | undefined, fallback?: string, max?: number): string;
/**
* A room name as display text, with the three states kept apart because each
* used to collapse into the next (reviews, 2026-08-08):
*
* - a printable name is the exact literal — "проект" is `"проект"`, not
* "(unnamed room)"; "Zoë" is `"Zoë"`, not `"Zo"`;
* - a name that cannot be printed within the cap is SAID to be unprintable —
* claiming the room is unnamed would be false, and printing an altered
* spelling would be worse;
* - only a genuinely absent or empty name is "(unnamed room)".
*
* The phrases carry no quotes of their own, so they cannot be mistaken for a
* printed name: every real name on a page is a quoted JSON literal, and a room
* literally named `(unnamed room)` still renders distinguishably, in quotes.
*
* `name` is `unknown` because two of the call sites read it off the wire where
* the types are aspirational; a non-string is not a name, so it falls to the
* unnamed phrase — the same narrowing `asRoom` (src/scope.ts) applies.
*/
export declare function roomNamePhrase(name: unknown): string;
/**
* How many characters of NAMES the `Domains:` line may spend.
*
* Sized against the tool-result cap it exists to respect: `MAX_RESULT_CHARS`,
* exported from src/tools.ts (and re-exported from src/shared.ts), is 96,000,
* and the reserve covers the four other stats lines, the
* escape legend, and the truncation notice `capResult` appends if it ever fires.
* The relationship is pinned behaviourally — "memory_stats fits the result cap
* without losing its tail", test/handlers.test.ts — so this number cannot drift
* away from that one in silence.
*/
export declare const MAX_DOMAIN_LIST_CHARS = 90000;
/**
* The `Domains:` line of memory_stats — the surface this tool's own
* description sends the reader to "to confirm the exact domain name before
* writing to it", which it could not do while the names went through
* `safeInline`. (Until 2026-08-20, `memory_delete_domain`'s description sent
* readers here for the same reason, before deletion was withdrawn to an
* administrative REST-only operation.)
*
* A name that cannot be printed exactly is COUNTED, never silently dropped:
* dropping it would make this list assert that a store does not exist, on the
* one surface whose job is the opposite. An absent or empty list keeps the
* existing "none reported", which is honest about both of the states core can
* produce (no key on a non-core 200, or a genuinely empty bucket). The
* NON-ARRAY half of that first branch is unreachable from `memory_stats`
* (S7, 2026-09-23): its handler now returns `isError` before calling this
* function when `domains` is not an array at all, since its outputSchema
* requires one; it stays here for the direct unit tests below and as
* defense in depth for any other caller.
*
* `maxChars` bounds the NAMES, and it is the reason memory_stats is no longer
* the one tool result that could exceed the 25K-token Connectors Directory cap.
* This line is linear in the number of stores and nothing bounded it: 4,000
* domains rendered past 100,000 characters, every time, with no hostile input
* involved. Cutting the list here rather than capping the whole message is
* deliberate — `capResult` truncates from the END, so a blind cap would have
* eaten the average-quality line and the reminder that rooms are separate
* stores, i.e. the parts of the answer that are not the wall of names.
*
* What is cut is COUNTED and its cause is NAMED, in a clause kept separate from
* the unprintable one: "the list is too long" and "this name cannot be
* reproduced" are different facts about different names, and merging them would
* put the wrong cause on both.
*/
export declare function formatDomainList(domains: unknown, emptyLabel?: string, maxChars?: number): string;
/**
* The one clause that explains the escaping, so a reader cannot mistake
* `"engineering\u200b"` for a name whose last six characters are a backslash, a
* `u` and four digits.
*
* Deliberately absent unless an escape was actually used: the common case is a
* plain name, where the quotes are the whole convention and a legend would be
* the sort of caveat that trains readers to skip caveats.
*/
export declare const DOMAIN_ESCAPE_LEGEND: string;
/**
* Append {@link DOMAIN_ESCAPE_LEGEND} to `message` if one of `names` was printed
* in it WITH an escape, and the legend is not there already.
*
* Two properties, both by construction rather than by call-site discipline:
*
* - At most once per answer. A message is assembled from several independent
* notes and more than one of them may have named a store.
* - Only for a name that is actually on the page. A caller passes the
* candidates it had; whether a sentence named one of them depends on the
* branch it took (`scopeLabel` says "that room" for a room address, and
* nothing at all for a name it cannot reproduce). A legend that explains
* escapes in a name the reader cannot see is a caveat about nothing.
*/
export declare function withDomainEscapeLegend(message: string, ...names: (string | null | undefined)[]): string;
/**
* The same legend, for a surface that printed its literals under a different
* cap. The result-line tags (`@domain`, `[by "name"]`) refuse a literal longer
* than MAX_DOMAIN_TAG_LITERAL and print a name-free fallback instead, so a
* candidate must be judged under THAT cap there: judged under the larger
* note cap, a name in between the two would count as printed-and-escaped
* whenever its literal happens to appear in a result's content, and the
* legend would explain an escape the tag never showed (review round 2 on
* issue #66).
*
* The at-most-once check looks for the whole legend, not a fragment of it:
* a fragment short enough to fit a printed literal (an author name from
* another connector, say) could otherwise appear inside a tag and suppress
* the legend for a genuinely escaped name elsewhere on the page. The whole
* legend is longer than any literal either cap admits.
*/
export declare function withEscapeLegendAt(max: number, message: string, ...names: (string | null | undefined)[]): string;
/**
* A third renderer, for values that go into `structuredContent` as the
* service sent them rather than into a sentence a reader compares by eye.
*
* `exactLiteral` is for a value the reader must reproduce byte-for-byte (a
* domain name): it either prints the value exactly, quotes included, or
* refuses to print it at all (the TEXT path for a write's `reason` keeps
* using it, `src/tools.ts`, `reasonQuote`). `structuredContent` is a
* different surface: a consumer reads `reason` as a plain string field, not
* as a quoted literal it must decode, so the same escaping convention does
* not apply. What still applies is CN-032: `reason` is text the memory
* service chose, and it reaches a caller's context unquoted, so it must be
* stripped of the same instruction-hiding character classes `safeInline`
* strips for a display string, while leaving ORDINARY text untouched,
* because core's only write-rejection reason,
* `"Below importance threshold (0.047 < 0.1)"`, must survive with its
* parentheses and `<` intact, which `safeInline` itself does not guarantee
* (it deletes both; see the block comment at the top of `src/render.ts`).
*
* Matches the connector's own field description for this value verbatim:
* "Ordinary text is preserved exactly; only control, bidi, zero-width, and
* repeated-whitespace characters are normalized before display"
* (mnemoverse-mcp-remote, `memoryWriteOutput.reason`). The character classes
* are copied from its `safeInline` (src/tools/index.ts) so the two servers
* normalise the same bytes the same way:
*
* - C0 controls (U+0000-U+001F) and C1 controls (U+007F-U+009F), which
* includes tab, newline and CR, are each replaced with a space, not
* deleted, so removing one cannot glue two words together.
* - Bidi controls (U+061C ALM, U+200E-U+200F LRM/RLM, U+202A-U+202E the
* explicit embedding/override pairs and PDF, and U+2066-U+2069 the
* isolate pairs and PDI) are also replaced with a space.
* - Zero-width format characters (U+200B-U+200D ZWSP/ZWNJ/ZWJ, U+2060 word
* joiner, U+FEFF ZWNBSP/BOM) are dropped with no replacement, since they
* sit inside words rather than between them.
* - Whitespace runs (including the spaces just introduced) are then
* collapsed to one space each, and the result is trimmed.
*
* Reused by later slices for the other free-text fields the service can
* return unquoted: room name, `share_message`, `next_steps`.
*/
export declare function structuredText(s: unknown, cap: number): string | undefined;