Skip to main content
Glama

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. A task is a goal and a checklist: items move through todo, doing, done and dropped, 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/billing still answers a read of acme, and also cross-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 full

A 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 checks

Register 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 date

See Hooks and the warden for what each event emits and how to turn the warden off.

NOTE

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 in a terminal

~/.claude.json, top-level mcpServers

claude mcp add --scope user memai <command>

Claude desktop app — Chat and the Code tab

Windows: %LOCALAPPDATA%\Claude-Data\<install>\claude_desktop_config.json for the packaged app, %APPDATA%\Claude\claude_desktop_config.json otherwisemacOS: ~/Library/Application Support/Claude/claude_desktop_config.json

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.

IMPORTANT

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

memai.db

the General project, the one every install starts with

projects/<name>.db

every other project, one file each, named as you named it

active

one line naming the project in use; absent means General

backups/

VACUUM INTO copies of General, named General-<stamp>.db; any other project's go in backups/<name>/

renders/, warden/

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:

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 tools
also_domainA
Idempotent

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_scanA
Read-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_edgeA
DestructiveIdempotent

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_jumpA
DestructiveIdempotent

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_nodeA
DestructiveIdempotent

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_relayoutA
DestructiveIdempotent

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_memoryA
Destructive

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.

forgetA
Idempotent

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_diagramA
Read-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_caseA
Read-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_memoryA
Read-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_relationsA
Read-onlyIdempotent

List a memory's links, one page at a time; follow next_offset.

Inputs: uid (required), part, offset.

list_by_domainA
Read-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_domainsA
Read-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_projectsA
Read-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_recentA
Read-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_projectA
Destructive

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_readA
Read-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_runsA
Read-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_scanA
Read-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_statusA
Read-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.

pulseA
Read-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_memoryA
DestructiveIdempotent

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.

recallA
Read-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.

set_confidenceA
Idempotent

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_caseA
Idempotent

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_itemA
Destructive

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_noteB
Destructive

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_readA
Read-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.

timelineA
Read-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_domainA
DestructiveIdempotent

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

    A3.6/5.0

    Scored across 43 tools

    Disambiguation3/5

    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.

    Naming Consistency3/5

    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.

    Tool Count2/5

    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.

    Completeness5/5

    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

    ActivityActive
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      An 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.
      1
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      A local memory server for AI agents that stores and retrieves information via MCP, keeping all data in SQLite on your machine.
      153 PyPI
      1
      Apache 2.0
    • A
      license
      A
      quality
      B
      maintenance
      MCP server for persistent, cross-session, local-first memory for AI agents, storing memories as Markdown files with SQLite indexing for hybrid search.
      24
      Apache 2.0