Documentation

Everything about the OpenLoft platform: the NMAFC memory framework, the agents we host for you, and the hosted memory service. Start with the framework — it is open source, so run it yourself with no OpenLoft account required.

github.com/blok-hamster/nmafc

Overview

NMAFC is an open-source memory layer for LLM agents. It is the engine behind OpenLoft Memory, and you can run it yourself — the whole thing is Apache-2.0.

It exists to solve one problem: assistants that forget. Rather than stuffing raw transcripts into a vector database and paying for repeated summarisation passes, NMAFC compacts each turn into atomic facts with resolved absolute dates at write time, lets unused facts fade on a decay curve, and retrieves through a fusion of five ranked channels.

What you get

CapabilityWhat it does
Temporal groundingResolves "last week" to a real date when the fact is written, so a question about dates can be answered precisely later.
Decay and reinforcementFacts fade with conversational turns; every retrieval strengthens retention. Frequently-used facts persist, one-off noise expires.
Three memory typesCoreAnchor (identity and immutable rules, never decay), ActiveContext (current state), EphemeralState (session noise).
Dual-tier storageA small working set in LanceDB plus an append-only archive in SQLite with a full-text index. No external vector database to run.
Graph retrievalEntity links are resolved on ingest, so multi-hop questions traverse relationships rather than relying on similarity alone.
Source attributionEvery fact points at the turns it was extracted from, so you can show a user why the assistant believes something.
Several ways to connectPython SDK, REST, MCP, and an OpenAI-compatible endpoint. See Integrations below.

It is a library first

NMAFC is a Python package with an HTTP server attached. If you can import a package, you can use it. Nothing requires you to adopt OpenLoft, and nothing requires an LLM provider from us.

Install

Python 3.11 or newer. Two supported routes — a local library, or the HTTP server.

As a library

shell
git clone https://github.com/blok-hamster/nmafc.git
cd nmafc

# a virtualenv is worth it; the engine pins LanceDB tightly
uv venv && source .venv/bin/activate     # or: python -m venv .venv && source .venv/bin/activate

# pick the extras you need
uv sync --extra all                      # or: pip install -e ".[llm]"
uv run pytest -q                         # 833 tests, about 30s, no network

The extraction step needs tool-calling

Facts are extracted by asking your model to call a tool. A provider without tool-calling can serve recall but cannot write memory, so the engine will look healthy and quietly remember nothing. Every provider listed on this page supports it; check yours does.

As a server

shell
# the engine ships its own server: an explorer UI plus a REST API
uv run nmafc-web --host 127.0.0.1 --port 8000

# then read the interactive API surface
open http://127.0.0.1:8000/docs

Bind to localhost unless you have put authentication in front of it. The bundled server ships with its explorer endpoints open — see the security note below before exposing it.

Before you expose it to a network

Out of the box the server's explorer endpoints trust an X-Agent-Id header and do not require a key, and the tenant instance cache is unbounded. On a shared network that is a cross-tenant read and a denial-of-service surface. Either run it on localhost behind your own proxy, or use the OpenLoft Memory service, which binds the tenant to a credential and bounds the cache.

Quick start

The shortest useful path. Construct a memory, give it a turn, recall what it kept.

python
import asyncio
from nmafc.wrapper import NeuromorphicMemory
from nmafc.integration.factory import create_llm_provider, create_embedding_provider

async def main():
    memory = NeuromorphicMemory(
        llm_provider=create_llm_provider("openai/gpt-4o-mini"),
        embedding_provider=create_embedding_provider("openai/text-embedding-3-small"),
    )

    # one turn: recall, generate with the facts injected, then write what it learned
    reply = await memory.process_turn("I run a bike shop in Lisbon.")
    reply = await memory.process_turn("Which city am I in?")

    # read-only retrieval — performs no writes and no reinforcement
    result = await memory.recall("where is the shop?")
    print(result.context)

    memory.close()

asyncio.run(main())

Use process_turn, not remember-then-recall

A turn fuses fact extraction into the same LLM call that produces the reply. Calling remember() and recall() separately costs a second LLM round trip per turn, because extraction then has nothing to fuse with. process_turn is one call; the split path is two.

Synchronous code

python
from nmafc.wrapper import SyncNeuromorphicMemory

memory = SyncNeuromorphicMemory.from_config()
memory.process_turn_sync("I prefer Postgres over Mongo.")
context = memory.recall("what database do I prefer?")
memory.close()

Talking to it over HTTP

