MemAI
MemAI is a long-term memory server for AI agents, storing persistent, cross-session memories in a single SQLite database with hybrid BM25+vector search and ACID transactions. It supports:
Write various memory types:
note(facts, decisions, findings),checkpoint(current work state),anti_pattern(pitfalls),reasoning(traces),handoff(inter-agent messages).Retrieve flexibly:
search(hybrid keyword+semantic),recall(notes only),pulse(session warm-up with latest checkpoint, open handoffs, anti-patterns, and recent notes),list_by_domain/list_recent(recency-ordered),list_domains(domain tree with stats), andget_memory(full details including history and relations).Manage domains: Hierarchical domains with subdomain filtering and domain stats.
Edit and correct:
edit_memory(preserves history),set_confidence(unverified, confirmed, contradicted).Link memories:
link_memorieswith typed relations (supersedes, relates_to, etc.),get_relations.Delete safely:
forget(soft-delete, reversible) andpurge_memory(permanent, requires confirmation).Curate and optimize:
dedup_scan(detect duplicates/contradictions),optimize_scan(corpus dump with hints), and staging suggestions for human review viaoptimize_stage,optimize_runs,optimize_status.Help and discovery:
helpfor tool summaries/documentation.Additional features: Local embeddings (offline CPU with bundled or custom Hugging Face models), web dashboard for human curation (bulk actions, graph view, domain management).
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@MemAIremember that my name is Alice"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MemAI
A long-term memory MCP server for AI agents. Agents call its tools to write memories — facts, decisions, checkpoints, pitfalls, documented flows — during a session and read them back in later ones, which is the state an MCP server's own process does not keep between conversations.
One SQLite file holds all of it: rows, keyword index, vectors, edit history, relations, diagrams. A local admin dashboard serves the same store as the human curation surface.
Storage
A single SQLite file in WAL mode:
table | holds |
| the rows: type, domain, session, tags, content, status, confidence, timestamps, recheck date and source reference |
| the extra domains a memory belongs to, one row per path — every domain filter reads it |
| FTS5 (BM25, |
| sqlite-vec |
| how often each memory was read back, and when — fed by the MCP tools only |
| full history; correcting a memory keeps the previous version |
| typed edges between memories ( |
| documented flows and what they point at |
| curation staged for human review |
| store settings, plus the embedding model and dimension behind the vectors |
sqlite-vec hooks SQLite's transaction lifecycle, so a vector commits or rolls back with the row it belongs to.
Related MCP server: heropen
Embeddings
A model2vec static model
(minishlab/potion-base-8M) ships bundled in the package; MEMAI_EMBED_MODEL
selects another one by Hugging Face repo id or local path.
meta records the model name and dimension; if either changes, all vectors are
dropped and re-embedded in one transaction on the next connect, since vectors
from one model are meaningless in another's space. If the model cannot load,
writes proceed without vectors, retrieval degrades to keyword-only, and the
gaps are backfilled on a later connect.
Retrieval
search is hybrid: FTS5 BM25 plus brute-force cosine KNN over the vectors (no
ANN index — a linear scan is plenty at this scale), merged by reciprocal rank
fusion. Each result says which side matched (match_source: fts | vec |
both) and carries the raw fts_rank / vec_distance. Multi-term queries are
OR'd on the keyword side, so several paraphrases in one call all help.
The two arms do not get the same vote. Plain RRF assumes both retrievers are
equally informative; measured against 216 pairs a human had marked as related
in a real store, an equal vote was a cliff — relates_to recall@5 fell from
66% (keywords alone) to 55.7% fused, because the weaker arm displaced keyword
hits at the same rank. At any weight below equal it lands on one flat plateau
at 69.1%, above keywords alone: the vector arm has real signal (it found 11
targets the keyword arm missed) and simply cannot outrank a keyword hit. The
weight is 0.5, the middle of that plateau.
Both arms fetch exactly limit before the merge. Fetching deeper is the
textbook move — fusion exists to recover the row ranked just outside one arm's
window — and against a real 536-memory store it cost recall at every step:
100% for the keyword arm alone, 100% fused at 1x, 96.7% at 2x, 90% at 4x, 85%
at 8x. Depth is a claim that both retrievers are informative. Measure it
against the store before raising it.
The vector arm is bounded by distance as well as by count. A KNN has no
notion of "nothing here is close": asked for 200 rows it returns the 200
nearest however far away they are, so a search for a word the store had never
seen came back with the whole store, every row past the keyword matches
labelled match_source: vec as if the vector side had found it. Measured over
a real store, the best distance the vector arm reaches is 0.157–0.555 for a
query with a real target and 0.68–0.93 for one without; the cut sits at 0.60,
which drops the sweep to nothing and costs no recall. It is calibrated to this
model — re-measure it if you change MEMAI_EMBED_MODEL.
BM25 is weighted per column. domain and also_domains are indexed so a scope
name is findable, not so that every row filed under acme/cache outranks the
one memory that discusses the cache; in a store organised by domain, unweighted
that is most of the store.
Three annotations ride along. succeeded_by names a memory that supersedes this
one, read from the relations graph, so a superseded hit does not come back
looking current with its replacement one edge away. collapsed names near-identical
results folded into this one, so a fact written five times spends one slot;
that folding is on for the MCP tools and off for the dashboard, which has to
see the copies. And confidence: contradicted sorts behind everything that
still holds without disappearing — knowing a claim was ruled out is what stops
it being written again.
Every list-style read is snippet-truncated at 400 characters, so each result
carries est_tokens — the estimated cost of the record's full content, not of
the snippet — and the response carries the sum over what it returned, which is
what turns a get_memory(uid) into a priced decision rather than a guess. It is
a fixed ratio over the character length, not a tokenizer. The four list tools
return {"results": […], "est_tokens": N} for that reason; pulse prices every
record it hands over, and get_memory does not — it already returned the whole
thing.
Both retrievers only widen the candidate set; whether a candidate answers the
query is the calling agent's judgement. list_by_domain / list_recent are
the fallback for a search that comes back thin.
timeline asks what the retrievers cannot: not what mentions a record, but what
was being written when it was. Anchor it with a uid, or with a query whose
top hit becomes the anchor (the response says which was used and which record it
landed on), and it returns the records created immediately before and after
it, in creation order. domain and type narrow the neighbourhood rather than
the anchor, so one call reads a whole store's week or one routine's.
pulse and the list_* tools sort by created_at DESC, never by similarity —
a similarity top-1 can surface an old memory over a current one, so the
"what is the latest state" tools stay recency-only.
pulse is the state of a scope, not its contents: each list stops at a handful
and its scope block is the rest — where it read (paths), what the scope
holds (total, by_type), what each list left behind (not_shown), and the
level below it (subdomains, with own and subtree counts). That is the
drill-down plan for search(query, domain=…) or list_by_domain(domain, type=…).
Domains
A memory's domain is a path: acme/x100/p200 is a routine inside a module
inside a product. Every read that takes a domain covers its subdomains, so
pulse('acme/x100') is the module-wide brief and pulse('acme/x100/p200') the
routine's; subtree=False narrows a list to one level. list_domains()
returns the tree, per level: what is filed on it, what its subtree holds, and
whether it exists only because something deeper is filed under it.
A filter also resolves a name that is only the deep end of a path, since a
caller usually has the routine's code and not the product above it:
list_by_domain('p200') finds the routine at acme/x100/p200. The literal
reading wins, an ambiguous name covers every branch holding it, and a response
with room for it reports domain_scope. A rename moves exactly the path it was
given.
The nesting lives in the string — no domains table, no id to resolve. Casing is
a store-wide policy (preserve | lower | upper); a non-conforming domain is
adjusted on write, not rejected.
A memory can belong to more than one. The path says where it lives — one
parent chain, the thing a re-home renames. also says which subjects it is
part of, and those cut across the tree: several routines can each be a step
of one end-to-end process without any being the parent of the others.
note(..., domain='acme/x100/p200', also='omni/x900') files on the routine and
makes every read scoped to omni/x900 return it too, subtree included.
A cross-listing is a membership, not a move: domain is untouched, re-homing
omni/x900 retargets the memberships pointing into it, and a path the memory's
own domain already sits under is dropped as redundant. A path can exist only
as a cross-listing — an end-to-end flow whose every step lives under some other
branch — and it is a scope like any other, counted apart from the filed rows
(also/subtree_also in list_domains(), scope.also in pulse) so the
tree never totals more than the store.
Diagrams
diagram() documents a routine start to end as a graph, stored as
type='diagram'. A node has a stable key, an objective label, a shape
(start | step | decision | io | end) and an optional note carrying
the reasoning; an edge carries the condition on a branch. Exactly one start
is required and every node must be reachable from it; cycles are allowed.
Steps are addressable, which is what makes a diagram the index of its domain
rather than prose: diagram_link() attaches a note/anti_pattern/reasoning to
one step (visible from both ends), diagram_jump() continues a step into
another flow (stored once, reported on both), diagram_node/diagram_edge
patch one piece at a time.
Node positions are stored server-side, so every reader sees the same picture
and an arrangement made in the dashboard persists; diagram_relayout()
rebuilds them. get_diagram(uid, format=…) reads it back:
format | what it gives |
| the canvas drawing in a pan/zoom shell — the one to show |
| the same drawing as a plain file, to attach or link |
| portable, but re-lays out and discards the stored positions |
| the prose projection kept as the memory's content |
| the full graph with positions, notes and links; the only round-trippable one |
Both SVG formats write the markup to $MEMAI_HOME/renders/ and return the path
plus a thin index of the steps. A render is a cache of a record that lives in
the database, so it is swept per the retention setting in the dashboard.
Curation
A memory about code is true until the code changes, and nothing in a store
notices that. The durable writers (note, anti_pattern, reasoning,
diagram) take review_after — a date, or a span from today like 90d — which
is the writer's own estimate of when its claim stops being safe to trust
unchecked, and source_ref, which says what to check it against. Both are
optional and most memories should leave them empty; a date nobody meant is
worse than none.
What they buy is that the store can flag its own decay: pulse reports
scope.stale when a scope holds memories whose date has passed, optimize_scan
marks each one due and lists its source_ref, and a curation pass pushes a
date out with a review suggestion after actually rechecking. Verifying a
source_ref against a live tree is not something the store does — it has no way
to know where that tree is.
Curation starts at the write. A writer probes the store for what it just wrote
and returns similar when something crosses the threshold, plus one line on
what to do about it — the agent still has the context that produced the text,
so that is the one moment when "the store already said this" is free to act on.
It never blocks the write. Quiet by design, or the field gets trained away:
diagrams are out on both sides, archived rows are out, and consecutive
checkpoints of one effort are out because they share a skeleton by design.
dedup_scan surfaces likely-duplicate pairs by lexical overlap. The full pass
is two tools: optimize_scan dumps the corpus compactly — each memory's
curation fields, the relation edges, dedup hints, domain-variant clusters, flat
domains that already spell a hierarchy, and per-memory anchors (URLs, paths,
identifiers) to check against live facts — with paging and since for
incremental passes. It also carries recalls/last_recall per memory and
never_recalled over the whole corpus: without those a pass judges text, and
cannot tell the note answered three times a week from the one nobody has needed
since it was written. optimize_stage writes a batch of suggestions to a run:
compact, reword, retag, redomain, crosslist, set_confidence,
review, archive, link, merge, distill.
Nothing is applied there: the human reviews each one in the dashboard, which
backs up before the first apply and can undo any of them. Destructive kinds
require a non-empty verified describing the live-facts check behind them.
forget is a soft delete — content kept, row out of default search/list output
(status: archived). purge_memory permanently deletes the row plus its edit
history and relations, gated on a confirm_phrase equal to "DELETE <uid>",
a string that can only plausibly come from a human confirming that id.
Tools
help() returns every tool with a one-line summary; help(command='<name>')
returns that tool's signature and its full documentation, read live from the
code. "Full" is the operative word: a tool's description is sent with every
request for the whole session, so the reasoning and worked detail live behind
help() and only what a caller needs in order to choose correctly stays in the
schema.
The same arithmetic applies to the tool list itself. All 36 schemas cost about
9.5k tokens of every request whether or not the session ever documents a flow
or runs a curation pass, so MEMAI_TOOLS names the groups to publish:
| tools | ~tokens/request |
| 36 | 9.5k |
| 30 | 7.7k |
| 28 | 7.7k |
| 22 | 5.9k |
core is the reading, writing, editing and linking surface; diagrams is
authoring one (get_diagram stays in core — reading a flow is a read);
curation is the optimize/dedup pass plus the store-wide settings and
purge_memory. Any group implies core. The default publishes everything,
because dropping a tool an existing setup calls is not something to do
quietly — and either way help() documents every tool and names the ones this
process did not load, so an agent that needs one gets told how to turn it on
instead of concluding memai cannot do it.
Writing | |
| Save a fact/decision/finding ( |
| Save work state; fields are free-length |
| Save a pitfall to avoid repeating |
| Save a reasoning trace |
| Leave a note for another agent/session |
Reading | |
| Warm-up: latest checkpoint, open handoffs/anti-patterns, recent notes, flow titles, |
| Hybrid BM25 + vector search, source-annotated |
| Relevance-ranked recall of |
| Recency-ordered, scoped to a path and its subdomains |
| Recency-ordered, global |
| The records written either side of one memory, in |
| The domain tree: own/subtree/cross-listed counts and latest activity |
| Full record: edit history, relations, referencing diagrams |
| Relations for a memory |
Diagrams | |
| Document a routine as a graph |
| Add, patch or remove one step |
| Wire two steps, relabel or remove the wire |
| Attach a memory to one step |
| Continue a step into another flow |
| Recompute the stored node positions |
| Read a diagram back (formats above) |
Editing and domains | |
| Correct a memory, or |
| Create a typed relation |
|
|
| Cross-list a memory into one more path |
| Drop one cross-listing; where it is filed is untouched |
| Read/set the domain-casing policy |
Curation and deletion | |
| Likely-duplicate pairs, for review |
| Dump the corpus compactly to plan a curation pass |
| Stage a batch of suggestions for human review |
| What was staged, and what the human applied or rejected |
| Soft delete (archive, reversible) |
| Hard delete, requires |
| Tool docs read live from the code's docstrings |
Writer tool names match the type they store (note() → type='note',
reasoning() → type='reasoning', ...), so the verb an agent calls is exactly
the string it later filters on.
Getting it read
A memory server has one failure mode that dwarfs the rest: nothing goes wrong, and the agent simply never calls it. Three things address that, in order of how little they ask of anyone.
Server instructions. Sent in the MCP handshake and injected into the model's context by hosts that support it — a paragraph naming the read tools and the write ones. Nothing to configure.
The warm_up prompt. MCP prompts are invoked by the person, which makes
this the one place in the protocol where the store can be read without the agent
having decided to read it. Hosts that surface prompts show it as a command; it
returns the same brief the hook below emits.
memai-hook. A console script that reads the SQLite store directly — no MCP,
so there is no server to wait for, no tool to have been loaded, and no race with
the host's own startup. It reads the hook payload on stdin and writes one JSON
object on stdout, and it never fails loudly: no store, an unreadable one, a
payload that is not JSON, all exit 0 with no output. guard is the one
exception, and the only event that reads the call rather than the store.
event | what it emits |
| the store's state as context — counts, active domains, latest checkpoint, open handoffs, pitfalls, recent notes, documented flows — ending in the instruction to call |
| a reminder that what should outlive the transcript belongs in the store |
| a nudge to checkpoint, and only when nothing was written recently — a timer-based nudge fires whether or not there is anything to record, which teaches the agent to skip it |
| refuses a memai write whose required text never arrived — |
The instruction rides along with the context instead of arriving on its own per-prompt hook: one event to register, and this text is already read at the moment the instruction is needed. Nothing searches on a prompt's words — what a person writes is instruction far more often than subject, so a search on it answers confidently about a different subject, and an agent that is told to open the subject picks the domain itself.
Why the guard is one of them. A tool call is written as tagged parameters,
and a tag opened without the antml: prefix is dropped by the parser before the
call leaves the client: the server never sees the parameter, and the text it
held is gone. The server then names one missing field at a time, so each retry
reads as a new problem and reusing the text block carries the typo with it —
measured on one host's transcripts in August 2026, ~50 calls blocked across 10
sessions, 32 of them anti_pattern and 18 checkpoint. The guard refuses the
call naming that cause; a parameter the tool does not require cannot raise
anything at all, so those are reported as a systemMessage and the write goes
through. What it checks is read from the tool signatures and tested against
them, and everything else — another server's tool, one it does not guard, a
payload it cannot read, an error of its own — goes through untouched.
Register all four with:
memai-hook install # the user's ~/.claude/settings.json
memai-hook install --skills # copy the bundled skills into ~/.claude/skills/
memai-hook install --check # hooks and skills; exit 1 unless every hook
# is registered as this version writes it
memai-hook install --print # what it would write, writing nothing
memai-hook install --settings <path> # write that block somewhere elseHooks on the same event that memai did not write are left alone, memai's own
entries are replaced rather than appended, and an existing settings file is
copied to <name>.bak-<stamp> first. The command is registered as an absolute
path with forward slashes — a command hook is handed to a shell, and a POSIX
shell reads the backslashes of a Windows path as escapes.
One scope: the user's settings
~/.claude/settings.json, with the skills directory beside it, is the scope
memai installs into and the only one it reads back. One registration covers
every project.
--settings <path> writes the same block into any other file — a repository's
.claude/settings.local.json included. Nothing reads that file back, and
nothing reports it as out of date; keeping it current is yours.
While the user's settings register no memai hook, the server's MCP instructions
carry a line asking for memai-hook install. Once they do, the instructions stay
quiet until the installation is out of date, and then they name what drifted and
the command that fixes it: a host event that is not registered, a registration
whose command has left the disk, one that fires this version's command through an
entry it would write differently, or a bundled skill whose installed copy is
untouched while the bundle has moved on. An install does not keep itself current,
and nothing writes to ~/.claude without being asked.
A registration that fires some other memai-hook still on disk is left alone —
edit the command by hand and it stays yours.
--domain narrows the session brief, --budget caps the characters it emits,
--quiet-minutes how recent a write has to be for stop to stay quiet. The
registration itself, which install writes for you:
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "memai-hook session-start" }] }
],
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "memai-hook pre-compact" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "memai-hook stop" }] }
],
"PreToolUse": [
{
"matcher": "mcp__[Mm]em[Aa][Ii]__(note|reasoning|handoff|checkpoint|anti_pattern)",
"hooks": [{ "type": "command", "command": "memai-hook guard" }]
}
]
}
}The hook reads MEMAI_HOME from its own environment, so a store outside
~/.memai has to be set where the hook can see it — the host's env block
reaches the MCP server, not a hook process.
Writes carry a session stamp derived per server process unless one is passed,
so a conversation's memories group together in the dashboard without an agent
having to remember an id.
The status line
memai-hook statusline reads the same store the hook events do and writes one
plain line — not the JSON object an event emits — for a host that renders a
status line:
memai 128 mem | acme/x100 | cp 3h agoHow much is stored, the busiest domain, and how old the latest checkpoint is,
in under 80 characters. The domain path is the field that gives way when the
line would be too long. --domain scopes all three. Same tolerance as the
events: an empty store, an unreadable one, junk on stdin — no line, exit 0.
The domain is ranked on the memories naming that exact path, filed there or cross-listed there, and not on its subtree, so a parent holding nothing of its own never outranks the child doing the work.
Bundled skills
The package ships agent skills as Markdown under memai/skills/, one directory
per skill: how to use the store, and the curation pass as an orchestrator plus
one skill per decision it makes. memai-hook install --skills copies them into
the skills/ directory beside the settings file it would otherwise register
hooks in — ~/.claude/skills, or beside a --settings target.
Only the names memai ships are read or written: a skill directory it does not
ship is left untouched, and a file it would overwrite is copied to
<name>.bak-<stamp> first. A file already holding the bundled bytes is left
alone, so a second run copies nothing.
--check reports each bundled skill as installed, outdated or missing
alongside the hooks, but only the hooks decide its exit code: a hook that is
missing puts the store out of reach, while an uninstalled skill is a choice.
--check --skills moves the gate to the skills.
Each run leaves a receipt in that directory — .memai-skills.json, holding one
sha256 per installed file and the memai version that wrote it — and the states
are read against it:
state | what it means |
| the bundled bytes are there |
| what is there is the copy the receipt recorded, and the bundle has moved on — an update waiting to be copied |
| somebody changed it after it was installed |
| the directory is not there |
Only outdated reaches the MCP instructions; missing and edited are left
alone. --check prints the version that installed them, and says when an edited
skill is also behind the bundle. Re-installing takes the update and leaves the
local copy in <name>.bak-<stamp> beside it.
Both sides are compared over the same file names, so a backup sitting inside a
skill directory is not a difference, and edited is judged against the names the
receipt holds, so a bundle that gained or lost a file can still tell an untouched
copy. Without a receipt, a skill whose bytes differ reads as outdated, and the
next install records the hashes.
Admin dashboard
memai-admin (or python -m memai.admin) serves a local web dashboard over
the same store at http://127.0.0.1:8888 (loopback only;
--host/--port/MEMAI_ADMIN_PORT to change):
Overview — counts, confidence meter, per-type distribution, 30-day activity, vector coverage, recent memories, active domains.
Memories — hybrid search + filters (type/domain/status/confidence/ session), bulk actions, and a record drawer: edit content with history and line-level diffs, edit metadata with re-embedding (cross-listings included), confidence triage, archive/restore, relations, guarded purge. A row that matches a domain filter only by cross-listing says so.
Graph — force layout of the relations graph; drag, zoom, click to inspect, link mode to create relations.
Diagrams — the documented flows, and a canvas editor for one: add and patch steps and edges, drag the arrangement, attach memories to a step, jump to another flow, relayout, export mermaid.
Domains — the tree, one expandable row per level with filed, subtree and cross-listed counts. Move/rename/merge (re-homes the subdomains too; every affected row is re-embedded and audited), archive/delete, casing policy and normalization, spelling-drift detection between siblings (
acme/Cachevsacme/cache).Optimization — the staged runs: each suggestion as the set before and after, applied or rejected one at a time or in a batch behind a safety backup, and undone individually.
Maintenance — integrity/FTS/vector health checks, FTS rebuild, vector backfill/re-embed, orphan cleanup, VACUUM, timestamped backups (
VACUUM INTO), render retention, a dedup review queue, the audit trail.
Destructive-action parity with the MCP tools is kept: archiving is the default
"delete", purging demands the literal DELETE <uid> typed into the UI.
It is meant to run on the machine that holds the store, so it has no login: it
binds to loopback and refuses cross-origin requests — any Sec-Fetch-Site or
Origin that is not this server, and any write that is not
application/json, so another page you have open cannot POST to your port.
--host on something other than loopback serves the whole store to anyone who
can reach it, and prints a warning saying so.
It runs on Starlette + uvicorn, which the mcp SDK pulls in anyway (declared
here too, since this package imports them and the SDK does not bound them). The
front end is plain ES modules, no build step: webui/core/ for the router and
shared machinery, webui/views/ one module per section,
webui/diagram-engine.js for the canvas. UI text lives in
webui/i18n/<code>.json, one file per language — English and pt-BR ship, and
only the fallback and the active locale are fetched. Roboto is bundled in
webui/fonts/, under the SIL Open Font License 1.1 (webui/fonts/OFL.txt,
separate from MemAI's MIT licence).
Measuring retrieval
Retrieval changes are easy to argue about and hard to be right about — two in
this repo shipped on textbook reasoning and cost recall before anyone measured
them. tools/bench-retrieval.py scores a store against ground truth the store
already holds: a diagram step's label and the memory linked to explain it, and
relates_to edges. Both are pairs a person asserted were related, so no
labelling session is needed.
python tools/bench-retrieval.py --home /path/to/a-copy-of-a-storeIt reports recall@k per arm and fused, the ceiling any fusion of those two arms
could reach, and how far off the misses were. Point MEMAI_EMBED_MODEL at
another model and run it again — that is the whole procedure for deciding
whether a model change is worth it. Run it against a copy: opening a store
applies any pending migration.
Setup
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # or .venv/bin/pip on non-Windows
pytestOn Windows, install.bat does the venv + install steps and run-admin.bat
starts the dashboard (both activate .venv themselves; extra arguments pass
through, e.g. run-admin.bat --port 8890).
Register it as an MCP server (e.g. in a Claude Desktop / Claude Code MCP config) pointing at the installed console script:
{
"mcpServers": {
"memai": {
"command": "memai-mcp"
}
}
}Starting the dashboard with it
The MCP server can bring the dashboard up with it, so a session begins with both. Off unless asked — a memory server has no business opening a web port uninvited:
{
"mcpServers": {
"memai": {
"command": "memai-mcp",
"env": {
"MEMAI_HOME": "/path/to/your/memai-store",
"MEMAI_ADMIN_AUTOSTART": "1",
"MEMAI_ADMIN_PORT": "8888"
}
}
}
}Every variable MemAI reads belongs in that block: a server the host launches
sees this environment and no other, not your shell's. MEMAI_ADMIN_PORT is
shown at its default, for when 8888 turns out to be taken; MEMAI_HOME is a
placeholder — drop the line to keep the store at ~/.memai, and on Windows
mind that JSON wants its backslashes doubled.
A host starts several MCP servers per session, so "start it" has to mean "start
it once". Each one asks /api/ping whether a MemAI dashboard already answers —
first at the address a running one recorded in $MEMAI_HOME/admin.json, then
at its own configured port — and only then tries to bind. Whichever wins the
kernel's race keeps the port and the rest exit, with no lock file to survive a
killed session. A port that answers but is not MemAI stops the attempt, logged.
The dashboard is detached on purpose: it outlives the session that opened it.
memai-admin --status says where it is, memai-admin --stop stops it.
Autostart is loopback-only; --host on the command line still lets a person
override that, with the warning it prints.
Export and import
VACUUM INTO makes a byte-perfect copy of the store, which restores a machine
and answers nothing else: you cannot diff two of them, grep one, put one in a
review, or carry a domain to another store. memai-store writes the same
content as text.
memai-store export --out memories.jsonl # round-trippable
memai-store export --format md --domain acme/x100 # to read and grep
memai-store import memories.jsonl --dry-runjsonl is one record per line — every column, cross-listings, relations, and
whole diagram graphs including the positions somebody arranged by hand — so a
diff is per memory. md is one document grouped by domain, export only.
An import writes what is not already there and skips what is, so running it twice changes nothing; the local row always wins, because an import is how a store is restored, merged into or carried, and in all three the row somebody has been using is the one to keep. uids and timestamps come across unchanged — renumbering would break every relation, node link and jump pointing at them. The FTS index and the vectors are rebuilt from the content, and the edit history is not carried: that is what the binary backup is for.
Data location
$MEMAI_HOME/memai.db if MEMAI_HOME is set, otherwise ~/.memai/memai.db,
with backups/ and renders/ beside it. Not tracked in git — user data,
created on first run.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceAn MCP server that provides persistent long-term memory for AI agents via local SQLite storage with low token overhead, enabling memory storage, retrieval, and management across sessions.1MIT
- AlicenseNot gradedqualityBmaintenanceA local memory server for AI agents that stores and retrieves information via MCP, keeping all data in SQLite on your machine.1Apache 2.0
- AlicenseAqualityCmaintenancePersistent memory MCP server for AI agents, using SQLite with hybrid keyword and semantic search for long-term memory storage.5Do What The F*ck You Want To Public
- AlicenseAqualityBmaintenanceMCP server for persistent, cross-session, local-first memory for AI agents, storing memories as Markdown files with SQLite indexing for hybrid search.24Apache 2.0
Related MCP Connectors
Shared, governed long-term memory for AI agents across tools and sessions via MCP and REST.
Cloud-hosted MCP server for durable AI memory
Person-owned, portable AI memory as a remote MCP server, readable and writable by any MCP client.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Filipe-Soares-de-Almeida/MemAI'
If you have feedback or need assistance with the MCP directory API, please join our Discord server