MemoryIndustry
MCP serverDocs & knowledgeMemoryIndustry persistent memory MCP server: BM25+MMR retrieval and audit.
Unavailable. This server has no hosted endpoint yet, so ahel can't serve it.
Connect ahel once, and every AI you use reads what you have installed.
From the project's README
As published by leandropg19/memorys in README.md.
Formerly cuba-memorys. Same daemon, same cuba_* MCP tools, new product name.
Long-term memory for AI coding agents. An MCP server that gives your agent a knowledge graph it can search, reason over, and be corrected by — so it stops forgetting your codebase between sessions.
Written in Rust. Backed by PostgreSQL + pgvector. 31 MCP tools (32 with CUBA_DOCS=1), 23 CLI commands, and every number below measured on a benchmark that — as of v0.12 — actually measures what it claims to. (The previous one did not. See Measured.)
Install
pip install memory-industry # or: npm install -g memory-industry
claude mcp add memory-industry -- memory-industry
# Previous names still install the same binary:
# pip install cuba-memorys
# npm install -g cuba-memorys
That is the whole setup. On first run it provisions a PostgreSQL 18 + pgvector container via Docker and initializes the schema. Docker must be running. The cuba-memorys command remains a binary alias.
{
"mcpServers": {
"memory-industry": {
"command": "memory-industry"
}
}
}
No DATABASE_URL needed. Or run cuba-memorys setup (or memory-industry setup) and it writes the config for every client it finds — then cuba-memorys setup check audits them for disagreement, which is the failure that actually bites (two configs, two embedding dimensions, one silently broken search).
{
"mcpServers": {
"memory-industry": {
"command": "memory-industry",
"env": { "DATABASE_URL": "postgresql://user:pass@localhost:5432/brain" }
}
}
}
Needs the vector and pg_trgm extensions. cuba-memorys doctor will tell you if anything is missing.
stdio gives every client its own process, and every process loads its own copy of the models — embeddings, reranker and NLI together are several GB. Three editor windows meant three copies, and on a 16 GB laptop that is the whole machine.
serve loads them once and answers every client over loopback HTTP, which is also the shape the 2026-07-28 MCP specification settled on: no session handshake, every request self-describing.
cuba-memorys serve # 127.0.0.1:8787 by default
cuba-memorys serve 127.0.0.1:9000 # or pick the address
memory-industry serve is the same command. Point every client at it, and give each one its own Mcp-Client-Id so their sessions stay separate — without it jornada start in one window becomes the active session of the next:
{
"mcpServers": {
"memory-industry": {
"type": "http",
"url": "http://127.0.0.1:8787/mcp",
"headers": { "Mcp-Client-Id": "editor-window-1" }
}
}
}
GET /health reports uptime, database reachability and the clients seen so far. CUBA_HTTP_ADDR overrides the address; CUBA_HTTP_TOKEN requires Authorization: Bearer, and is mandatory if you bind anything other than loopback — the daemon serves the entire graph with no authentication by default.
Models load in the background after the port opens, so a client that connects during startup waits on its first search instead of timing out the connection. Under stdio that timeout was how you ended up with abandoned multi-GB processes: the client gives up at 30 s but never closes stdin, so the server sat there holding every model it had loaded. Stdio now exits if no handshake arrives within CUBA_HANDSHAKE_TIMEOUT_SECS (60 s, 0 disables).
Without a model, embeddings are hash-based: deterministic, and semantically meaningless. Search still works through the lexical and BM25 branches, but nothing understands meaning.
One command installs the models and the ONNX runtime, on any OS — no shell scripts, no manual ORT_DYLIB_PATH:
cuba-memorys models all # embeddings + NLI + reranker + runtime
cuba-memorys models embed # just the embeddings model (~113 MB)
cuba-memorys models all --gpu # GPU runtime, if you have one
cuba-memorys doctor # confirms what loaded
Everything lands in ~/.cache/cuba-memorys/ and is found automatically. models downloads only when you run it — nothing is fetched behind your back.
bge-m3 (1024-d) is better than e5-small for Spanish, though the size of the gap is no longer claimed (the old +21 nDCG figure came from a broken benchmark). It needs a dimension migration (scripts/migrate-embedding-dim.sh 1024) and CUBA_EMBED_MODEL=bge-m3 CUBA_POOLING=cls.
CUBA_MODE is a preset that sets the database, the models, and outbound network together, so you pick one name instead of lining up a dozen env vars:
CUBA_MODE | Database | Capabilities | Network out |
|---|---|---|---|
local (default) | Docker on this machine | embeddings + NLI as installed | none |
red | shared managed Postgres (set DATABASE_URL with sslmode=require) | + provenance per node, real-time sync between machines | none |
completo | whatever DATABASE_URL implies | + reranker (GPU if present) + cuba_docs | cuba_docs |
Two machines, one memory. Point both at the same managed Postgres (Neon or Supabase free tier both have pgvector and fit the 36 MB corpus many times over), give each a name with CUBA_NODE_NAME, and CUBA_MODE=red. What one writes, the other reads; every memory records which machine it came from (origin_node). Without a shared database, cuba_sync does the same job through a git repository — see Sync between machines. Do not expose your own Postgres port to the internet — use a managed provider's TLS, or a private network like Tailscale.
Real isolation when you share. A shared database is where row-level security stops being decorative. Run cuba-memorys secure once (as the admin role) to create a non-superuser cuba_app with RLS and append-only audit actually enforced, then point the runtime at it with CUBA_SKIP_MIGRATIONS=1. cuba-memorys doctor reports whether the runtime role is a superuser (which bypasses all of it) or not.
Maximum capability. CUBA_MODE=completo turns on the cross-encoder reranker (+93% nDCG) and cuba_docs. The reranker no longer needs that mode when the machine can actually run it: a build with a GPU provider that finds a working device turns it on by itself, because that is where it fits its budget. On CPU it stays off by default — the table below is why — and cuba-memorys doctor says which of the three reasons applies. Asking for rerank: true in the call still overrides everything. On CPU faro time-boxes it and falls back to the RRF ranking (CUBA_RERANK_TIMEOUT_SECS, default 20 s), so a slow machine still answers. GPU binaries ship with CUDA (NVIDIA) and, on Windows, DirectML (any GPU) — cuba-memorys models runtime --gpu fetches the accelerated runtime.
Fetching the GPU runtime is only half of it: the binary itself has to be built with --features cuda, or gpu::configure() registers no provider and the reranker runs on CPU. That is not a hypothetical — it is what a 50-candidate rerank costs on a 6-core laptop, measured with cargo run --release --example rerank_bench:
| build | 50 candidates, mixed lengths | inside the 20 s budget? |
|---|---|---|
CPU, with_intra_threads(2) | 106,9 s | no — scores computed, then discarded |
| CPU, physical cores | 61,0 s | no |
--features cuda | 4,1 s | yes |
Same ranking either way — CPU and GPU agree candidate for candidate, differing only in the fifth decimal of the score. Run rerank_bench on any machine to see whether the reranker fits its budget there or is silently throwing the work away, and cuba-memorys doctor reports whether this build has a GPU provider at all.
This section used to say "every model quietly runs on CPU", implying all three would run on the GPU once you built with --features cuda. Only the reranker ever did. The embedder ships dynamically quantised to INT8, which means 96 DynamicQuantizeLinear feeding 144 MatMulInteger — and the CUDA provider registers no kernel for either, so ONNX Runtime partitions them onto the CPU no matter what you build. Registering CUDA for that session bought nothing and cost a VRAM arena the model never computed in: 374 MiB held while all 544 MB of weights sat in host RAM. The NLI cross-encoder has the opposite problem — it is FP32 and stuck there, because mDeBERTa is documented upstream as not supporting FP16 and the INT8 build returns confident false entailments.
So placement is now decided per model rather than once per process, and only the reranker asks for the GPU. On the 6 GB card this was measured on, the daemon went from 5228 MiB of VRAM to 2950 MiB while searching, and 0 while idle — and down to 1460 MiB with the two opt-in steps in Footprint below.
Individual env vars (CUBA_DOCS, CUBA_RERANKER_PATH, …) always override the preset.
What it actually does
Most memory servers are a key-value store with an embedding bolted on. This one models four kinds of memory, because the psychology literature says they are four different things and they decay differently:
| What it holds | How it strengthens | |
|---|---|---|
| Semantic | Facts about entities — "all endpoints are async" | Access (Hebbian/BCM, Oja 1982) |
| Episodic | Events with actors and time — "we shipped v2 on Tuesday" | Power-law decay (Tulving 1972, Wixted 2004) |
| Procedural | How things are done here — recipes with a track record | Success, not access (ACT-R) |
| Working | Scratch notes bound to the current session | Cleared with the session |
Procedural memory is a separate table rather than a ninth observation type for a specific reason: ACT-R separates declarative memory (reinforced by access) from procedural (reinforced by success). As an observation, a recipe consulted constantly because it keeps failing would climb in importance. It is ranked by Wilson lower bound, so 1/1 successes scores 0.21 and 47/50 scores 0.84 — a lucky first try does not outrank a track record.
Retrieval
Hybrid RRF fusion (k=60, Cormack 2009) over three signals — full-text, BM25 (ts_rank_cd), and pgvector HNSW — with entropy-routed weighting that shifts from keyword-heavy to semantic as the query's Shannon entropy rises.
Answers arrive in compact by default: abbreviated keys, content truncated at 1200 chars. 30% fewer tokens, and a slightly better nDCG — measured on the 221 id-scored questions, +0.0090 with a paired 95% interval of [+0.0024, +0.0166]. The format genuinely cannot change which documents rank; what it changes is how many of them survive the response token budget before they are scored. Verbose at the default 5000-token budget weighs 5286 tokens and loses its tail; compact weighs 3723 and keeps it. Pass "format": "verbose" for the full per-branch score breakdown.
Verification that actually verifies
cuba_faro mode=verify checks a claim against what is stored. It used to score claims by cosine similarity to the retrieved evidence, and that does not work — similarity measures what a text is about, not what it asserts. "cuba-memorys is written in Rust" and "…in Java" are nearly the same vector. Measured on the live corpus, the false claim scored 0.61 and the true one 0.59.
Entailment is a different question from similarity, and it needs something that reads. A local cross-encoder now judges each piece of evidence — supports / contradicts / unrelated — and confidence is derived from the verdicts, each weighted by that evidence's similarity. Same corpus, after:
| Claim | Before (cosine) | Now |
|---|---|---|
| "written in Rust" (true) | 0.59 | 0.995 · verified |
| "written in Java" (false) | 0.61 | 0.00 · contradicted |
| "the best paella uses saffron" (unrelated) | 0.45, with 10 "evidence" items | 0.00 · unknown, no evidence |
Being on-topic is not support, and unrelated counts for neither side.
The judge is mDeBERTa-v3-base-xnli running locally on ONNX: 100 languages, ~50 ms per verdict, no API key, no network, no cost. That matters here — about 75% of this corpus is Spanish, and the English-only NLI models everyone reaches for first would have silently failed on three memories out of four. Install it with cuba-memorys models nli; cuba-memorys doctor will tell you whether it loaded.
Without it, verification falls back to an LLM (your MCP client's own model via sampling, a local claude CLI, or the Anthropic API) — and with none of those, to an honest unknown rather than an invented verdict.
Two things it will not do. It will not confirm a claim on weak evidence: entailment must clear 0.80 while contradiction needs only 0.60, because confirming a false memory and doubting a true one are not errors of equal cost. And when it cannot tell, it says so instead of returning whichever number came out largest — an argmax over a 3-way head will happily publish supports for a claim that is flatly false, and did.
Calibrated abstention
The out-of-distribution gate rejects queries the corpus cannot answer. The threshold is not a magic constant: Ledoit-Wolf covariance shrinkage plus a conformal quantile, calibrated against your own corpus with cuba-memorys calibrate --dataset <questions.jsonl> --apply and persisted (the dataset is required — without it the command refuses). (The theoretical χ² threshold rejected 100% of answerable queries. Distribution-free calibration is not a nicety here.)
Sync between machines, through git
CUBA_MODE=red puts two machines on one database. cuba_sync is the other route, for machines that never see each other: the graph is written out as JSON you can commit, and read back on the other side.
cuba-memorys sync export # write the bundle under .cuba-memorys/
cuba-memorys sync import # read one back in
cuba-memorys sync diff # entities on disk vs entities in the database
cuba-memorys sync status # which bundles this machine has already imported
cuba-memorys hook install # export after every commit, import after every checkout
The same four actions are cuba_sync action=export|import|diff|status. A bundle is one JSON file per entity with its observations inside, plus episodes/YYYY-MM/, errors/, decisions/, relations.json, projects.json, tombstones.json and a manifest.json — the active project and anything not bound to a project, unless you pass --scope all. Embeddings stay out unless you ask for them (--with-embeddings): they are most of the bytes and they can be recomputed. A bundle imports once, and the manifest hash covers the contents of every file in it — so an unchanged bundle is skipped, and a hand-edited entity file is a new bundle rather than a silent no-op.
A deletion travels now, and stops where it would take something with it. Deleting a row records a tombstone, and the receiving side deletes exactly the ids that were named. Before this, a delete was not slow to arrive — it was undone: the peer still had the row, exported it, and it came back on the next round trip. The entity tombstone is the dangerous one, because deleting an entity cascades to everything hanging off it. It is applied only when this machine has no observations or episodes under that entity that the sender never named; otherwise it is withheld and reported in tombstones_withheld. A tombstone for an entity with three children there must not take three hundred here.
And a bundle cannot quietly wipe you. If the tombstones in it would delete at least 25 rows and more than 10% of the observations on this machine, the import refuses and asks for confirm=true. A remote wipe and a large legitimate cleanup look identical; the only difference is whether you meant it. The floor matters as much as the ratio: on a database with a single observation a pure percentage demanded confirmation to delete that one, and a guard that trips on ordinary curation is one everybody learns to pass confirm=true through — and then it guards nothing.
conflict=merge does not merge content, and now says so. merge and skip are one policy: rows that are missing here arrive, and where a row already exists with different content, the one that was here first wins and the incoming text is dropped. What changed is the silence — the import counts those rows and reports them as diverged, with their ids and a note saying what it did. conflict=overwrite takes the incoming version and keeps the one it replaced in previous_versions (the newest 20 are kept), and clears the embedding when the content changed, so a row stops being retrievable by a meaning it no longer carries.
Counters do merge, under either policy. importance and access_count on an entity, and strength on a relation, are not values one side copies from the other: each machine grows its own, from its own reinforcement and its own traversals. The higher of the two wins, which is idempotent — importing the same bundle twice inflates nothing. (Summing would be more faithful to "both machines counted", and would double on a re-import, so it loses to a rule that cannot corrupt the number.)
Which machine is which. Each installation generates a uuid in its own database on first migration — one row, stable across restarts, unique by construction — and the manifest carries it, so a bundle can say which machine produced it. CUBA_NODE_NAME keeps meaning what it always meant: a human-readable label stored in origin_node. It is not the identity and could not be one, because two machines both called pop-os is the likeliest outcome there is.
The clock ticks for what a peer needs, and stays still for local noise. An observation's version advances when its content, type, trust, evidence level or tags actually change, and for nothing else. Decay moves importance and last_accessed; reembed replaces vectors. If either woke the clock, every export would ship a graph that had not changed and the two machines would never stop talking to each other about nothing. Rewriting a row with the same content does not tick it either, so an idempotent re-import does not invent a conflict out of agreement.
Older bundles still import. The format is SCHEMA_VERSION 2: version, updated_at, origin_node, previous_versions, evidence, verification and trust travel now, because a conflict rule that compares clocks needs the clock to be in the file. Bundles written before that still import — every new field defaults, and a v1 observation lands as asserted, which is the honest reading of a file that never claimed anything stronger.
Anything in an incoming bundle that looks like a credential is stored quarantined instead of trusted — withheld from cuba_faro and cuba_expediente until you promote it with cuba_eco — because an import reads JSON out of a repository anyone with push access can write to.
A peer that only ever reads. CUBA_PEER_TOKEN reaches five more verbs and nothing else. pull returns the bundle in the response instead of writing it anywhere, paged by file (limit, offset) up to a 3 MB budget per page — abort if manifest_hash changes between pages, because that means this node was written to mid-transfer and the pages describe two different states. notify is the one write a peer token may make: a short summary (at most 2000 characters) saying the other machine learned something, tagged with node_id/node_name, surfaces at the next cuba_jornada start and in status, and closes itself when a bundle carrying its manifest_hash is imported — it never enters the graph itself. conflicts lists the rows two machines disagree about with both texts, and resolve id=… keep=ours|theirs|both closes one: keep=both (the default) keeps this machine's text current and files the other in previous_versions, discarding nothing, while theirs also clears the embedding because it described text that is no longer here. fetch is the other half and runs on the local machine: it pages a peer's pull over HTTP, lands the files, imports them with the same validation as any bundle, and records the peer's manifest hash so the next fetch stops before opening a transaction when nothing changed. Embeddings are omitted by default on export and included by default on pull — a peer that receives text without vectors cannot search what it just received until it re-embeds, which on a machine without a GPU is slow and sequential — and a bundle whose model or dimension does not match this machine is refused rather than silently filling the index with vectors from another space.
And it tells you when it is broken
$ cuba-memorys doctor
[ ok ] migrations 49 aplicadas, ninguna dirty
[ ok ] embedding_dim runtime 1024-d == columna vector(1024)
[ ok ] runtime_role 'cuba_app' sin superuser — RLS y audit efectivos
[ warn ] binary_freshness 4 proceso(s) MCP corren un binario más viejo que el de disco
This exists because the failure mode of a hybrid search engine is not a crash — it is a vector branch dying and the search quietly becoming lexical, with no symptom. The server now refuses to start on an embedding-dimension mismatch, and search sets degraded: true in the response when a branch fails.
The CLI: your memory without an LLM in the middle
Twenty-three commands. memory-industry --help lists them all.
Shortened here. Read the whole README on GitHub.
Signals
- GitHub stars
- 29
- Forks
- 3
- Last commit
- Sep 2026
- Weekly downloads
- 163
ahel review (caution)
S2medium
demands high-sensitivity credentials
Automated review, not a security audit. Ruleset v1.
Advanced
- Delivery
- memory-industry MCP server → your ahel gateway (mcp.ahel.ai) → every connected AI client.
- Catalog kind
- mcp-server
- Gateway key
io-github-leandropg19-memory-industry- Source
- github.com/leandropg19/memorys