A local-first memory layer for AI agents. Strata compresses what an agent tells it, indexes the result with hybrid search, and serves it back over MCP and HTTP. Every model call and every byte of storage stays on hardware you own.
AI coding agents forget. Close a session and the architecture you explained, the decision you argued through, and the bug you finally cornered are gone. The next session starts by re-explaining all of it.
The usual answers are unsatisfying in opposite directions. Dumping raw transcripts into a vector store is cheap to write and painful to read back: retrieval returns fragments of conversation, not answers. Hosted memory services read well but hand your project's internals to somebody else's infrastructure, and bill per token to do it.
Strata takes a third position. Memory is compressed into durable facts on the way in, and reasoned over on the way out, by two small models running on your own machine.
Two local models, served by Ollama, do the work. An embedding model turns text into vectors for similarity search. A small instruct model compresses raw input into a summary plus tags on write, and composes retrieved memories into a single answer on read.
content ──> hash and dedupe ──> INSERT (committed) ──> compress ──> embed ──> UPDATE
│ │ │
fails loud degrades degrades
The insert commits before any model is called. This ordering is the single most
important rule in the system: a memory that reached Strata is never lost because a
model was slow, unreachable, or unpulled. If compression or embedding fails, the row
stays durable at status: 'raw', the caller is told so, and a background repair pass
picks it up later and finishes the job.
The repair pass runs on an interval, claims a Postgres advisory lock so only one process ever works the backlog, and distinguishes two kinds of failure. Content that defeats the model counts against a per row attempt cap with exponential backoff. A transport failure, meaning Ollama is down or too slow, counts nothing at all and stops the pass, because every remaining row would fail identically and charging them for one outage would write off the corpus in a handful of passes.
query ──> cache ──┬──> lexical search ─┐
│ ├──> reciprocal rank fusion ──> synthesis
└──> semantic search ─┘
Stages run cheapest first, and the two search paths run concurrently rather than in sequence. Lexical search is Postgres full text search, which finds exact identifiers and error strings. Semantic search is pgvector cosine similarity over an HNSW index, which finds meaning when the wording has nothing in common. Reciprocal rank fusion merges the two ranked lists without needing their scores to be comparable, which they are not.
Only the fused shortlist reaches the instruct model, never the whole store, so read latency stays bounded as memory grows.
Failure is graded, not binary:
| Component | Down | Result |
|---|---|---|
| Redis | Cache miss every time | Slower, correct |
| Ollama | No query vector, no synthesis | Lexical results, no answer, warning logged |
| One search path | Fusion over the survivor | Fewer results, warning logged |
| Postgres | Nothing can be served | The call fails, loudly |
A degraded result is never written to the cache. The failure that rule prevents is specific: an Ollama outage plus a keyword-poor query produces zero results and an authored "nothing matched", which cached under a live corpus version would keep telling the agent its memory is empty long after the model came back.
One process serves both surfaces. MCP and HTTP are thin: they own validation, framing, and status codes, and nothing else. Everything either of them can do lives in the domain layer below, which is why the two cannot drift apart.
flowchart TB
agent["AI agent<br/><i>MCP client</i>"]
script["Scripts, dashboards<br/><i>HTTP client</i>"]
subgraph daemon["Strata daemon, one long-lived process"]
direction TB
auth["Bearer auth<br/><i>single token, timing-safe</i>"]
subgraph surfaces["Surfaces: validation, framing, status codes"]
direction LR
mcp["MCP over HTTP<br/><code>POST /mcp</code>"]
rest["REST<br/><code>/v1/*</code>"]
end
subgraph domain["Domain: surface-agnostic, contains no SQL"]
direction LR
tools["remember · recall<br/>search_by_tag · forget"]
fusion["Reciprocal rank fusion<br/><i>pure function</i>"]
end
repair["Repair pass<br/><i>advisory-locked, cancellable</i>"]
subgraph infra["Infrastructure: mutually isolated"]
direction LR
store["MemoryStore<br/><i>all SQL lives here</i>"]
cache["Recall cache<br/><i>version-scoped keys</i>"]
models["Model client"]
end
end
pg[("PostgreSQL + pgvector<br/><i>HNSW, tsvector, GIN</i>")]
redis[("Redis")]
ollama["Ollama<br/><i>embedding + instruct</i>"]
agent --> auth
script --> auth
auth --> mcp
auth --> rest
mcp --> tools
rest --> tools
tools --> fusion
tools --> store
tools --> cache
tools --> models
repair --> store
repair --> models
store --> pg
cache --> redis
models --> ollama
MCP runs over Streamable HTTP with a fresh protocol server per request. Sharing one server or transport across requests routes one client's response into another client's connection, so the SDK throws rather than allow it, and Strata builds a new pair each time. A single long-lived process is what makes one HTTP listener, one MCP endpoint, and one repair schedule coherent together; the alternative, spawning a process per client session, gives you N repair passes over one backlog when clients are connected and none at all when they are not.
The interesting part of this project is not the retrieval pipeline. It is that the rules the pipeline depends on are enforced by tooling rather than remembered by whoever is editing.
Module seams are lint rules, not conventions. The three infrastructure clients, Postgres, Redis, and Ollama, may not import one another. The domain layer may not import a surface. Neither surface may import the other. A violation of any of these compiles cleanly and passes every test; only the architecture rots. So each boundary is a lint rule, and a shell script probes all of them 49 times over, planting imports that must be rejected and imports that must be allowed, failing if the linter gets either wrong.
One contract, tested twice. The storage interface has a single conformance suite of behavioural assertions. It runs unmodified against both the in-memory fake used by fast tests and real Postgres in a container. A fake that has drifted from the database it stands in for is worse than no fake, because it makes the suite confidently wrong.
A guard counts only once it has been seen failing. Every check added to this codebase is first verified by deliberately breaking the thing it protects and watching it go red. A test that passes against broken code is not a test. This caught a real case where a passing suite was measuring the wrong invariant.
Errors are typed, wrapped, and never swallowed. Every external call is bounded by a timeout and converted into a tagged error with the original preserved as its cause. The mapping from error code to HTTP status is an exhaustive record rather than a switch with a default, so adding a new failure mode fails to compile until somebody chooses its status, instead of silently becoming a 500.
Retrieval quality is measured, not argued about. eval/ holds a purpose-built
corpus and a harness that scores four retrieval strategies against it. Constants like
the fusion dampener and the HNSW search width are treated as unsettled until the
harness has compared them. See eval/README.md.
More detail lives beside the code it describes:
src/README.md, module map, dependency direction, and error modeltests/README.md, the testing strategy and what each layer proveseval/README.md, the retrieval yardstick and what it has found
Requires Node 20 or newer, pnpm, and Docker.
pnpm install
docker compose up -d # Postgres with pgvector, and Redis
cp .env.example .env
pnpm check # typecheck, lint, and the full test suitedocker compose up starts the substrate only, on deliberately non-default ports so a
locally installed Postgres cannot quietly stand in for the container. The test suite
runs without any container: files that need one skip themselves. To run everything
including those, use ./scripts/integration.sh, which brings the stack up, runs the
suite serialized, and tears down from a trap on the way out.
The production stack runs Postgres, Redis, Ollama, and Strata together. Models are pulled as an explicit step rather than a boot dependency, because a multi-gigabyte download that reports success while still running is not something to gate a deployment on.
cp .env.example .env && $EDITOR .env # POSTGRES_PASSWORD and MCP_AUTH_TOKEN
docker compose -f docker-compose.prod.yml up -d --wait
docker compose -f docker-compose.prod.yml run --rm ollama-pull
docker compose -f docker-compose.prod.yml --profile app up -d --build --waitThe daemon binds loopback by default and publishes to 127.0.0.1 unless BIND_ADDR
says otherwise. That default is deliberate. Docker's firewall rules are evaluated
before the chain ufw writes to, so a published port is not closed by the rule an
operator believes closes it, and Strata serves plain HTTP, so a bearer token crosses
the wire in the clear. Point BIND_ADDR at a private overlay address such as a
Tailscale or WireGuard interface, and let the overlay carry encryption and identity.
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_URL |
required | Connection string for the memory store |
REDIS_URL |
required | Connection string for the recall cache |
OLLAMA_URL |
required | Absolute URL of the model server |
EMBEDDING_MODEL |
required | Model used for vectors |
INSTRUCT_MODEL |
required | Model used for compression and synthesis |
MCP_AUTH_TOKEN |
required for HTTP | Bearer token, minimum 32 characters |
STRATA_TRANSPORT |
http |
http for the daemon, stdio for local development |
HTTP_HOST |
127.0.0.1 |
Bind address inside the process |
HTTP_PORT |
8080 |
Listen port |
OLLAMA_TIMEOUT_MS |
60000 |
Ceiling on a single model call |
COMPACTION_ENABLED |
false |
Reserved, off by default |
A malformed environment throws at startup rather than on the first tool call, where the failure would reach the agent as a confusing tool error instead of an obvious boot failure.
Four, deliberately. Every additional tool measurably dilutes an agent's ability to pick
the right one, and remember and recall are the two that matter.
| Tool | Purpose |
|---|---|
remember |
Store content. Returns the id, summary, tags, and whether compression succeeded |
recall |
Hybrid search with optional synthesis. Returns ranked results and an answer |
search_by_tag |
Exact tag lookup. Makes no model calls at all |
forget |
Soft delete by id. Returns whether anything was deleted |
The same domain logic, for scripts and dashboards that do not speak MCP. Versioned
under /v1, because a script depending on a response shape makes that shape an API.
GET /v1/health
POST /v1/memories
GET /v1/memories tag search
DELETE /v1/memories/:id
POST /v1/memories/:id/restore
POST /v1/recall
Deletion is soft, so restore exists and is HTTP only: an agent that could undo its own deletions would be able to work around a user's decision to remove something.
A single migration owns the whole schema. Late schema changes force rewrites of tools that are already finished, so the columns that later features need ship from the start, unused, rather than arriving as a migration against live data.
Memories carry raw content, a compressed summary, tags, an embedding, a status, session provenance, and usage counters. Deletion sets a timestamp and supersession sets a pointer to the replacing row; nothing is ever removed. Every read path filters both, so a forgotten memory cannot resurface through a search path that forgot to check.
pgvector 0.8 or newer is a boot-time contract, verified by a migration rather than assumed from the image tag, because pinning an image cannot upgrade an extension inside a volume that already exists.
TypeScript in strict mode, Node 20, Hono, PostgreSQL with pgvector, Redis, and Ollama.
No any at any module boundary. Validation happens once at each edge with Zod, and
after that the code trusts its types.
Early stage and under active construction. Interfaces, schema, and tooling are still taking shape, and the storage and transport layers are further along than the retrieval tuning that sits on top of them.
MIT. See LICENSE.