MemAI
Server Configuration
Describes the environment variables required to run the server.
| Name | Required | Description | Default |
|---|---|---|---|
| MEMAI_HOME | No | Directory to store the memai.db file. Defaults to ~/.memai. | |
| MEMAI_ADMIN_PORT | No | Port for the admin dashboard (default: 8765). | |
| MEMAI_EMBED_MODEL | No | Set to a Hugging Face repo id or a local path to use a different embedding model instead of the bundled one. |
Instructions
Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.
Long-term memory that survives between sessions. Read before working:
pulse(domain) for the state of a subject, recall(query) or search(query)
for anything specific, list_domains() for the tree that exists. Write as
you go, not at the end: note() a durable fact, anti_pattern() a pitfall
worth not repeating, checkpoint() where the work stands before a pause,
diagram() a routine start to end. One memory holds ONE fact: when a body
grows into several subjects, write them as separate memories and
link_memories() them to each other. A claim you could not check says so in
its own body; set_confidence(uid, 'confirmed'|'contradicted') closes it
once the evidence turns up. must_read() says which memories wait to be read
in a scope -- open tasks first (task() files one), then pitfalls, handoffs,
notes and flows; work an open task with task_item() and it closes itself
once every item is done or dropped. A person never sees an item's id, and
reads a task's goal, comments and notes in the dashboard: there an item is
[[#id]], shown as its number, and anywhere else -- a reply, a file, a
commit -- it is item n of its task, named by the task's title. A bare id is
written nowhere. pinned in must_read() counts memories a
person marked as mandatory reading: list them with must_read(type=...,
pinned=true) and open every one before acting.
A domain is a path ('acme/x100/p200') and every read covers its subdomains, so the same call asks about a product or one routine depending on how much of the path it gives.
NOTE: no memai hook is registered in the user's settings, so nothing puts the store in front of this session -- memai is read only when you call it, and a session that forgets to call it starts blind. Say so in your first reply and offer to run this, exactly as written (the name alone is not on PATH):
/app/.venv/bin/memai-hook install
It registers SessionStart, PreCompact, Stop and PreToolUse in
~/.claude/settings.json, for every project -- the last of those is the guard:
it refuses a memai write whose required text never arrived, and records the
domains a session names, which scopes the task ask at Stop. --check reports
what is registered, --print shows the block without writing it.
Capabilities
Features and capabilities supported by this server
Protocol revision2025-11-25
| Capability | Details |
|---|---|
| tools | {
"listChanged": false
} |
| prompts | {
"listChanged": false
} |
| resources | {
"subscribe": false,
"listChanged": false
} |
Tools
Functions exposed to the LLM to take actions
| Name | Description |
|---|---|
| noteA | Save a general long-term memory (fact, decision, finding). Stored as type='note'. Timeless knowledge -- retrieved by relevance, not recency. Bring it back with recall() (or search(type='note')); must_read(type='note') lists the most recent ones as headers. |
| checkpointA | Snapshot current working state (intent/established/pursuing/open_questions). A summary of where the work stands, so the next session picks up the right bearing via pulse(). Fields are free-length; still prefer a readable summary here and put timeless detail into note() -- checkpoints are read for bearing, not as an archive. One fact per note(), each cited back here by [[uid]] and linked with link_memories(); pulse() returns the latest checkpoint IN FULL, so every session pays for whatever was parked in these fields. Stored as type='checkpoint'. A domain holds ONE active checkpoint. While one is already filed at
|
| anti_patternA | Record a pitfall: a move that looks right, why it fails, and what to do instead. For a mistake worth not repeating. A fact that simply holds is a note(); how an analysis ran and what it settled is a reasoning(). Stored as type='anti_pattern' and read back under TEMPTATION / WHY WRONG / INSTEAD; pulse() counts the open ones for a domain and must_read(type='anti_pattern') lists them. ONE pitfall per memory: a second temptation from the same session is its own anti_pattern(), connected with link_memories(). Returns the new |
| reasoningA | Record an analysis worth keeping: what was thought, and what it settled. For the PROCESS, not the fact it produced -- note() takes the fact. Stored as type='reasoning'; filter search/list_* with type='reasoning' to get these back. ONE analysis per memory: a second hypothesis tested in the same session is its own reasoning(). |
| taskA | Open a task: a goal and a checklist, kept until its items are closed. Stored as type='task'. Work it with task_item(), grow it with task_add() and discuss it with task_comment(); it archives itself once every item is done or dropped, and must_read() lists the open ones. Free text cites another item only as [[#id]], which reads back as its current number and label; a label -- a title, an item's text -- never carries one. To a person, name it item n of its task. |
| task_itemA | Update one item of a task -- its text, its state, a comment on it, memories linked to it -- or delete it. Give at least one of text, state, comment and related, or delete. They apply together, or not at all. The result names the item's number Free text cites another item only as [[#id]], which reads back as its current number and label; a label -- a title, an item's text -- never carries one. To a person, name it item n of its task. |
| task_addA | Append items to a task, one per line; a closed task reopens. For work the checklist is missing. To move or comment on an item that exists, task_item(); to give an item its detail, task_note(). Free text cites another item only as [[#id]], which reads back as its current number and label; a label -- a title, an item's text -- never carries one. To a person, name it item n of its task. Returns {"uid", "items", "progress", "task_state", "archived"}: the new items as {id, n}, and the task as it stands. |
| task_commentA | Comment on a task, or on one of its items when A comment never edits the task's content, so it carries what the
checklist cannot: why an item is blocked, what a review said. Text an
item is worked from -- its brief, a rule several items share -- is a
task_note(); a comment that goes with a change of state goes in
task_item()'s Free text cites another item only as [[#id]], which reads back as its current number and label; a label -- a title, an item's text -- never carries one. To a person, name it item n of its task. Returns {"uid", "comment_id"}, plus |
| task_readA | Read one collection of a task, one page at a time. Follow |
| task_noteB | A note a task owns: an item's brief, a rule its items share. Not a memory: only task_read() returns it. A note on items is a brief:
goal, context, steps, pitfalls, done_when and depends_on are required,
extra_info is optional, and they read back under the labels GOAL: /
CONTEXT: / STEPS: / PITFALLS: / DONE WHEN: / DEPENDS ON: / EXTRA INFO:.
A note on the whole task is free text in Free text cites another item only as [[#id]], which reads back as its current number and label; a label -- a title, an item's text -- never carries one. To a person, name it item n of its task. |
| diagramA | Document what a routine does, start to end, as a graph. Stored as type='diagram'. For a PROCESS, not a fact: note() records what is true, checkpoint() where the work stands, this one how a routine runs. Every step is a separate object that can carry its own explanation and its own links to other memories, which is what makes a diagram the source of truth for its domain instead of one more wall of prose. Keep every review_after and source_ref matter most for a flow: it describes code, and the code moves. Returns {"uid": ...}, or {"ok": False, "errors": [...]} with nothing written at all. Node positions are computed and stored server-side, so the flow renders identically for every reader -- see get_diagram(). |
| diagram_nodeA | Add, patch or remove one step of a diagram. Only the arguments you pass are touched, so patching a note leaves the label alone; pass note="" to clear one. The whole-graph rules are relaxed here on purpose: a step may sit unattached until you add its edges, which is what lets a flow be built up across several calls. diagram() enforces them. |
| diagram_edgeA | Wire two steps of a diagram together, relabel that wire, or remove it. Calling again with the same endpoints updates the label instead of adding a second edge between them. |
| diagram_linkA | Attach another memory to one specific step of a diagram. What turns a diagram into an index of its domain: the step states what happens, the linked note/anti_pattern/reasoning states why it is that way. Point at the step the memory actually concerns -- for an edge to the diagram as a whole use link_memories() instead, and to say the branch continues in another flow, diagram_jump(). get_memory() on the linked memory reports the diagrams that reference it, so the connection is visible from both ends. Returns {"ok": True}, or {"ok": False, "errors": [...]} for an unknown diagram, step or memory, a link from a diagram to itself, or a delete with no such link. |
| diagram_jumpA | Continue one step of a flow into ANOTHER flow, optionally at one of its steps. Not the same statement as diagram_link: that attaches prose explaining a step, this says the rest of this branch is documented elsewhere. Use it where a routine hands off -- a sub-process, an error path owned by another flow, a variant of the same job. Stored once and read from both ends, so the return trip already exists and get_diagram(format='json') reports it on both diagrams. |
| diagram_relayoutA | Recompute a diagram's stored node positions from scratch. Positions live in the store, not in a viewer, so every reader sees the same picture and positions hand-adjusted in the admin dashboard persist. This discards those adjustments and rebuilds the layered arrangement -- the fix for a diagram dragged into a mess. |
| get_diagramA | Read a diagram back: format='svg-interactive' to show it, 'json' to reason about it. Formats: 'svg-interactive' (canvas drawing in a pan/zoom shell -- the one to SHOW), 'svg' (same drawing as a plain file, to attach or link), 'mermaid' (portable, but re-lays out and DISCARDS the arrangement the user made), 'text' (the prose projection), 'json' (the full graph with positions, notes and links -- the only round-trippable one, and the one to reason over). Both SVG formats write the markup to a file and return its path plus a
thin index of the steps; the payload is deliberately too small to draw
from. The returned |
| searchA | Keyword search over memory content+tags+domain: FTS5 BM25. Each result is annotated with match_source ("fts", or "uid" for the row a pasted identifier names) and fts_rank (bm25, lower = better). The search only widens the candidate set -- judge the returned candidates yourself. Only active memories by default. Returns {"results": [...], "est_tokens": N}. Content is
snippet-truncated per result -- call get_memory(uid) for the full
record; a result's Two annotations worth acting on. A memory marked confidence='contradicted' sorts behind everything that still holds, but it does come back: knowing a claim was ruled out is worth a slot, and it is what stops it being written again. A diagram ranks like any other memory: it comes back when it matches the query, in the position its score earns. Nothing lifts a type to the top, so a flow in the results is a flow this query actually hit -- and when one does show up it is worth opening first, because it states a whole routine the surrounding notes only annotate. To recall note()'d knowledge specifically, recall() is the sugar for search(type='note') -- which also means recall() never surfaces a diagram; use search() for that. |
| recallA | Recall long-term knowledge saved with note() (type='note'). The dedicated verb for "bring back what I noted": a BM25 search scoped to type='note', ranked by relevance -- which is what you want for timeless facts/rules/decisions. This (or search(type='note')) is how notes come back by relevance; must_read(type='note') lists the newest. Returns {"results": [...], "est_tokens": N}. Content is
snippet-truncated -- call get_memory(uid) for the full record; a
result's Results carry the same |
| list_by_domainA | List memories for a domain and its subdomains, most recent first. Fallback when search misses. A task row carries Returns {"results": [...], "est_tokens": N}. Content is
snippet-truncated per result -- call get_memory(uid) for the full
record; a result's |
| list_recentA | List the most recent active memories, optionally filtered by type/domain. Returns {"results": [...], "est_tokens": N}. Content is
snippet-truncated per result -- call get_memory(uid) for the full
record; a result's |
| timelineA | What else was being written around one memory, in creation order. For the question search cannot ask: not what mentions this record, but what was being written when it was. Neighbours are picked by time alone, so they come back whether or not they share a word with the anchor -- around a checkpoint, that is the notes and pitfalls of the same stretch of work. One of uid or query is required; give both and uid wins. The response
reports Returns {"anchored_by": ..., "anchor": {...}, "before": [...], "after": [...]}, both lists oldest first, so before + [anchor] + after reads straight down the clock. Neither list contains the anchor. domain and type narrow the NEIGHBOURHOOD, not the anchor, and archived records are left out of it, as everywhere else by default. Each record is snippet-truncated with |
| list_projectsA | The projects in this home, and which one every call here reads and writes. A project is one SQLite file with its own memories, domains, relations
and diagrams. The active one is switched in the admin dashboard, and a
running server follows on its next call -- so every write's result names
the project it landed in, and pulse() names the one it read. Each entry
carries |
| list_domainsA | List the domain tree: every path with its counts and latest activity. Warm-up discovery. domain is free text and drifts over time (e.g. 'proj-1042' vs 'proj-1042-cache-warmup'), so this surfaces the paths actually in use instead of leaving you to guess one. Ordered by most recent activity. Per entry:
Casing may be enforced store-wide -- call get_domain_case() to see the active policy before coining a new domain. |
| also_domainA | Cross-list an existing memory into one more domain path. For the membership a memory picks up after the fact: it was filed where
it lives, and later turns out to be part of a subject that cuts across
the tree. Every read scoped to Returns {"uid": ..., "also": [...]} with the whole resulting set. A path the memory's own domain already sits under is dropped as redundant, so the echo is what actually holds. |
| unfile_domainA | Drop one of a memory's cross-listings. Does not touch where it is filed. Returns {"uid": ..., "also": [...]} with what remains. |
| get_domain_caseA | Report the store's domain-casing policy. Returns {"mode": "preserve"|"lower"|"upper"}. 'preserve' stores
domains as written; 'lower'/'upper' coerce every domain to that case
on write (a non-conforming domain is adjusted, not rejected, and the
writer's result carries a |
| set_domain_caseA | Set the store's domain-casing policy for every domain written from now on. This only governs new writes -- to bring already-stored domains into line, run the "Normalize domains" action in the admin dashboard (it previews collisions before merging variant spellings). Returns the stored {"mode": ...}. |
| must_readA | What is still open in a scope: counts per category, or one category's headers. Without type: {"categories": [{"type", "count"}, ...]} for task,
anti_pattern, handoff, note and diagram, in that order, leaving out the
empty ones, plus "pinned" -- the same shape, counting the memories a
person pinned as mandatory reading -- when any pin is in scope. A pinned
memory still counts in its category. With type: {"type", "total",
"items", "next_offset"} -- one page of headers (uid, title, domain,
tags, est_tokens; a task adds Every pin listed is read with get_memory(uid) before acting, none skipped. Pending means: a task that is open; an active anti_pattern, handoff or note that is not contradicted; an active diagram.
|
| pulseA | Session warm-up: the latest checkpoint, what is pending, and what to read next. Returns {project, latest_checkpoint, must_read, pinned, read_next, scope},
plus latest_checkpoint is picked by created_at DESC, never by similarity --
a similarity-ranked top-1 can return a stale checkpoint over a
same-day one, which is exactly the failure mode this avoids. It is
returned in full (that's the point of pulse), with its relations
attached so linked memories are visible without a separate
get_relations call, and carries
A scope holds what is CROSS-LISTED into it as well as what is filed
there, so warming up an end-to-end flow brings back the routines that
are steps of it wherever they live. A long checkpoint is cut ( |
| get_memoryA | Fetch one memory: its fields, and counts of what is linked to it.
|
| edit_memoryA | Correct a memory's content or its source reference, keeping the previous version. Corrections are common in append-only memory stores that only support delete, not edit; this preserves the old content instead of losing it. Each field is settable on its own; passing none of new_content, source_ref, title, tags and goal is an error rather than a silent no-op. Refuses to rewrite a diagram's content: that is generated from the
graph, so a hand-written replacement would be silently overwritten by
the next structural change -- edit the flow through
diagram_node/diagram_edge. Its source_ref is ordinary metadata and is
editable here like any other memory's. A task's content is generated
from its goal and items the same way: rewrite its goal with |
| link_memoriesA | Create a queryable edge between two memories. This is what splitting a body into several memories costs: a [[uid]] written inside prose is a reference a reader follows, and only an edge created here is visible to get_relations() and to the graph. Refuses an unknown uid, a memory related to itself, and an edge that already exists with that same type -- each as {"ok": False, "errors": [...]}, so a typo comes back as something to fix instead of a dangling edge or a raw database error. |
| get_relationsA | List a memory's links, one page at a time; follow |
| set_confidenceA | Set a memory's confidence: unverified | confirmed | contradicted. Call it once evidence settles a claim: the code, a run or a source checked against the memory. A contradicted memory sorts last in search and is left out of the notes, pitfalls and handoffs must_read() lists and of the checkpoint pulse() returns, so a claim known to be wrong stops steering work while it stays readable; unverified and confirmed rank alike. To correct the text, edit_memory(); to retire it, forget(). Now and then a result of any memai tool carries Returns {"ok": True}, {"ok": False} for an unknown uid, or an error for any other value. |
| forgetA | Archive a memory (soft delete -- content is kept, just excluded from default search/list). The call for a memory that is wrong, stale or a copy. It is reversible: the dashboard's restore brings an archived memory back, and list_by_domain(status='archived') still reads it. purge_memory() is the irreversible delete, only on the user's explicit request; to correct a memory instead of retiring it, edit_memory(). Archiving an open task cancels it; its items keep their states. Returns {"ok": True}, or {"ok": False} for an unknown uid. |
| purge_memoryA | PERMANENTLY delete a memory + its edit history + relations. Irreversible. Use forget() instead unless the user explicitly asked to permanently remove data -- forget() is reversible (archived, content kept), this is not. |
| move_to_projectA | Carry memories from the active project into another one, and remove them here. Each memory travels whole -- body, cross-listings, usage counts, edit
history, the relations and diagram graph inside the slice -- into
|
| dedup_scanA | Surface likely-duplicate/contradictory memory pairs. Lexical overlap over near-identical text -- each pair carries its
|
| optimize_scanA | Dump the memory corpus compactly so you can plan a curation pass. Step 1 of the "optimize my memories" workflow: every memory's
curation-relevant fields, the relation edges among them, usage counts,
dedup and domain hints, and per-memory Start with what the store already says is suspect:
The listing is slim so a big store fits one response, and a page ends
early at an internal size budget -- BEFORE PROPOSING ANY CHANGE, CHECK IT AGAINST LIVE FACTS, and record what
you checked in each suggestion's |
| optimize_stageA | Stage a batch of curation suggestions for human review in the dashboard. Step 2 of the "optimize my memories" workflow. NOT applied here: the user reviews and applies or rejects each one in the admin dashboard, which backs up before the first apply and can undo any of them. Invalid suggestions are skipped and reported in |
| optimize_runsA | List optimization runs with their review progress. Read-only companion to optimize_stage: after staging, use this to see whether the user has applied/rejected your suggestions in the admin dashboard. Each run carries total/pending/applied/rejected counts, its note, and the safety-backup path once the first apply happened. Applying/rejecting stays in the dashboard by design -- the agent proposes, the human disposes. |
| optimize_statusA | Inspect one optimization run: every suggestion and its decision. Read-only. Returns the run header plus each suggestion's kind, target_uid, payload, rationale, verified, status (pending/applied/rejected) and decided_at -- so you can tell which proposals landed, follow up on rejected ones, or build on applied ones in a later pass. |
Prompts
Interactive templates invoked by user choice
| Name | Description |
|---|---|
| warm_up | What memai already knows, as text, for a session about to start. The same brief the SessionStart hook emits (see memai.hook), offered here for hosts that surface prompts as commands. A prompt is invoked by the person, which makes it the one place in the MCP protocol where the store can be READ without the agent having decided to read it. |
Resources
Contextual data attached and managed by the client
| Name | Description |
|---|---|
No resources | |
TDQS
Scored across 43 tools
The core write verbs (note, checkpoint, anti_pattern, reasoning) and the task_/diagram_ families are well separated, but the read surface overlaps heavily: recall is explicitly 'sugar for search(type="note")', and list_recent, list_by_domain, must_read, and pulse all return 'most recent memories' with only subtle scope differences. dedup_scan and optimize_scan also cross into the same 'find suspect memories' territory. Descriptions do a lot of work to disambiguate, but an agent still faces real misselection risk.
Within domains the prefixes are consistent (task_add/task_read/task_note, diagram_node/diagram_edge), but across the server conventions clash: bare nouns (note, checkpoint, task, diagram), bare verbs (search, forget, pulse), verb_noun (get_memory, list_domains), and noun_verb (task_add, diagram_link) all coexist. Generally readable, but not a single predictable pattern.
43 tools is a very heavy surface for a memory store and pushes well past the 'too many' threshold. Several clusters could be collapsed (four optimize_* tools, four separate diagram-edge/link/jump verbs, near-duplicate read tools), so the count feels inflated relative to what an agent can reliably choose among.
Coverage is genuinely thorough: create/read/update/delete across multiple memory types, soft vs hard delete, relations, edit history/versioning, confidence, domain and project management, plus a full curation workflow. Almost no obvious lifecycle gap remains for a memory-management domain.