Skip to content

Popoto: Agent Memory on Postgres, Redis, and Valkey

Memory for LLM agents, as primitives you program rather than a service you call. Records decay over time, confidence moves with evidence, associations form between things mentioned together, and a context assembler packs the result into a token budget before each turn.

It runs in your process against a database you already operate. Underneath is one model API with two storage backends: Redis/Valkey, the default, and Postgres, opt-in since 1.10. You choose the backend per model or per process, and the model code does not change. Your memory data stays in your database.

pip install popoto

Three packages, 9.0 MB of site-packages in a clean Python 3.12 venv (redis-py 8.1.0, measured 2026-09-04), no API key. Point it at Redis or Valkey on localhost:6379 and you are running.

Choosing a backend

For agent memory, Postgres is the recommended substrate. Every agent-memory feature runs on it, its deployment model is one central database shared by every agent and machine, and new capabilities land there first. Redis and Valkey remain fully supported, for agent memory and for the rest of the ORM, and nothing changes for an existing Redis deployment.

pip install 'popoto[postgres]'      # adds psycopg, pgvector, greenlet
export POPOTO_BACKEND=postgres      # or per model: class Meta: backend = "postgres"
export POPOTO_POSTGRES_URL=postgresql://localhost:5432/agents

Postgres needs PostgreSQL 18 or newer; EmbeddingField also needs the pgvector extension. Popoto never reads DATABASE_URL, and it creates its tables on first use. The two backends differ in a few documented places, and DataFrameField stays Redis-only: see the Use Postgres walkthrough, Postgres Backend and its documented divergences. An existing Redis memory store moves across with a one-off copy from an RDB snapshot: Redis to Postgres Migration.

Memory around an LLM turn

from popoto.recipes import SubconsciousMemory

sm = SubconsciousMemory(agent_id="agent-1")

messages, assembly = sm.inject_context(messages)   # pre-turn: retrieve + inject
answer = call_your_llm(messages)                   # your LLM call
sm.extract_memories(answer, importance=0.6)        # post-turn: save what was learned
sm.report_outcomes(assembly, outcome="acted")      # feedback: reinforce what was used

agent_id is the only required argument. It partitions every index, and both an explicit .filter(agent_id=...) query and inject_context's default retrieval path (lexical/BM25) honor that partition — the latter only since 1.9.0 (#576; before that, two agents sharing one Redis via the default loop could retrieve each other's memories). Leaving model_class unset selects DefaultMemory, which ships the benchmarked configuration: decay, confidence, a keyword index that makes retrieval respond to the query text, and an association graph. The loop is the same on either backend; with POPOTO_BACKEND=postgres set, DefaultMemory is a Postgres table.

Add memory to your agent walks the same loop up level by level, from a single decaying field to the full assembly. Running inside Claude Code, Codex, Hermes, or OpenClaw instead of your own loop? Add memory to your harness wires the same primitives into hooks and MCP, no glue code required. It runs on Redis, Valkey or Postgres.

What is measured

Every number here comes from a harness in this repository, with its result JSON committed alongside, and was measured on the Redis backend (Postgres numbers are in the Postgres Backend reference). Method, per-category tables, and the runs that came out badly are in Benchmarks.

  • Retrieval quality. LongMemEval-S, all 500 questions, hybrid BM25 + vector: Recall@1 0.892, Recall@5 0.986, MRR 0.931. Read the granularity disclosure before comparing this to another system: Popoto ranks turns and scores hits at the session level.
  • Retrieval latency. p50 3.0 ms at 1,000 records, 6.0 ms at 20,000, in-process on the lexical path, on one Apple-silicon machine. Absolute milliseconds are machine-dependent; the shape of the curve is the durable part.
  • Install weight. Three packages and no API key, verified in a clean venv. Nothing here calls out to a hosted service.

End-to-end judged answer accuracy trails retrieval quality by a wide margin. Finding the right evidence is far more reliable than answering from it, and the number, its interval, and its protocol are published in Benchmarks.

Built on a full ORM for Redis, Valkey, and Postgres

Every memory primitive is a field on an ordinary model, so the same Django-like query syntax, indexes, TTLs, relationships, and pub/sub apply to memory records and to everything else you store, on either backend.

from popoto import Model, KeyField, Field, SortedField, GeoField

class Restaurant(Model):
    name = KeyField()
    cuisine = Field(type=str)
    rating = SortedField(type=float)
    location = GeoField()

Restaurant.create(
    name="Burger Palace",
    cuisine="American",
    rating=4.5,
    location=(40.7128, -74.0060),
)

restaurant = Restaurant.query.get(name="Burger Palace")
print(f"{restaurant.name} serves {restaurant.cuisine} food.")
# => 'Burger Palace serves American food.'

On Redis, reading and writing happen at RAM speed; on Postgres, each model is a typed table with native indexes. Popoto adds async operations, multi-tenancy via KeyField namespacing, geometric distance search, timeseries for streaming data, Pandas and Xarray interoperation, pub/sub for message queues, content and embedding fields for large content storage and semantic search, and generic export/import with per-field round-trip fidelity for moving records between stores, in one format on both backends.

Start at Configuration and Models and Fields for the ORM half of the library.

Valkey is a first-class target

Popoto uses core Redis data types and commands only, with no Redis-module dependency, and the suite carries explicit Valkey-safety tests asserting that indexes stay on plain types. Point REDIS_URL at a Valkey server and the same code runs.

REDIS_URL = "redis://HOST[:PORT]/DATABASE[?password=PASSWORD]"

REDIS_URL is optional in local development. See Configuration for the full connection options.

Since August 2026 that claim also rests on observed CI. The test suite — everything except the slow-marked tests, chiefly the stress suite — runs against a real Valkey server on every pull request and every push to main, as a separately named pytest (Valkey) check in tests.yml, pinned to valkey/valkey:8-alpine alongside the Redis job's redis:7-alpine. The job asserts via INFO server that the container really is Valkey before pytest starts, and there has been no Valkey-only failure across 60 completed runs.

Error reporting is opt-in

Library-specific exceptions can be reported to the maintainers through an isolated Sentry client that never touches your own Sentry setup. It is off until you turn it on.

import popoto

popoto.enable_error_reporting()

Install popoto[monitoring] for the sentry-sdk dependency, or skip this entirely. Popoto works the same either way.


Popoto is named after the Māui dolphin, the world's smallest dolphin subspecies. Dolphins are fast, agile, and work in social groups. Popoto wraps Redis and Valkey in the same spirit.

For help building applications with Python and Redis, contact Tom Counsell on LinkedIn.