MemAI
A long-term memory server for coding agents: it stores typed memories in a SQLite file and gives keyword-based retrieval, task tracking, and diagram documentation across sessions.
Write memories —
note(facts/decisions),reasoning(analysis process),anti_pattern(pitfalls),checkpoint(working state), each with domain paths, tags,source_ref, andreview_afterdecay dates.Retrieve —
search/recall(FTS5 BM25 keyword search, domain-scoped),list_by_domain,list_recent,timeline(what was written around a memory),get_memoryfor full records.Warm up a session —
pulsereturns the latest checkpoint in full plus pending counts and aread_nextplan;must_readlists open categories, pinned memories, and overdue checkpoints.Manage tasks —
taskcreates a goal plus checklist,task_itemupdates/renames/deletes items and states,task_add,task_comment,task_read, andtask_notefor item briefs (GOAL/CONTEXT/STEPS/PITFALLS/DONE WHEN/DEPENDS ON).Document flows —
diagrambuilds a node/edge graph,diagram_node,diagram_edge,diagram_link(attach memories to steps),diagram_jump(hand off to another flow),diagram_relayout, andget_diagramrenders as SVG/Mermaid/text/JSON.Organise domains —
list_domainsfor the tree,also_domain/unfile_domainfor cross-listing,get_domain_case/set_domain_casefor casing policy.Correct and curate —
edit_memory(replace or append, with edit history),link_memories/get_relationsfor graph edges,set_confidence,forget(reversible archive) andpurge_memory(permanent, needs a typed confirmation phrase).Run curation passes —
dedup_scanfor near-duplicates,optimize_scanto dump the corpus,optimize_stageto propose suggestions for human review, andoptimize_runs/optimize_statusto check what was applied.Work across projects —
list_projectsnames the SQLite files,move_to_projectcopies memories (with backup, dry-run by default) between them.
Click on "Deploy 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., "@MemAI remember 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.
Agents call MemAI's MCP 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.
The tools answer any MCP host. What surrounds them targets Claude Code: the hook events that put the store in front of a session, the bundled skills, and the warden subagent that consults it on a session's behalf.
Highlights
Types, not one blob.
note,reasoning,anti_pattern,checkpoint,task,diagram— a pitfall is read back by the tool that asks for pitfalls, not found by luck among everything else. Ataskis a goal and a checklist: items move throughtodo,doing,doneanddropped, each carries its own comments and linked memories, and the task archives itself once every item is closed.Domains are paths. A memory filed on
acme/checkout/billingstill answers a read ofacme, andalsocross-lists it under the subjects that cut across that tree.Keyword retrieval, nothing to download. SQLite FTS5 with BM25 over title, content, tags and domain. No embedding model, no network call, no GPU.
The store reaches a session by itself. Hook events warm a cold session and prompt it to write; the warden subagent reports only the memories that bear on what is actually happening.
Curation stays a person's. Confidence, decay dates, dedup and staged suggestions: an agent proposes, a human applies them in the dashboard.
One file holds a project. Rows, keyword index, edit history, relations, diagrams: a project's whole memory in one SQLite file. Keep one for everything, or one per project and switch between them from the dashboard. Copy it, back it up, delete it.
Related MCP server: heropen
What it looks like
Mid-task, an agent writes down what it just paid for:
note(
title="Stripe sends charge.succeeded twice for one charge",
domain="acme/checkout/billing",
tags="idempotency, webhook, retry, duplicate delivery",
content="A retry carries the same event id, so the handler has to key off "
"the event id. Keying off the charge id lets the second delivery "
"book the order again.",
)Days later a cold session opens on that subject and asks for its bearing:
pulse("acme/checkout")It gets the latest checkpoint in full and a count of what is pending anywhere under that path: open tasks, pitfalls, handoffs, notes and flows. The session asks for the category it needs, and gets headers it can open:
must_read("acme/checkout", type="note") # titles and uids, that note among them
get_memory(uid) # the one the work touches, in fullA task is how work reaches the next session. The agent files it with a goal and one item per line, and works it as it goes:
made = task(
title="Move billing webhooks to the idempotent handler",
goal="Every billing webhook is deduplicated by event id.",
items="Key charge.succeeded by event id\nCover refunds and disputes\n"
"Remove the charge-id lookup",
domain="acme/checkout/billing",
)
uid = made["uid"]
charge, refunds, lookup = (i["id"] for i in made["items"]) # each is {id, n}
task_item(uid, charge, state="done", comment="Handler keys off the event id.")
task_note(uid, title="Refund replay", items=str(refunds),
goal="Replay a refund once, however often it arrives.",
context=f"Refunds arrive twice on retry; [[#{charge}]] shows the key.",
steps="Key refunds by event id.",
pitfalls="A dispute reuses the refund's event id.", done_when="The replay test passes.",
depends_on=str(charge))An item is a short label, at most 80 characters; its detail goes in a brief.
What only makes sense inside the task — an item's brief, a rule several items
share — is a task note, not a memory. A note on items is a brief: GOAL, CONTEXT,
STEPS, PITFALLS, DONE WHEN and DEPENDS ON, with EXTRA INFO for anything else.
Every tool names an item by its id; the number a list shows is only its place.
A person never sees the id, so an agent names an item to one as item n of
the task: the task's content lists each item as its number, text and id, every
task result carries n beside each id it names, and a foot_note on each
restates the rule.
Free text cites an item as [[#id]], which reads back as the item's current
number and label, and the dashboard inserts one when # is typed in a task's
text. task_item also renames an
item or deletes one, and edit_memory(uid, goal=...) rewrites the goal, so a
task is corrected in place. Search, recall and the session brief never return
a note. get_memory(uid) on a
task returns its head (goal, progress, counts), and task_read(uid, part)
reads its items, notes, comments and links one page at a time. Every tool
result stays under the size Claude Code shows inline: what can grow comes back
in pages with a next_offset. memai-store task-adopt turns memories linked to
a task's items into its notes, and refuses one that would land on items
without being a brief.
Open tasks come first in the session-start brief. The PreToolUse hook notes the
domains a session names in its memai calls, and at the end of a turn the stop
hook blocks once per interval to ask the agent to update the open tasks of those
domains, and to check similar domains for a task filed under another path. A
session that named no domain is not asked. The
dashboard's Maintenance view switches that reminder off and sets its interval;
a task's checklist, comments and linked memories are worked there as well. A
closed task is an archived memory, so list_by_domain(domain, type="task", status="archived") lists the completed and cancelled ones.
Quickstart
python -m venv .venv
.venv/Scripts/pip install --no-deps -e . # .venv/bin/pip off Windows
.venv/Scripts/pip install --require-hashes -r requirements-dev.txt
.venv/Scripts/python tools/install-webui.py # the admin dashboard
.venv/Scripts/python -m pytest
npm run typecheck && npm test # the dashboard's own checksRegister the server with your host, then let it reach a session by itself — the hooks put the store in front of an agent that did not ask for it, and the bundled skills and subagent teach it what to do with them:
claude mcp add --scope user memai C:\path\to\MemAI\.venv\Scripts\memai-mcp.exe
memai-hook install # the four hook events
memai-hook install --skills # the bundled skills
memai-hook install --agents # the warden subagent
memai-hook install --check # what is registered, and what is out of dateSee Hooks and the warden for what each event emits and how to turn the warden off.
Two hosts, two different files. Neither reads the other's, so a server registered in one is invisible to the other, and an empty list in one says nothing about the other.
host | the file it reads | how to write it |
Claude Code CLI — |
|
|
Claude desktop app — Chat and the Code tab | Windows: | Settings → Developer → Edit configuration, which opens the file the app reads |
Both hosts take the same block, pointing at the console script the install put in the environment:
{
"mcpServers": {
"memai": {
"command": "memai-mcp"
}
}
}A bare memai-mcp resolves only if that environment's Scripts\ (bin/ off
Windows) is on the PATH of the process that launches the server — and a GUI app
inherits the desktop session's PATH, not your shell's. Unless you know it is
there, give the absolute path instead:
{
"mcpServers": {
"memai": {
"command": "C:\\path\\to\\MemAI\\.venv\\Scripts\\memai-mcp.exe"
}
}
}claude mcp list reports what the CLI loaded; the desktop app lists what it
loaded under Settings → Developer → local MCP servers. A host reads its config
once, at startup, so restart it after an edit.
On Windows, install.bat does the venv, install and dashboard-build steps,
update.bat pulls the checkout and then runs install.bat, and run-admin.bat
starts the dashboard (it activates .venv itself; extra arguments pass
through, e.g. run-admin.bat --port 8890). stop-mcp.bat and stop-admin.bat
stop what is running. install.bat and update.bat refuse to start while an
MCP server or the dashboard still runs from the checkout, list what is open,
and offer to close it.
Windows locks an .exe while a process is running it, so apip install cannot
rewrite .venv\Scripts until every MCP server and the dashboard are down.
An agent installing MemAI on a new machine follows .agents/install.md: requirements, the order that keeps a running server from breaking the install, both MCP config files, and the checks that confirm the result.
Staying current. The stop hook asks GitHub, at most once per interval
(a day unless chosen otherwise), which releases are published and caches them —
tag, page and notes, one record per release — in MEMAI_HOME/update.json. A session that starts behind is told which version it
runs, how many releases came after it, what each of them changed (their notes,
flattened to a few plain lines) and the commands that update this checkout
— for the person to run once every session and the dashboard are closed, for
the reason above. The dashboard shows the same thing at leisure: the version
mark in its app bar carries the count and opens Releases, which renders the
packaged CHANGELOG.md with the running version marked and anything published
since it at the top. There, Check now asks GitHub at once, and Check every
sets the interval, from an hour to a week (the server accepts up to 30 days);
the choice is kept in MEMAI_HOME/update-settings.json and holds for every
project. memai-hook install --check prints the same comparison.
MEMAI_UPDATE_CHECK=0 stops the request, and belongs in the environment the
host itself runs in: the hook and the dashboard make it, not the MCP server,
which only reads what they cached.
The dashboard
memai-admin (or python -m memai.admin) serves the store at
http://127.0.0.1:8888 — loopback only; --host / --port /
MEMAI_ADMIN_PORT to change. It is where memories are read, edited, triaged and
curated by a person, where the diagrams are arranged, and — behind the version
mark in its app bar — where the release history is read.
memai-admin --status says where it is, memai-admin --stop stops it.
The dashboard is a Vue 3 and TypeScript app that Vite builds into
src/memai/webui/dist/. Without Node 22.18+ or a reachable npm registry, the
install takes the prebuilt one from the release instead. On start, memai-admin
rebuilds it when its sources differ from the ones the build was stamped with,
running npm ci too when the lockfile changed; it serves the build it has when
npm is missing or fails. MEMAI_ADMIN_BUILD=0 turns that off.
A memory can be pinned from its record: for every domain, or for its own
domain only (its subdomains and cross-listed paths included). must_read() and
pulse() count pinned memories beside the categories, the session brief
counts the global ones, and an agent lists them with
must_read(type=..., pinned=true) and reads every one before acting.
A host starts several MCP servers per session, so the dashboard is started once
and shared: each server asks /api/ping whether one already answers before
trying to bind, and the one that wins keeps the port. It is detached on purpose,
so it outlives the session that opened it.
{
"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_HOME is a
placeholder — drop the line to keep the store at ~/.memai, and on Windows mind
that JSON wants its backslashes doubled.
Where the data lives
Under $MEMAI_HOME if it is set, otherwise ~/.memai. Not tracked in git —
user data, created on first run.
path | what it is |
| the |
| every other project, one file each, named as you named it |
| one line naming the project in use; absent means |
|
|
| generated SVGs, and the warden's per-session state |
A project is one whole memory: its own domains, relations, diagrams and
settings. Any name that works as a Windows file name works as a project
name, and two names that differ only in case are one project. The switch is
in the dashboard's top bar, and every MCP server, hook and dashboard on the
machine opens the active project on its next call — no restart. Every
write's result and every pulse() name the project they touched, and
list_projects() lists them all.
Memories move between projects from the dashboard — a selection in
Memories, or a whole domain in Domains — with move_to_project(), or with
memai-store move. A move copies each memory with its history into the
target, checks it there and only then removes the original, after a backup
of the source is written. What the copy cannot carry — a relation to a
memory outside the selection, a diagram jump across it — is reported before
anything moves.
Documentation
How the parts work lives in the wiki:
section | pages |
getting started | |
concepts | How a session uses memory · Memory types · Domains · Projects |
guides | Tasks · Diagrams · Curation · Dashboard · Backups and export |
reference | Tools · Hooks and the warden · Configuration · Storage · Retrieval |
Licence
MemAI is MIT. Roboto is bundled in webui/fonts/ under the SIL Open Font
License 1.1 (webui/fonts/OFL.txt), separate from MemAI's own licence.
Available Tools
43 toolsalso_domainAIdempotent
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 domain returns it from now on, without
moving it -- use the dashboard's re-home for that.
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.
Inputs: uid (required), domain (required).
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 uid and the project it landed in, plus similar when
the store already holds something close: correct, drop or link it as
similar_hint says. A field over its ceiling is refused and nothing is
written.
Inputs: title (required), instead (required), pattern (required), why_wrong (required), +6 more.
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
domain, the call is refused and nothing is written: the refusal
lists it under checkpoints. Read it with get_memory(uid) and decide
whether its work is done. Done: forget(uid, reason='...'), then call
again. Not done: carry what still holds into these fields and call
again with supersedes='', which archives it as superseded by the
new one. A checkpoint past the age limit set in the dashboard refuses
every write under its domain the same way, flagged overdue, until
it is settled.
Inputs: title (required), intent (required), pursuing (required), established (required), +6 more.
dedup_scanARead-onlyIdempotent
Surface likely-duplicate/contradictory memory pairs.
Lexical overlap over near-identical text -- each pair carries its
method. Two takes on one subject in different words do not surface
here. Same-domain/session checkpoint pairs are excluded (timelines, not
dups)
and checkpoint pairs rank below durable-type pairs. Not an automatic
merge -- returns candidate pairs + similarity score for the agent to
review and decide (link_memories / edit_memory / forget as
appropriate).
Inputs: type, limit, domain, offset, +1 more.
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 label objective -- what happens at that step, nothing
more. The reasoning, caveats and history belong in that node's
note, where they explain without cluttering the flow.
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().
Inputs: edges (required), nodes (required), title (required), also, +7 more.
diagram_edgeADestructiveIdempotent
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.
Inputs: uid (required), to_key (required), from_key (required), label, +1 more.
diagram_jumpADestructiveIdempotent
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.
Inputs: uid (required), node_key (required), peer_uid (required), label, +2 more.
diagram_linkADestructiveIdempotent
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.
Inputs: uid (required), node_key (required), target_uid (required), delete, +1 more.
diagram_nodeADestructiveIdempotent
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.
Inputs: key (required), uid (required), note, label, +2 more.
diagram_relayoutADestructiveIdempotent
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.
Inputs: uid (required).
edit_memoryADestructive
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 goal,
and its items through task_item().
Inputs: uid (required), goal, mode, note, +4 more.
forgetAIdempotent
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.
Inputs: uid (required), reason, superseded_by.
get_diagramARead-onlyIdempotent
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 next_step says what to do with the path, at the
point where it matters: when the user asked to SEE the flow, reading the
file and emitting it inline is the work, not a cost to avoid.
Inputs: uid (required), format, offset.
get_domain_caseARead-onlyIdempotent
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 domain_adjusted note). Read this before
coining a new domain so its casing matches what will be stored.
Inputs: no named parameters.
get_memoryARead-onlyIdempotent
Fetch one memory: its fields, and counts of what is linked to it.
next names the call that pages each part: get_relations() for
relations and diagrams, get_diagram() for a diagram's graph,
task_read() for a task's items, notes and comments, edits_offset for
the edit history and content_offset for a body too long for one
response.
Inputs: uid (required), edits_offset, content_offset.
get_relationsARead-onlyIdempotent
List a memory's links, one page at a time; follow next_offset.
Inputs: uid (required), part, offset.
link_memoriesAIdempotent
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.
Inputs: to_uid (required), from_uid (required), relation_type (required), note.
list_by_domainARead-onlyIdempotent
List memories for a domain and its subdomains, most recent first.
Fallback when search misses. A task row carries state (open,
completed or cancelled) and progress {done, total}.
Returns {"results": [...], "est_tokens": N}. Content is
snippet-truncated per result -- call get_memory(uid) for the full
record; a result's est_tokens estimates what that full record costs,
and the top-level est_tokens is the sum over the results.
Inputs: domain (required), type, limit, offset, +2 more.
list_domainsARead-onlyIdempotent
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: domain (the full path), parent, depth, count (filed
at exactly this path), subtree (that plus everything nested under
it), children, and implicit -- true for a level that exists only
because something deeper is filed under it. Read subtree to pick the
scope worth warming up: a parent holding nothing of its own can still
be where the work is.
also and subtree_also are the same two counts for memories
CROSS-LISTED here rather than filed here -- the cross-cutting subjects.
A path with count 0 and also above it is one of those and nothing
else: an end-to-end flow whose steps all live under other branches.
Reads scoped to it return them all.
Casing may be enforced store-wide -- call get_domain_case() to see the active policy before coining a new domain.
Inputs: offset.
list_projectsARead-onlyIdempotent
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 name, memories (the active rows) and active. Names are
matched without regard to case wherever a tool takes one.
Inputs: offset.
list_recentARead-onlyIdempotent
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 est_tokens estimates what that full record costs,
and the top-level est_tokens is the sum over the results.
Inputs: type, limit, domain, offset, +1 more.
move_to_projectADestructive
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
target, is checked there, and only then purged from the active
project, after a backup of it is written.
Inputs: target (required), uids, create, domain, +1 more.
must_readARead-onlyIdempotent
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 progress {done, total} and doing, its
items in progress as {id, n}), newest first, a task by its latest item
update. Open a header with get_memory(uid). A page holds at most limit
headers and ends early at the size budget; next_offset is absent on
the last page.
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.
overdue, present only when non-empty, lists the checkpoints in scope
that stayed open past the age limit, with an alert to relay to the
user: memai refuses new writes under each until it is settled.
Inputs: type, limit, domain, offset, +1 more.
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.
Inputs: title (required), content (required), also, tags, +4 more.
optimize_runsARead-onlyIdempotent
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.
Inputs: offset.
optimize_scanARead-onlyIdempotent
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 anchors (URLs, paths,
identifiers) to check against live facts. Read it, then stage what you
decided with optimize_stage.
Start with what the store already says is suspect: due: true on a
memory means its own writer dated it for a recheck and the date has
passed, and source_ref says what to check it against.
recalls and stats.never_recalled are NOT that. A low count means
unproven, not useless -- a memory about a rare subject looks exactly
like a memory nobody wants, and the rare subject is frequently the
reason the store is there. Use the aggregate to judge the STORE and
never a single row: do not propose archiving something because it is
unread.
The listing is slim so a big store fits one response, and a page ends
early at an internal size budget -- truncated means page onward with
offset + count. relations_truncated means the page's first memory has
more edges than fit: get_relations(uid) lists them all. stats.by_domain
names the largest domains; stats.domains counts every one.
BEFORE PROPOSING ANY CHANGE, CHECK IT AGAINST LIVE FACTS, and record what
you checked in each suggestion's verified. Destructive kinds are
rejected without it: cross-check newer memories in the corpus, verify
code anchors against the live repo, web-check world facts.
Inputs: full, type, limit, since, +3 more.
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 errors, as many as fit
one page, with errors_total counting them all when some are left out;
the rest are staged. Returns {run_id, staged, errors}.
Inputs: suggestions (required), note.
optimize_statusARead-onlyIdempotent
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.
Inputs: run_id (required), offset.
pulseARead-onlyIdempotent
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 overdue when a checkpoint in scope stayed open past the age
limit -- the same block must_read() carries, and read_next then puts
settling it first.
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 est_tokens, what this response already
spent on it.
must_read is the categories list must_read(domain) returns without a
type: a count per category (task, anti_pattern, handoff, note, diagram),
leaving out the empty ones. read_next is the instruction to follow with
it, before acting: pinned memories, then open tasks, then
must_read(domain, type=...) for each other category, numbered when there
is more than one step. It is "" when nothing is pending. A pulse lists
no memories besides the checkpoint; must_read(domain, type=...) lists
headers and get_memory(uid) opens one.
pinned counts the pins in scope the same way (see must_read()); when it
is non-empty read_next asks for them before anything else.
scope is the rest of the brief: what the scope HOLDS. scope.by_type
counts every memory per type and scope.subdomains says which level it
is sitting in (own = filed there, subtree = with its descendants).
Read them as the drill-down plan: search(query, domain=...) or
list_by_domain(domain, type=..., limit=...) on the child that holds what
you need. A pulse is the state of a scope, never its contents.
scope.stale is the one thing here about DECAY rather than contents:
how many memories in the scope carry a review_after date that has
passed. Present only when non-zero. It means somebody who knew the
subject said when to look again and nobody has -- optimize_scan lists
which ones, with their source_ref.
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. scope.also counts how much of the
brief arrived that way, and a subdomain carries its own also --
present only when non-zero, so a store that never cross-lists never
sees the field.
A long checkpoint is cut (next names the rest).
Inputs: domain, offset.
purge_memoryADestructiveIdempotent
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.
Inputs: uid (required), confirm_phrase (required).
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().
Inputs: title (required), result (required), next_time (required), reasoning (required), +8 more.
recallARead-onlyIdempotent
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 est_tokens estimates what that full record costs, and the
top-level est_tokens is the sum over the results.
Results carry the same succeeded_by / collapsed annotations
search() explains.
Inputs: query (required), limit, domain, offset.
searchARead-onlyIdempotent
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 est_tokens estimates what that full record costs,
and the top-level est_tokens is the sum over the results.
Two annotations worth acting on. succeeded_by means something in the
store supersedes this memory: read that one instead. collapsed lists
near-identical results folded into this one, so a fact written five
times spends one slot -- raise limit if you want the copies.
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.
Inputs: query (required), type, limit, domain, +1 more.
set_confidenceAIdempotent
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 confidence_reminder:
memories still unverified past the span set in the dashboard, those in
the call's domain first. Settle the ones this session's evidence
reaches; it refuses nothing.
Returns {"ok": True}, {"ok": False} for an unknown uid, or an error for any other value.
Inputs: uid (required), confidence (required).
set_domain_caseAIdempotent
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": ...}.
Inputs: mode (required).
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.
Inputs: goal (required), items (required), title (required), also, +3 more.
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.
Inputs: uid (required), items (required).
task_commentA
Comment on a task, or on one of its items when item names its id.
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 comment.
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 item and n on an item.
Inputs: uid (required), body (required), item.
task_itemADestructive
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 n and the task's title.
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.
Inputs: uid (required), item (required), text, state, +3 more.
task_noteBDestructive
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 body.
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.
Inputs: uid (required), body, goal, items, +9 more.
task_readARead-onlyIdempotent
Read one collection of a task, one page at a time.
Follow next_offset until it is absent.
Inputs: uid (required), part (required), item, offset.
timelineARead-onlyIdempotent
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 anchored_by ('uid' or 'query') and the whole anchor record,
so which record the timeline is built around is never a guess.
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 est_tokens for its full content
-- call get_memory(uid) to open one.
Inputs: uid, type, after, query, +2 more.
unfile_domainADestructiveIdempotent
Drop one of a memory's cross-listings. Does not touch where it is filed.
Returns {"uid": ..., "also": [...]} with what remains.
Inputs: uid (required), domain (required).
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
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.
Maintenance
Related MCP Connectors
Hosted memory for AI agents over MCP. One memory shared by all your agents.
An MCP memory server. One memory your agents share — across models, devices and apps.
Persistent, inspectable memory for AI agents with lineage, correction, and a hosted MCP endpoint.
Persistent memory, knowledge graph, task management and more for AI agents via MCP.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceAn 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.153 PyPI1Apache 2.0
- AlicenseAqualityNot gradedmaintenancePersistent 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