Psych Runtime
Guides

Remember facts across runs

Psych owns exactly the second of the three things people call "memory".

This is the documentation for unreleased changes on main. For the released library, read v0.1.

Psych owns exactly the second of the three things people call "memory".

Called memoryWho owns it
Conversation historyAlready the log. psych_runtime.thread(), psych_runtime.records().
Durable facts across RunsThis package.
Semantic retrieval over documentsYou. 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)  # -> int
  • remember always 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.
  • forget returns False rather than raising when the fact is already gone or never belonged to this end user. Not an error a caller needs to handle.
  • recall returns every fact, oldest first. No ranking, no similarity, no partial result. limit truncates the oldest-first order; it never selects.
  • erase deletes 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() or dispatch(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 in psych_runtime.memory. That package must not depend on psych_runtime.tools.

On this page