Agent memory that forgets on purpose, and stays where it belongs.
A naaba is a chief among the Mossi of Burkina Faso — the one who holds and passes on what the community knows. That is the job here: not to store everything an agent has ever seen, but to keep what is worth keeping, and to keep it in its place.
Memory that only grows is not memory, it is a log. The usual agent memory appends every turn and retrieves by similarity. After a few thousand entries the same fact is stored eleven times in eleven phrasings, retrieval returns the eleven, the context window fills with restatement, and the answer gets worse as the system "learns" more. A memory layer has to forget, and it has to forget by a rule you can read.
Memory that grows in one pool leaks. One vector table for every user means every search
traverses everyone's vectors, and correctness of isolation rests on remembering a WHERE clause.
It also means one physical location for data that different jurisdictions require to sit in
different places — which is not a filter problem, it is a storage problem, and no application-level
check fixes it.
Isolation is in the index, not in the query. CockroachDB's vector index accepts prefix columns:
CREATE TABLE memories (
user_id UUID NOT NULL,
kind STRING NOT NULL,
content STRING NOT NULL,
embedding VECTOR(1024),
VECTOR INDEX (user_id, kind, embedding)
);That is one K-means tree per (user_id, kind), not one tree filtered afterwards. A search for one
user does not walk another user's vectors — a forgotten predicate degrades into no results rather
than into someone else's memories.
kind is in the prefix for a reason found by review, not by design: deduplication needs the nearest
memory of the same kind, and a predicate applied after a vector search can only remove rows from
the k it returned, never reach past them. Filtering in application code left duplicates undetected
whenever closer memories of other kinds crowded the result. Recall still searches every kind at
once — an unconstrained prefix column yields one span per value.
Location is a property of the row. With REGIONAL BY ROW, a memory written in Frankfurt is
stored in Frankfurt and a memory written in Singapore is stored in Singapore, under one logical
database and one query. Residency stops being a second deployment and becomes a column.
The two partitionings compose rather than collide — CockroachDB puts crdb_region ahead of
user_id in the vector index prefix, so a search narrows to one region and one user without a line
of application code. That is measured, not assumed: the query plans are in
docs/compatibilite-index-vectoriel.md.
Forgetting is a policy, not a cron job. Duplicate memories are merged rather than accumulated, each kind of memory has a ceiling, and what gets evicted at the ceiling is chosen by how recently it was useful — not by age. Session memories expire; pinned ones never do.
A distance cannot tell you whether two memories say the same thing. This was going to be a
similarity threshold, until the distances were measured. On Titan v2, "call him in the morning"
and "call him at the end of the day" — two contradictory instructions — sit 0.90 apart, while
"the head office is in Ouagadougou." and the same sentence with its accents stripped sit 1.15
apart. The contradiction is closer than the restatement, because an embedding measures shared
subject, not shared claim. So similarity search narrows the field to one candidate and a model
decides; on the pairs where distance failed, the judge is right every time. If the judge is
unavailable, nothing is merged — an extra memory is recoverable, an overwritten one is not.
The measurements are in docs/seuil-de-doublon.md.
Every recalled memory carries where it came from. A memory that cannot name its origin cannot be checked, and an agent that cannot be checked is one you have to trust.
It can tell you what it forgot, and why. Every automatic deletion — a ceiling eviction, an
expiry — leaves a line recording the kind, the age, the number of times the memory had been
recalled, and the rule that applied. Never the content: a record of forgetting that kept what was
forgotten has not forgotten anything, and it would quietly turn a ceiling that exists to hold less
into an archive of everything the user ever said. Deletions the user asked for are not recorded at
all. Without this journal, "it forgets by a rule you can read" is a claim you have to take on
faith; with it, curation_state returns the rule and the evidence. The journal is itself kept to a
retention window — an explanation that accumulated forever would be the failure this repository
opens by describing.
Four tools: remember, recall, forget, curation_state.
None of them takes a user_id. The vector index bounds a search to (region, user)
structurally, but that guarantee evaporates if the caller names the user in an argument — changing
one UUID would read somebody else's memories. So identity arrives with the bearer token and nowhere
else. Only the SHA-256 of a key is stored; a dump of the key table hands over nothing, and a lost
key is revoked rather than recovered.
That property holds by the absence of a parameter, which nothing in the language defends — so a
test does, for the day someone adds user_id to a signature while debugging.
naaba issue-key my-agent --region eu-central-1 # printed once, stored hashed
naaba serve --port 8000 # http://127.0.0.1:8000/mcp
python scripts/verifier-mcp.py # a real MCP client, end to endhttps://sfafxe9u2i.eu-central-1.awsapprunner.com/mcp
Three minutes of it, running: https://youtu.be/zpGIpFkBc0I. The terminal in that
video is the real output of scripts/demo-video.py --en against this URL.
Frankfurt, one container on App Runner, behind an HTTP health check on
/healthz. Tools are reachable with a bearer token; naaba issue-key mints one.
Two things about the deployment are worth naming, because both are easy to get wrong quietly.
The connection string is a Secrets Manager secret, not an environment variable —
an App Runner variable is readable in the console and in describe-service, and
a password that is merely inconvenient to find is not a secret. The container's
own role may invoke exactly two Bedrock models and read exactly one secret; it
has no access to the registry it was pulled from.
The health check is HTTP rather than App Runner's default TCP, and it touches neither the database nor Bedrock. A process that still accepts connections but answers nothing would pass a TCP check; a probe that depends on a third-party service turns someone else's latency into a restart loop.
./scripts/preparer-aws.sh # once: registry, secret, two roles
./scripts/deployer-apprunner.sh # build, push, deploy, probe
NAABA_MCP_URL=https://…/mcp python scripts/verifier-mcp.py --distantThe last one is the only proof that counts: a real MCP client against the
deployed URL, with tokens issued from the developer's machine against the same
cluster — so the deployment has no way to vouch for itself. The reasoning behind
each choice is in docs/deploiement.md.
- CockroachDB — distributed vector indexing with prefix partitioning, and row-level data residency. Cloud Managed MCP Server for schema work.
- AWS Bedrock — Titan Text Embeddings V2 for vectors, Nova Micro to adjudicate duplicates, Bedrock inference for the agent.
Early. This repository is being built in the open, one measured step at a time.
- Configuration and CLI, with the dimension invariant checked at startup
- Schema: memory table, partitioned vector index, row-level residency
- Bedrock embeddings, with the dimension checked on every response
- Curation: judged dedup, ceilings, least-recently-useful eviction, expiry
- MCP server: four tools, bearer-token identity, forgetting journal
- Deployed on App Runner, in Frankfurt, verified by a real MCP client against the live URL
- Demonstration filmed against that URL, subtitled, under three minutes
No number appears in this README until it comes out of a run that can be reproduced from this repository.
git clone https://github.com/benewende-dev/naaba && cd naaba
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # then fill it in — .env is git-ignored
naaba check # says what is missing, connects to nothing
python -m pytest tests/ # no network, no AWS billPython 3.11+.
The test suite deliberately touches nothing outside the process. Two commands do the opposite, and both print what they measured rather than a green tick:
./scripts/apply-schema.sh # create the table and its indexes
naaba embed "some text" # one real Bedrock call
python scripts/verifier-bout-en-bout.py # write, search, read the query plan
python scripts/verifier-curation.py # every forgetting rule, on throwaway rows
python scripts/verifier-mcp.py # a real MCP client against the server
python scripts/mesurer-seuil-doublon.py # the distances behind the designverifier-bout-en-bout.py writes three memories, searches them, prints the query
plan, and deletes what it wrote. It fails if the vector index was not used — the
failure mode that matters here is silent, since a query that falls back to a full
scan returns correct answers and only stops being affordable at scale.
verifier-mcp.py starts the server on a free port, speaks to it with a real MCP
client over HTTP, and checks the isolation claim the way it has to be checked:
by issuing a second token and confirming it retrieves nothing of the first
user's. A stub would only prove that the stub isolates.
Apache 2.0.