persistmemory
The official Python client for the PersistMemory API. Sync and async, typed,
with a py.typed marker.
Quickstart
from persistmemory import PersistMemory
client = PersistMemory() # reads PERSISTMEMORY_API_KEY
# A Space is required. There is no account default to fall back to — see below.
work = next(space for space in client.spaces.list() if space["name"] == "Work")
client.memories.remember(
"We chose Postgres for the ledger, not DynamoDB.",
space_ids=[work["id"]],
)
found = client.search.query("what did we decide about the ledger")
print([one["memory"]["title"] for one in found["results"]])
Three things to know first
remember does not return a memory. It hands material to the ingestion
pipeline and answers 202 with a job id. Extraction, entity resolution,
deduplication and conflict detection all run afterwards, and may produce one
memory, several, or none. Poll client.jobs.get(job_id) if you need to know
when - the terminal success state is completed, not succeeded.
A capture needs a Space. remember without space_ids is refused with a
400 that names the Spaces this account actually has. There used to be three
fallbacks — an account default, the oldest Space, or a new one invented on the
spot — and none of them exists now, because a memory filed somewhere nobody
chose is a memory nobody finds again. client.spaces.working() answers which
Space a given context is set to write to.
Search degrades rather than fails. With embeddings unavailable it falls
back to deterministic retrieval and still answers. Read
response["diagnostics"]["degraded"] before telling a user the system knows
nothing; it may merely be looking with one eye.
POSTs are not retried unless you say they are safe. A POST that timed out
may already have been processed - a connection that died after the server
accepted the request is indistinguishable from one that died before. Pass an
idempotency_key and the API deduplicates on it, so a retry returns the first
result instead of doing the work twice:
client.memories.remember(
"The migration is halfway done.",
idempotency_key="standup:2026-08-28",
)
Give the key meaning. A fresh random value per call makes every retry a new request, which is exactly what it exists to prevent.
What is here
Every resource hangs off the client, and the async client mirrors it exactly —
AsyncPersistMemory has the same attributes with the same methods, and a test
fails the build if the two ever disagree.
memories |
remember, get, list, history, confirm, pin, unpin |
search |
query, context, transcript |
spaces |
create, get, list, update, delete, merge, memories, add/remove memories, working, choose_working |
sharing |
share, unshare, set_role, collaborators, accept, restrict, add/remove memories |
conversations |
create, get, list, append, messages |
tasks |
create, get, list, update |
notifications |
preferences, set_preferences, history |
agent |
connections, enable, heartbeat, request_file, approve, deny, download, claim, complete, recent, in_space, share_machine, unshare_machine |
google |
drive files and content, upload, mail, send, attachments, contacts |
integrations |
list, available, connect, disconnect, get, update, sync |
surfaces |
the chat apps linked to the account — list, create_link, disconnect |
provenance |
recent, for_source, for_document — why a memory exists, or why one does not |
conflicts |
list, get, resolve |
entities · graph |
get, list, memories · traverse |
sources · documents |
where material came from, and what was read |
jobs |
get, list, dead, replay |
files |
upload, put, get |
keys |
list, create, revoke |
chat |
ask — an answer grounded in this account's own memory |
health |
live, ready, metrics |
A realistic example
Assembling context for a model, and filing what came back:
from persistmemory import AsyncPersistMemory, NotFoundError, RateLimitError
async def answer(question: str, conversation_id: str) -> str:
async with AsyncPersistMemory(timeout=15.0, max_attempts=4) as client:
# Bounded by TOKENS, not row count - ten long memories overflow a
# window that fifty short ones fit inside.
context = await client.search.context(
question,
token_budget=1_500,
scope="combined",
space_ids=["sp_work"],
)
if context["truncated"]:
print("Something relevant was left out for budget.")
reply = await call_your_model(context["context"], question)
# Both turns in one call: a claim is often split across an exchange,
# and extracting each turn in isolation finds neither half.
appended = await client.conversations.append(
conversation_id,
[
{"role": "user", "content": question},
{"role": "assistant", "content": reply},
],
idempotency_key=f"{conversation_id}:{question[:40]}",
)
if not appended["extracting"]:
# Said out loud rather than assumed. With no queue configured the
# turns are stored and never become memory.
print(appended.get("note"))
return reply
Paging, which every list endpoint shares:
# One page, for a UI that renders one page at a time.
first = client.memories.list(type="decision", limit=50).first()
# Or the whole walk. `all` needs a bound - an unbounded collect on a large
# account is minutes of requests and a list that exhausts memory.
decisions = client.memories.list(type="decision").all(500)
# Or item by item, stopping when you like.
for memory in client.memories.list(scope="combined"):
if memory["confidence"] < 0.5:
break
# The async client iterates the same way.
async for memory in client.memories.list(type="fact"):
...
Errors, which are classes you can branch on:
try:
client.spaces.get("sp_missing")
except NotFoundError:
return None
except RateLimitError as limited:
# Already retried, and still refused. `retry_after_seconds` is what the
# server said, not a guess.
print(f"Rate limited; try again in {limited.retry_after_seconds or 60}s")
Branch on the class or on error.code, never on error.message. Messages get
rewritten, translated, and deliberately made vaguer for security; a client
keyed to message text breaks silently when any of that happens.
Sync and async
Both are written out, rather than one wrapping the other. A sync facade over
the async client needs an event loop, and calling it from inside a running one
either deadlocks or needs a background thread nobody asked for. What the two
share is everything they DECIDE - headers, error mapping, retry policy, and the
URL and body of every endpoint, in _core.py and _ops.py, which both import.
The duplication is the waiting, and only the waiting.
Behaviour
| Auth | Authorization: Bearer <api_key>. A pm_live_... key or a session JWT. |
| Retries | 429 and 5xx and transport failures. Never 4xx. Three attempts by default. |
| Backoff | Exponential from 250ms, capped at 8s, full jitter. Retry-After is honoured as a floor. |
| Timeouts | 30s per attempt, not per call - so backoff cannot eat the deadline. Override with timeout= per request. |
| Cancellation | Async: cancel the task, and CancelledError propagates untouched. Sync: timeout=. |
| Pagination | nextCursor absent means stop. An empty page does not. |
The API key is held in a name-mangled attribute and never returned, logged or
put in an error. repr(client) gives
PersistMemory(base_url=..., api_key='[redacted]'), the Authorization header is
built per request rather than stored on the httpx client where a debugger would
print it, and any key-shaped string in an error message is redacted on the way
out - because the way a credential actually escapes is a traceback pasted into a
bug report, not a deliberate log line.
Options
from persistmemory import Backoff, PersistMemory
client = PersistMemory(
api_key="pm_live_...",
base_url="https://api.persistmemory.com",
timeout=30.0,
max_attempts=3,
backoff=Backoff(base_seconds=0.25, max_seconds=8.0, factor=2.0, jitter=1.0),
# Injected, which is what makes a test of your code incapable of opening a
# socket by accident.
transport=httpx.MockTransport(handler),
)
Tests
uv run pytest # no network: every test runs against httpx.MockTransport
uv run ruff check .
Release files for persistmemory 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| persistmemory-0.2.0.tar.gz | 63.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| persistmemory-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 119.9 kB
Release files / persistmemory-0.2.0.tar.gz
| Download URL | persistmemory-0.2.0.tar.gz |
|---|---|
| Size | 63.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c1c3a72e63d9e3b40ff626569fd396cff743dfce6f8111c7a01f2df401f9ac44
|
|
BLAKE2b-256 checksum How to use checksums |
c34968877aa4cef40fe0774e3a99f6b5d164376b283a6ea48f69b8fb3af17d76
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency logRelease files / persistmemory-0.2.0-py3-none-any.whl
| Download URL | persistmemory-0.2.0-py3-none-any.whl |
|---|---|
| Size | 56.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c3c118053fe31ada05bea470576941bffc4cf46977af9321955ac193a0ddb07f
|
|
BLAKE2b-256 checksum How to use checksums |
c18281f059d3d1cb91ae3deec3ae7c7aca621c0cb70c75324d18ddddce3181f0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency log