Remember facts across runs
Psych owns exactly the second of the three things people call "memory".
Psych owns exactly the second of the three things people call "memory".
| Called memory | Who owns it |
|---|---|
| Conversation history | Already the log. psych_runtime.thread(), psych_runtime.records(). |
| Durable facts across Runs | This package. |
| Semantic retrieval over documents | You. Psych refuses it, see below. |
The port
Four methods, on psych_runtime.memory.port.MemoryStore:
await memory.remember(scope, end_user_id, "prefers the Berlin warehouse") # -> Memory
await memory.forget(scope, end_user_id, memory_id) # -> bool
await memory.recall(scope, end_user_id, limit=None) # -> tuple[Memory, ...]
await memory.erase(scope, end_user_id) # -> intrememberalways adds. It never merges or deduplicates, because deciding when two facts are "the same" is a judgement Psych is not in a position to make for you. Do it above the port if you want it.forgetreturnsFalserather than raising when the fact is already gone or never belonged to this end user. Not an error a caller needs to handle.recallreturns every fact, oldest first. No ranking, no similarity, no partial result.limittruncates the oldest-first order; it never selects.erasedeletes everything for one end user, immediately and completely. Your customers will ask you for exactly that, and a soft delete or a tombstone would be lying about it.
The isolation boundary
MemoryKey is tenant plus end-user id, as its own validated type rather than a
string built at each call site. Both fields are required, so a call site that
forgets one fails to construct a key at all instead of silently reading or
writing under a narrower key than intended.
memory_key(scope, end_user_id) is the one place that construction happens, so
there is exactly one place a bug that drops the tenant could live.
Wiring
from psych_runtime.memory.store_backed import StoreBackedMemory
runtime = psych_runtime.Runtime(
store=store,
model=model,
registry=registry,
memory=StoreBackedMemory(store),
end_user_id="user-42", # REQUIRED when memory is set
memories=("prefers metric units",), # facts injected into the prompt
)end_user_id has no default on purpose. Defaulting it would quietly point every
end user at one bucket of facts.
With memory= set, the model gets remember and forget as tools and can
manage its own facts.
Why the default adapter does not write through Store
It would be neater, and it was the first design tried. It does not work, for a
reason worth knowing before you try it again: Store's log is append-only and
its Records are immutable by design. Memory needs forget and erase to
actually delete, and Store's contract has no delete operation anywhere, on any
of its four adapters. Modelling a fact as a Record would mean either widening
the closed Record union for a type that violates immutability the moment it is
deleted, or writing under a synthetic run id and reading it back by scanning
past the reducer, relying on adapter internals no contract promises.
So StoreBackedMemory keeps its own small, mutable, per-process table keyed by
MemoryKey and guarded by a lock. That is right for tests and single-process
deployments, and facts do not survive a restart.
A persistent adapter
Implement the same four methods against your own table. It is a short adapter,
not a project, because nothing about memory's correctness depends on the backend
the way lease semantics do. That is what "one adapter, not four" buys:
MemoryStore is not special-cased to need per-backend implementations the way
Store is.
Whatever you build, make erase a real delete.
No RAG, and this refusal is deliberate
No embeddings, no chunking, no indexing, no reindexing, and no hook shaped like a place to plug those in later.
This is the refusal most worth re-litigating on a bad day, so here is the defence plainly. Built-in RAG is the most requested and most regretted feature a library can ship. It looks like a small addition (an embedding call, an index, a similarity search) and it is actually a permanent commitment to an embedding model that will be deprecated, a chunking strategy that will be wrong for someone's documents, and a reindexing job somebody has to operate forever. None of that is a runtime concern; all of it is a product concern with its own release cadence, cost model and failure modes.
Supply a vector store and a retrieval tool of your own. Register it as an ordinary code tool and grant it in the Spec; it narrows, gets approved and gets metered like anything else.
Gotchas
- Memory is not conversation history. For "what did we say last time", use
psych_runtime.thread()ordispatch(continues=run_id). memories=on the Runtime is a different thing from the port. It is a fixed list injected into the prompt, useful for facts you already hold.- The tool wiring lives in
psych_runtime.tools.builtins, not inpsych_runtime.memory. That package must not depend onpsych_runtime.tools.