shell
# /v1/memories is Mem0-shaped, so migrating from Mem0 is a base URL and a key
curl -X POST http://127.0.0.1:8000/v1/memories \
  -H "Authorization: Bearer $NMAFC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"I moved to Porto in March."}]}'

curl -X POST http://127.0.0.1:8000/v1/memories/search \
  -H "Authorization: Bearer $NMAFC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"where did I move?"}'

Choosing an integration

Four ways in. They reduce to one decision: how much of your request loop do you want to hand over.

IntegrationUse it whenCostTool-calling safe
Python SDKYou own the application and want the tightest integration.Lowest — one LLM call per turn.Yes
REST (/v1/memories)You are not Python, or you want a service boundary.Two calls per turn if you split it; one if you use /chat.Yes
MCP (stdio)You want zero code change in a coding agent.One call per turn.Yes
OpenAI endpoint (/v1/chat/completions)You only want to change a base URL.Two calls per turn.No — see below

The OpenAI-compatible endpoint drops tools

POST /v1/chat/completions forwards a fixed allow-list of sampling parameters. tools, tool_choice and parallel_tool_calls are not on it, so tool-calling clients break when pointed at it. If your harness uses tools, use the explicit remember/recall surface or MCP instead. This is a known limitation of the passthrough, not something you have configured wrong.

MCP — coding agents

The zero-code-change path. The MCP server exposes six tools over stdio, so any MCP host can use NMAFC without your writing integration code.

ToolPurpose
memory_recallRetrieve context for a question. Read-only.
memory_rememberIngest a transcript. Costs one LLM call for extraction.
memory_forgetInvalidate an entity. Temporal, not destructive — see Limitations.
index_repoIndex a git repository for code search. Fully offline, no tokens.
find_callersWhat references this symbol, with file:line.
repo_statusWhich indexed files have changed since indexing.

Claude Code / Claude Desktop

.mcp.json
{
  "mcpServers": {
    "memory": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/nmafc", "nmafc-mcp"],
      "env": {
        "OPENAI_API_KEY": "sk-...",
        "NMAFC_HOT_URI": "/path/to/nmafc/data/lancedb",
        "NMAFC_COLD_URI": "/path/to/nmafc/data/cold.db"
      }
    }
  }
}

Cursor

.cursor-mcp.json
{
  "mcpServers": {
    "memory": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/nmafc", "nmafc-mcp"]
    }
  }
}

Launch it from the repository root

The server reads config from configs/default.toml relative to the working directory. Started from anywhere else it silently falls back to library defaults, which usually means a different data directory than you expect.

Codex uses the same JSON shape in .codex-mcp.json. Any other MCP host takes the same command and environment.

OpenAI drop-in

If your stack is already OpenAI-shaped, MemoryOpenAI wraps an existing client and interposes memory on chat completion. It remembers on the way out and recalls on the way in.

python
import openai
from nmafc.proxy import MemoryOpenAI
from nmafc.wrapper import SyncNeuromorphicMemory

client = MemoryOpenAI(
    openai.OpenAI(api_key="sk-..."),
    memory=SyncNeuromorphicMemory.from_config(),
)

# extra keyword arguments are consumed by the wrapper, not sent upstream
reply = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Remind me what I said about the lease."}],
    memory_conversation_id="lease-thread",
).choices[0].message.content

This is a decorator, not transparent interception

MemoryOpenAI wraps one method on chat.completions and is not a subclass, so isinstance checks against the OpenAI SDK fail and strictly-typed clients reject the extra keyword arguments. Everything else passes through untouched. If you need real transparency, the explicit SDK is less surprising.

Agent hosting

OpenLoft deploys and operates agent harnesses for you. Every one of them reads the same memory — which is the point, not a side effect.

HarnessStatusNotes
OpenClawAvailablePlugin ecosystem, agent-to-agent mesh, skills and MCP servers. Web UI on its own subdomain.
Hermes AgentAvailableSelf-improving skills, cron scheduling, browser automation. OpenAI-compatible gateway.
Claude CodeAnnouncingAnthropic’s terminal agent, containerised. Bring your own Anthropic key.
OpenCodeAnnouncingOpen-source terminal agent, any LLM provider. Bring your own provider key.
CodexAnnouncingOpenAI’s coding agent, headless. Bring your own OpenAI key.
Gemini CLIAnnouncingGoogle’s terminal agent. Bring your own Google key.

Why only terminal agents

The harness has to be a program we can run in a container with a key and a health check. Desktop applications cannot be — Cursor, Windsurf and VS Code are applications on your machine, so they connect to the same memory over MCP instead of being hosted. See Harness setup above.

