Async Engine API Reference
MemoryEngine is the low-level async API. Use this when you need full control over the event loop, or when integrating into an existing async application. For most use cases, prefer MemoryClient.
Creating an Engine
from prme.storage.engine import MemoryEngine
from prme.config import PRMEConfig
# Factory method
config = PRMEConfig(
db_path="./memory.duckdb",
vector_path="./vectors.usearch",
lexical_path="./lexical_index",
)
engine = await MemoryEngine.create(config)
# Context manager
async with MemoryEngine.open(config) as engine:
await engine.store("hello", user_id="alice")
# engine.close() called automaticallyAlways call await engine.close() when done, or use the context manager.
store()
async def store(
content: str,
*,
user_id: str,
session_id: str | None = None,
role: str = "user",
speaker: str | None = None,
node_type: NodeType = NodeType.NOTE,
scope: Scope = Scope.PERSONAL,
metadata: dict | None = None,
confidence: float | None = None,
epistemic_type: EpistemicType | None = None,
source_type: SourceType | None = None,
event_time: datetime | None = None,
ttl_days: int | None = ...,
extract: bool | None = None,
) -> strBoth the async API and MemoryClient.store() accept these explicit controls:
epistemic_type— override auto-inference (OBSERVED, ASSERTED, INFERRED, etc.)source_type— override auto-inference (USER_STATED, SYSTEM_INFERRED, etc.)ttl_days— explicit TTL. UseNonefor no TTL. Default (...) uses the config’s per-type default.extract—Truealso queues the background LLM extractioningest()runs once the node is stored, so the text gets entities and claims too; the call does not wait and the node is kept as it is.Falsenever extracts. Default (None) followsenable_store_extraction, which is off.roleandspeaker—role="participant"marks a human in the conversation other than the memory’s owner (USER_STATED, like"user"), andspeakernames who said it. The reader context shows the name before the text. See conversation participants.
ingest()
Two-phase ingestion: persists event immediately, then runs LLM extraction.
async def ingest(
content: str,
*,
user_id: str,
role: str = "user",
speaker: str | None = None,
session_id: str | None = None,
metadata: dict | None = None,
wait_for_extraction: bool = False,
scope: Scope = Scope.PERSONAL,
) -> strSet wait_for_extraction=True to block until the LLM has finished extracting entities and relationships.
ingest_batch()
async def ingest_batch(
messages: list[dict],
*,
user_id: str,
session_id: str | None = None,
wait_for_extraction: bool = False,
scope: Scope = Scope.PERSONAL,
) -> list[str]Extraction status and recovery
The LLM ingest() path atomically saves extraction work alongside its source.
Retry attempts and scheduling survive restart. In-process retry tasks stop when
the engine closes; after reopening, explicitly process due work with:
status = await engine.extraction_status(event_id, user_id="alice")
# After correcting the cause of a terminal failure, make it eligible again.
if status is not None and status.status == "failed":
await engine.retry_extraction(event_id, user_id="alice")
result = await engine.process_extractions(user_id="alice", limit=100, budget_ms=5000)Status distinguishes pending, running, failed and complete, and reports
phase, attempts, lease expiry and a bounded failure code. An abruptly terminated
worker’s live lease must expire before another worker can claim it. Processing
budgets apply between jobs; provider calls retain their own timeout. Retrieval
does not run LLM recovery, and there is no background daemon. The same recovery
methods are available on MemoryClient.
Saved extraction is reused after a materialization failure, and a saved complete
plan preserves graph identities and numerical embeddings. If a referenced memory
changed (StaleDerivationPlanError), call retry_extraction(..., replan=True)
before processing: it creates a new plan revision from the same extraction and
may compute new embeddings. It does not preempt live or completed work, or repeat
the model’s extraction. Legacy sources without work records are not automatically
enrolled; reingest() derives them.
With PRME_EXTRACTION_BATCH_TARGET_TOKENS above zero, extraction runs in
batches of a session’s turns (see
batched extraction):
ingest(wait_for_extraction=False) queues the turn, a waiting ingest()
extracts the queue through its own turn, and ingest_batch() saves every
message before extracting them together. close() extracts the turns still
queued, waiting the usual 10 seconds plus the extraction timeout for each
batched request they need, five minutes at most; a turn still queued after
that stays pending until process_extractions() or the owner’s next flush.
With wait_for_extraction=True, failure raises ExtractionError; its event_id
identifies the persisted source and reason_code gives the sanitized failure
category when available. Import it from prme. A successful empty extraction
remains distinct from a provider error. The synchronous client waits for
extraction by default.
ingest(scope=...) keeps every extracted node in the caller’s scope. Model
scope classifications do not grant write access elsewhere; fact classifications
are retained as metadata.suggested_scope. Entity matching also stays within
the same user and scope.
Switching embedding models
A store is tied to the model whose vectors it holds. With enable_multi_model_embeddings (off by default,
PRME_ENABLE_MULTI_MODEL_EMBEDDINGS=true) it keeps vectors by node, model name and model version, one index per model,
and reembed() builds another model’s vectors for the memories already stored. The model runs locally, only vectors
are written, and the store can be used while the job runs. An engine without the option raises
ReembedDisabledError.
config = PRMEConfig(enable_multi_model_embeddings=True,
embedding={"additional_models": ["BAAI/bge-base-en-v1.5"]}) # written on every store too
async with MemoryEngine.open(config) as engine:
result = await engine.reembed("BAAI/bge-base-en-v1.5", batch_size=64, pause_ms=50)
while result.cursor: # a call with a limit stops early
result = await engine.reembed("BAAI/bge-base-en-v1.5", limit=1000, cursor=result.cursor)
print(result.total, result.done, result.remaining, result.failed)
print(await engine.embedding_status()) # every model, its vectors and whether it is completeWhen remaining is 0, set embedding.model_name to the new model and reopen: searches read its index, and the old
model’s vectors stay until await engine.drop_embeddings("BAAI/bge-small-en-v1.5"). A store whose vectors were all made
by another model than the configured one refuses writes with an error that names both models, and its retrieval
metadata carries embedding_incomplete with the counts. The design, the PostgreSQL and DuckDB storage, the adoption of
an existing store, and what a hosted service must do are in switching embedding
models . prme reembed, POST /v1/reembed and memory_reembed are the same job.
Re-deriving stored memories
A memory keeps the entities, claims and links the engine derived from it when
it was stored, or none when store() saved it plainly. When an engine change
improves how memories are derived, reingest() brings one owner’s stored
memories up to date. It is off unless enable_reingest is set
(PRME_ENABLE_REINGEST=true); an engine that did not turn it on raises
ReingestDisabledError.
config = PRMEConfig(enable_reingest=True) # requests are batched at the default token target
async with MemoryEngine.open(config) as engine:
preview = await engine.reingest("alice", dry_run=True) # no model call, no change
print(preview.selected, preview.model_calls, preview.source_tokens)
result = await engine.reingest("alice", limit=500)
while result.cursor: # more sources are left after the cap
result = await engine.reingest("alice", limit=500, cursor=result.cursor)It reads the owner’s sources (every memory saved through store(), ingest()
and the like) in event order and runs the same batched extraction ingest()
runs: memories of one session share a request up to
extraction_batch_target_tokens, and one without a session_id goes alone.
Then it publishes what the present engine derives from each. The source events
and their raw notes are never changed or deleted, and only the named owner’s
sources are touched.
- Replacing old records. An earlier claim the new derivation states again is
superseded by it (
superseded_byand aSUPERSEDESedge). One it no longer states is archived, withmetadata.reingest.replaced_bynaming the claims that replaced it. Both leave retrieval and the search indexes, and both stay readable withget_node(..., include_superseded=True). Entities are kept and reused, because every memory that names an entity shares it. - Idempotent and resumable. Each source is saved with a marker that names the
settings it was derived under: the derivation version (
DERIVATION_VERSION, a number bumped when what is derived changes; a new package version alone leaves sources current), the extraction model and prompt, and the options that decide what is derived. A second run skips the sources that have it and creates nothing. The model’s output is saved before anything is published, so a run that stops after a request does not ask the model again, and the next call continues with the sources that are not current. An option that only changes what is derived makes every source stale again but needs no new model calls while the saved output is current. - Counts.
ReingestResulthastotalsources, how many arecurrent, how many areunfinished(their own extraction job has not finished: runprocess_extractions()first), theselectedsources of this call, how many wereprocessedandfailed, how many areremaining, acursorto continue after the last selected source, themodel_callsmade (or, for a dry run, expected) and thesource_tokensthose requests carry, for a cost estimate. A failed source stays not current and is tried by the next call without a cursor. Three failed requests in a row end the call. - Limits.
limitcaps the sources one call re-derives;scoperestricts the call to one scope. A second call for the same owner in the same engine raisesReingestBusyError. Between two calls of a long run, a claim an older memory states may be current beside the later memory that corrected it until that memory is reached, so run to the end. Retrieval and other writes continue during a run.
The same call is on MemoryClient.reingest(), POST /v1/reingest, the
memory_reingest MCP tool and prme reingest.
ingest_fast()
The fast path commits the event and its pending work atomically, without embedding or LLM calls. Retrieval or organization materializes a raw NOTE with the original event ID, provenance, and timestamps. Pending work survives restart; the configured queue size bounds each batch rather than dropping events. Use ingest() when you need LLM extraction. Latency depends on the database commit.
async def ingest_fast(
content: str,
*,
user_id: str,
role: str = "user",
speaker: str | None = None,
session_id: str | None = None,
metadata: dict | None = None,
scope: Scope = Scope.PERSONAL,
event_time: datetime | None = None,
) -> strUse this for real-time conversational ingestion where latency matters.
Completion is acknowledged only after the vector and lexical indexes are persisted. Failed work remains pending for a later pass; one failure does not block the other items in that batch. A drain budget is cooperative: an indexing operation already in progress finishes before the budget is checked again.
Processing deferred events
event_id = await engine.ingest_fast("Alice prefers dark mode", user_id="alice")
status = await engine.processing_status(event_id, user_id="alice")
result = await engine.process_pending(user_id="alice", budget_ms=1000)
print(result.processed, result.pending, result.failed)
status = await engine.processing_status(event_id, user_id="alice")
print(status.status, status.attempts, status.last_error)process_pending() processes one batch without running organizer jobs. Repeat
as needed; failed items remain in pending, and persistent errors should be
inspected before retrying. budget_ms=0 reads current counts without processing.
Status and counts are read from durable storage and restricted to the supplied
user. Errors contain the exception type, without provider response text.
processing_status() returns None for an unknown event, another user’s event,
or a legacy event without a work record. New store() writes retain a complete
initial node snapshot and repair job alongside the event. The same methods repair
those writes after an indexing failure or restart, preserving type, ID, timestamps
and TTL without an LLM. If graph creation fails after acceptance, the exported
MaterializationError carries an event_id for inspection and recovery.
For ingest_fast() and ingest(), this status covers raw NOTE materialization;
LLM derivations use extraction_status(). Completion does not include optional
post-store reinforcement, supersedence or QA pairing. A later lifecycle operation
may still retire the memory, and processing does not reactivate retired nodes.
retrieve()
async def retrieve(
query: str,
*,
user_id: str,
scope: Scope | list[Scope] | None = None,
time_from: datetime | None = None,
time_to: datetime | None = None,
reference_time: datetime | None = None,
knowledge_at: datetime | None = None,
event_time_from: datetime | None = None,
event_time_to: datetime | None = None,
token_budget: int | None = None,
weights: ScoringWeights | None = None,
min_fidelity: RepresentationLevel | None = None,
include_cross_scope: bool = True,
) -> RetrievalResponseAdditional parameters vs MemoryClient:
weights— override scoring weights for this querymin_fidelity— minimum representation level for context packingmax_per_source— optional positive cap for results sharing one exact nonempty evidence set and byte-identical passage; use1when extracted sibling claims would otherwise repeat the same source textinclude_cross_scope— include hints from other scopes (default: True)event_time_from/event_time_to— filter by when events actually occurred (vs when they were stored)time_from/time_to— filter assertion validity windows across all retrieval paths (ENTITY and PREFERENCE nodes retain their existing exemption). Dates inferred from query text guide ranking, without imposing a validity cutoff.reference_time— timezone-aware clock for relative query dates and scoring decay. Defaults to UTC request time and is returned inresponse.metadata.reference_time. Reuse it to replay a query against unchanged memory/configuration. It does not apply a knowledge cutoff; setknowledge_atseparately when needed.
Rendered context and token budgets
response = await engine.retrieve("What should I remember?", user_id="alice", token_budget=2048)
context = response.bundle.render()
print(response.bundle.tokens_used, response.bundle.tokenizer)Pass this rendered string as the memory context. tokens_used counts the whole
string, including section labels, source IDs, dates, and epistemic metadata.
PackingConfig.tokenizer defaults to cl100k_base; select the consuming model’s
encoding (for example o200k_base) in the engine configuration. Token counts are
specific to that encoding. overhead_tokens reserves additional space for the
caller outside the measured context.
Entries retain complete source text or become explicit metadata/references;
text is never sliced to fit. A tiny budget can produce an empty bundle even for
pinned memories. Set min_fidelity=RepresentationLevel.FULL (or PROSE or
STRUCTURED, which pack the same records) when references without memory text
are unsuitable: records that fit only as a KEY_VALUE or REFERENCE fallback,
or whose text is blank, are then excluded and listed in bundle.excluded_ids.
Full node objects remain in results for inspection; concatenating their
content bypasses the bundle’s budget.
The separate format_for_llm(..., token_budget=...) formatter also counts its
entire output. It accepts token_counter=your_model_counter for tokenizers
outside tiktoken. Profiles, conflict annotations, and headers consume that same
budget, so a candidate is included only if the complete rendering fits.
Node Operations
# Get single node
node = await engine.get_node(node_id, include_superseded=False)
# Query with filters
nodes = await engine.query_nodes(
user_id="alice",
node_type=NodeType.FACT,
lifecycle_state=LifecycleState.STABLE,
limit=50,
)
# Get single event
event = await engine.get_event(event_id)
# Get events for a user
events = await engine.get_events("alice", session_id="s1", limit=100)Lifecycle Transitions
# Promote: TENTATIVE → STABLE
await engine.promote(node_id)
# Supersede: marks old node as SUPERSEDED, links to new
await engine.supersede(old_node_id, new_node_id, evidence_id="evt-123")
# Archive: any state → ARCHIVED (terminal)
await engine.archive(node_id)
# Reinforce: boost confidence (+0.05) and reinforcement_boost (+0.15)
await engine.reinforce(node_id, evidence_id="evt-456")Entity Snapshots
Generate a point-in-time snapshot of an entity and its neighborhood:
snapshot = await engine.snapshot("entity-id", at_time=datetime(2024, 6, 1))Organize
result = await engine.organize(
user_id="alice",
jobs=["promote", "deduplicate"],
budget_ms=3000,
)
# End-of-session lightweight organize
result = await engine.end_session(user_id="alice", session_id="s1")Quality & Feedback
from prme.quality.feedback import FeedbackSignal
# Record retrieval feedback
await engine.feedback(FeedbackSignal(
query="preferences?",
user_id="alice",
selected_node_id="...",
was_helpful=True,
))
# Get quality metrics
metrics = engine.quality_metrics
# Check materialization debt
debt = engine.materialization_debtclose()
await engine.close()Closes all backends, then, for a directory pack with encryption enabled, encrypts its files again. A single-file pack (pack_path) is encrypted by DuckDB the whole time it is open, so close() has nothing to encrypt; lock() and unlock() raise RuntimeError for such a pack.
Source: documentation/async-engine.md in PRME at bd92166f.