What you get

  • A provisioned container with its own subdomain and TLS.
  • Secrets pulled at runtime from a per-tenant vault, never as plain environment variables.
  • Health checks and automatic restart if the process dies.
  • An agent-to-agent gateway so agents in the same tenant can pair and collaborate.
  • WireGuard or overlay networking onto the shared memory service.

Two concurrent turns on one scope are not coordinated

The turn counter and the decay pass are not locked. One conversation at a time per scope is safe. Parallel writes to a single scope are not.

Hosted memory

The same engine, operated by us. Multi-tenant on our infrastructure, or deployed inside yours.

FormatStatusWhat it means
Managed, multi-tenantAvailableOne key per tenant, isolation by credential, bounded per-tenant resources. No vector database to provision.
Your VPCCommittedManaged Helm charts for EKS, GKE and AKS, in your own cloud account.
On-premiseCommittedA container bundle running beside your own inference — vLLM or Ollama. Air-gap installable.
Air-gapped / classifiedCommittedZero network egress. Local embeddings, local models, offline image bundle.

Enterprise controls — current state

ControlState
Tenant isolation bound to a credentialLive
Bounded per-tenant resourcesLive
SSO / OIDC and role-based accessCommitted
Access audit log with actor identityCommitted
Right-to-erasure with a certificateCommitted
Support SLA and named engineerCommitted

Committed is not shipped

The four committed controls are designed and scheduled but not built. If a procurement questionnaire depends on one of them, say so early — the honest answer is that the design exists and the work has not started.

Data residency

Content reaches whichever LLM and embedding providers you configure, even in a sovereign deployment. For a genuinely air-gapped setup, use a local model and a local embedder so nothing crosses the boundary. This is worth raising with a data protection officer early rather than at audit.

Metered LLM access

Use our model keys with per-request metering, or bring your own. Either way the same proxy fronts the provider.

  • Platform credits: one balance, metered per request, floored at zero rather than allowed to go negative.
  • Bring your own key: your provider credentials are stored per agent and used directly.
  • Per-agent switching: change an agent between platform and your own key without redeploying.
  • One proxy in front of every supported provider, so changing provider is a configuration change.

Memory turns cost tokens too

Extraction calls a model on every turn, so a memory turn is billable even when you are not paying for the reply. Meter it as you would any other model call.

API endpoints

MethodPathNotes
POST/v1/memoriesIngest a transcript. One LLM call for extraction.
POST/v1/memories/searchSemantic search. Returns score and hop distance.
GET/v1/memoriesList records for a tenant.
GET/v1/memories/{id}One record.
PATCH/v1/memories/{id}Override a fact. The previous version is retained.
DELETE/v1/memories/{id}Invalidate a fact. Temporal, not destructive.
GET/v1/memories/{id}/historyEvery version of a fact.
POST/v1/chat/completionsOpenAI-compatible with memory. Drops tools — see Integrations.

Scope

Every record is scoped by agent_id and conversation_id, passed as the X-Agent-Id and X-Conversation-Id headers or in the request body. One engine instance is bound to one pair, so a memory object cannot be used to read a different scope — it raises rather than widening.

CLI

shell
nmafc start        # serve the API and the explorer UI
nmafc init         # interactive setup wizard
nmafc chat         # terminal chat against your memory
nmafc eval locomo summarise   # regenerate the benchmark table from committed data

Configuration

A TOML file, or environment variables. Environment variables win.

configs/default.toml
[storage]
hot_uri = "./data/lancedb"      # working set
cold_uri = "./data/cold.db"     # append-only archive
embedding_dim = 1536
agent_id = "default"
conversation_id = "default"

[llm]
provider_model = "openai/gpt-4o-mini"

[embedding]
provider_model = "openai/text-embedding-3-small"

[decay]
# how long a working fact stays active. 0.005 gives roughly a 139-turn half-life.
lambda_active_context = 0.005
rerank_top_k = 20      # facts injected into context
hydrate_top_k = 5      # source turns attached behind them
VariablePurpose
NMAFC_HOT_URI / NMAFC_COLD_URIStorage locations. The hot tier also accepts an s3:// URI.
NMAFC_LLM_PROVIDER_MODELProvider and model for extraction and generation.
NMAFC_EMBEDDING_PROVIDER_MODELEmbedding provider.
NMAFC_EMBEDDING_DIMEmbedding width. Probed at boot if unset.
OPENAI_API_KEY / ANTHROPIC_API_KEYCredentials, read from the environment only.

Tuning decay is the highest-leverage change

lambda_active_context sets the half-life of a working memory in conversational turns. Raising it makes the engine remember longer and decay less. It is the setting most worth understanding before you change anything else.

Providers

ProviderKindNeeds a key
openaiLLM + embeddingYes
anthropicLLMYes
azure-openaiLLM + embeddingYes
bedrockLLM + embeddingAWS credentials or an API key
groq, openrouter, togetherLLM + embeddingYes
ollama, lmstudio, vllmLLM + embeddingNo — local inference
fastembedEmbedding onlyNo — runs locally

fastembed is worth knowing about if conversation content must not leave your boundary: it runs a small embedding model locally with no network call.

How it works

A turn goes through the write path; a question goes through the read path.

Write

  • The turn is sent to your model with a tool-calling schema, which returns an assistant reply and a set of atomic facts in the same response.
  • Relative dates are resolved to absolute calendar timestamps at this point, so "last week" stops being ambiguous the moment it is stored.
  • Each fact is given a memory type and a validity interval. A fact that contradicts an existing one supersedes it rather than accumulating.
  • Facts are written to the archive, then the working set. The archive is never mutated — superseded and invalidated rows are marked, not removed.

Read

  • The question is turned into an embedding and retrieved from the working set, the archive, and along entity links — up to two hops.
  • Results from each channel are fused by reciprocal rank fusion, which combines ranked lists without needing their scores to be comparable.
  • The top facts and the turns behind them are assembled into the context, verbatim and compacted, and handed to the model.
  • Retrieving strengthens what was retrieved, and the turn counter advances so everything fades slightly.

Why context stays small

Facts are roughly 32 tokens each, and only a handful of source turns are attached behind them. That is why a memory layer can answer questions across many sessions without the prompt growing with the transcript.

Benchmarks

On the LoCoMo benchmark of 1,985 conversational questions, both arms answering each question in the same execution window. You can regenerate the table yourself with one command and no API credentials.

shell
python scripts/benchmarks/results/paired_2026_09_10/summarise.py
CategorynNMAFCVanilla RAGDifference
Temporal32171.7%46.1%+25.5
Multi-hop9657.3%44.8%+12.5
Single-hop28150.9%45.6%+5.3
Open-domain84179.8%80.3%-0.5
Adversarial44635.9%50.4%-14.6
All categories1,98563.4%61.4%+2.0

Read the p-values, not the headline

The overall +2.0 is not statistically significant (p=0.085), and neither is the +5.3 on single-hop (p=0.133). The result that holds is temporal: +25.5 at p≈1e-14. The +6.8 on the scored set is largely the same finding seen from another angle — remove the temporal questions and it drops to +1.9 (p=0.165). We publish the losses because a benchmark that only shows wins is marketing, not evidence.

LoCoMo is licensed CC BY-NC 4.0. Its licence is non-commercial, so the aggregate numbers are reported here for reference and the dataset is not redistributed as a commercial evaluation harness.

Limitations

If you are evaluating this, read this section before you build on it. Every item is a measured or documented limitation, not a hedge.

Forgetting is not erasure

forget_entity and forget_record invalidate a fact so it stops being retrieved. The write-ahead archive retains the entry and there is no purge path. If you need right-to-erasure semantics, this is not yet it, and you should not describe it as such to a regulator.

Unanswerable questions are a weak spot

On false-premise questions NMAFC scores 14.6 points below vanilla RAG, and it will sometimes answer a question it should have declined. Two separate prompt-level fixes were measured and both made temporal accuracy worse, so this is not resolved. If your product needs reliable refusal, measure it yourself.

The bundled server is not hardened for the internet

The explorer endpoints trust a caller-supplied X-Agent-Id header and do not require a key, and the tenant instance cache is unbounded. Fine on localhost; not fine on a shared network. Use it behind your own proxy or use the OpenLoft Memory service.

Decay is conservative in the shipped configuration

At the default lambda_active_context of 0.005 a working fact takes roughly 460 turns to demote. That is deliberate — the aggressive default was measured and discarded — but it means decay is a long-horizon mechanism, not something you will observe in a short conversation.

Concurrent turns on one scope are not coordinated

The turn counter and the decay pass are not locked. Two simultaneous process_turn calls on the same scope can interleave. One conversation at a time per scope is safe; parallel writes to a single scope are not.

No point-in-time reconstruction

There is a rollback that rebuilds the working set from the archive, but it is lossy and it mutates live state. A read-only "what did the agent know at turn T" does not exist yet.