Skip to Content
ReferenceIntegration reference

PRME Integration Reference

Audience: AI coding assistants and developers integrating PRME into applications. Original reference: 2026-03-02, with subsequent sections updated incrementally. API coverage is partial. See the README  for newer capabilities and focused guides.


Table of Contents

  1. Quick Start
  2. Architecture Overview
  3. Core API Reference
  4. Data Models
  5. Type Reference
  6. Configuration
  7. Retrieval Pipeline Deep Dive
  8. Multi-User / Multi-Tenant
  9. Working Examples
  10. PostgreSQL Backend
  11. RFC Summary Table

1. Quick Start

Minimal working example — store memories and retrieve them with hybrid search.

import asyncio import tempfile from pathlib import Path from prme import MemoryEngine, PRMEConfig, NodeType, Scope async def main(): # 1. Configure — paths default to cwd; use a temp dir for demos tmp = tempfile.mkdtemp(prefix="prme_") config = PRMEConfig( db_path=str(Path(tmp) / "memory.duckdb"), vector_path=str(Path(tmp) / "vectors.usearch"), lexical_path=str(Path(tmp) / "lexical_index"), ) # 2. Create engine (async factory — initializes all 4 backends) engine = await MemoryEngine.create(config) try: # 3. Store memories await engine.store( "Alice prefers dark mode in all her editors.", user_id="alice", node_type=NodeType.PREFERENCE, scope=Scope.PERSONAL, ) await engine.store( "The team decided to use PostgreSQL for the backend.", user_id="alice", node_type=NodeType.DECISION, scope=Scope.PROJECT, ) # 4. Retrieve with hybrid search response = await engine.retrieve( "What does Alice prefer?", user_id="alice", ) # 5. Use the results for r in response.results: print(f"[{r.composite_score:.3f}] {r.node.content}") finally: await engine.close() asyncio.run(main())

Requirements: Python 3.11+, pip install prme. No LLM API key needed for store() + retrieve(). The ingest() method requires an OpenAI/Anthropic/Ollama API key for LLM extraction.

PostgreSQL backend: Pass database_url instead of file paths to use PostgreSQL for all storage. See Section 10 for details and a quick start example.


2. Architecture Overview

PRME coordinates four storage backends behind a single MemoryEngine API:

MemoryEngine │ ┌──────────┬───────┴───────┬──────────┐ ▼ ▼ ▼ ▼ EventStore GraphStore VectorIndex LexicalIndex

Two backend modes (selected automatically via config.backend):

LayerDuckDB mode (default)PostgreSQL mode (database_url set)
EventStoreDuckDB tablePostgreSQL table (asyncpg)
GraphStoreDuckDB + recursive CTEsPostgreSQL + recursive CTEs
VectorIndexUSearch HNSW + DuckDB metadatapgvector HNSW on nodes.embedding
LexicalIndexTantivy (BM25)tsvector / tsquery (GIN index)
Write QueueSerialized WriteQueue (single-writer)NoOpWriteQueue (passthrough)

Data Flow

Write path (store() / ingest()):

Content → Event (DuckDB) → MemoryNode (Graph) → Vector embedding → Lexical index

Read path (retrieve()):

Query → Analysis → Candidate Generation → Epistemic Filtering → Scoring → Context Packing → MemoryBundle (stage 1) (stages 2-3) (stage 4) (stage 5) (stage 6)

Memory Pack (portable artifact — DuckDB mode only)

In DuckDB mode, a pack is a directory by default, and all state lives in these files, copyable as a unit:

FileBackendPurpose
memory.duckdbEventStore + GraphStoreEvent log, graph nodes/edges, operations
vectors.usearchVectorIndexHNSW vector index
lexical_index/LexicalIndexTantivy full-text index directory
manifest.jsonEncryptionList of encrypted files and key derivation settings, written only when encryption is on

A single-file pack (opt-in) is one DuckDB file, conventionally ending in .prme. It holds the same durable tables as memory.duckdb; the derived indexes and the manifest move inside it:

In the .prme fileBackendPurpose
Every table of memory.duckdbEventStore + GraphStore + VectorIndexEvent log, graph nodes/edges, operations, and per-vector metadata and float payloads (vector_metadata, vector_payloads)
vector_snapshotVectorIndexOne row with the USearch snapshot (data), ndim, vector_count and saved_at, in place of vectors.usearch
prme_manifestMemoryEngineOne row describing the pack: format_version (currently 1), prme_version, embedding_model, embedding_version, embedding_dim, scoring_version, created_at and updated_at
(none)LexicalIndexTantivy index held in memory and rebuilt from active nodes on open, in place of lexical_index/

A single-file pack has no manifest.json; DuckDB encrypts the file itself when encryption is on.

In PostgreSQL mode, all data lives in a single database. The memory pack portability story (RFC-0014) applies to DuckDB mode.


3. Core API Reference

MemoryEngine

The single entry point for all operations. Always use the async create() factory.

MemoryEngine.create()

@classmethod async def create(cls, config: PRMEConfig | None = None) -> MemoryEngine

Factory method. Dispatches to DuckDB or PostgreSQL based on config.backend:

  • DuckDB (default): Opens DuckDB file, initializes schema, USearch index, Tantivy index, serialized write queue.

  • PostgreSQL (database_url set): Creates asyncpg pool, initializes PostgreSQL schema (pgvector + tsvector), uses NoOpWriteQueue passthrough.

  • config — Optional PRMEConfig. Defaults to PRMEConfig() (loads from env vars / .env).

  • Returns: Initialized MemoryEngine.


engine.store()

async def store( self, content: str, *, user_id: str, retrieval_content: str | None = None, value_bindings: list[MemoryValueBinding | dict] | None = None, 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, valid_from: datetime | None = None, valid_to: datetime | None = None, ttl_days: int | None = ..., ) -> str

Store content across all four backends in one call. No LLM needed. By default, the exact source text is also the searchable and model-facing memory. For large agent traces, logs, or structured documents, pass compact retrieval_content; get_event() still returns the exact source while the graph, vector index, lexical index and context packer use the compact representation. Use value_bindings when an exact source-backed presentation value differs from the complete string accepted by a tool. The value-binding guide defines visibility, resolution and audit behavior.

The event, complete initial node snapshot and repair job are saved atomically, then the graph node and both indexes are written. Index failures leave the job pending; processing_status() and process_pending() expose scoped inspection and repair after restart. A graph creation failure raises MaterializationError with the accepted event_id. Recovery preserves the original node values without an LLM. Optional reinforcement, supersedence and QA pairing run afterward and are outside this job’s completion boundary.

Question/answer pairing is disabled by default. enable_qa_pairing=True enables an experimental in-process heuristic that creates an extra merged node for consecutive turns from different roles or speakers in one exact session and scope. It is not recovered after restart, its graph/index writes are not one atomic durable publication, and registered quality evaluations have not enabled it. Prefer normal session-aware retrieval unless you are explicitly evaluating this hypothesis.

When enable_store_supersedence=True, a newly formed flip-flop chain can apply the existing bounded oscillation confidence penalty. The engine revalidates the exact owner, scope, node snapshots, and SUPERSEDES edges under the backend transaction, then commits the confidence update with one deterministic, checksummed PENALTY record. Concurrent and restarted attempts reuse that identity. This is a lexical heuristic behind the supersedence opt-in, not a truth judgment or calibrated confidence model.

ParameterTypeDefaultDescription
contentstrrequiredExact source text retained in the immutable event log
retrieval_contentstr | NoneNoneOptional compact text to index, rank and place in model context
value_bindingslist[MemoryValueBinding | dict] | NoneNoneExact presentation values paired with caller-supplied complete lookup forms
user_idstrrequiredOwner user ID (all queries scoped to this)
session_idstr | NoneNoneOptional session identifier
rolestr"user""user" (the memory’s owner), "participant" (another human in the conversation), "assistant", "tool", or "system"
speakerstr | NoneNoneOptional name of who said it; kept with the source and node and shown by the reader context format
node_typeNodeTypeNodeType.NOTEType of memory node
scopeScopeScope.PERSONALMemory scope
metadatadict | NoneNoneOptional structured metadata
confidencefloat | NoneNoneConfidence 0.0-1.0. If None, derived from confidence matrix
epistemic_typeEpistemicType | NoneNoneIf None, assistant/system roles infer INFERRED; other roles infer from node_type
source_typeSourceType | NoneNoneIf None, inferred from node_type + role: "user" and "participant" select USER_STATED, "assistant" and "system" select SYSTEM_INFERRED, "tool" selects TOOL_OUTPUT
event_timedatetime | NoneNoneTimezone-aware source event time; remains separate from admission and validity
valid_fromdatetime | NoneNoneTimezone-aware inclusive validity start; omission uses admission time
valid_todatetime | NoneNoneExclusive validity end; requires an explicit earlier valid_from
ttl_daysint | None...Omit for configured default, pass None to disable, or set a nonnegative override

Returns: str — UUID of the created event (source of truth ID).

A conversation between two or more people has more than one first-party speaker. Store the memory owner’s turns with role="user" and every other person’s with role="participant", each with their name as speaker. Every human’s statements are then USER_STATED with the same confidence, while assistant and system output stays SYSTEM_INFERRED. When the owner is not in the conversation, as with an imported chat between two other people, every person is a participant:

when = datetime(2023, 6, 9, 19, 55, tzinfo=timezone.utc) for speaker, text in [("Caroline", "I went to the support group yesterday."), ("Melanie", "That sounds wonderful. How did it go?")]: await engine.store(text, user_id="chat-7", role="participant", speaker=speaker, session_id="s1", event_time=when)

The name is kept in the reserved metadata.prme_speaker_v1 key of the source event and its node. Passing that key in metadata raises ValueError, so a caller that copies metadata from a stored memory into a new write must remove the key and pass speaker instead. Leading and trailing whitespace is removed; the name must then be non-empty, at most 200 characters, and free of control characters, line breaks and bidirectional controls. The speaker is an unverified caller assertion, not an identity: PRME does not resolve it to an entity. It does not select the role or source type, and it is not indexed for lexical search.

The reader context format prints it as - [2023-06-09 19:55] "Caroline": "I went to the support group yesterday.", and leaves it out when the text already begins with the name and a colon, after an optional leading parenthesized or bracketed group such as a date. The auditable format adds a "speaker" key to such records; the compact format does not show it. With enable_qa_pairing=True, a change of speaker within one role also creates a merged question/answer node, as a change of role does, and each half of its text starts with its speaker’s name.

ingest(), ingest_batch() (a "speaker" key per message), ingest_fast() and FastIngestItem accept the same speaker. It stays with the source event and its raw note. Claims that ingest() extracts do not show it yet: a participant’s first-person claim such as “I moved to Boston” reads like any other first-party claim, so follow a claim’s evidence_refs to its source event to see who said it. With enable_speaker_references (on by default since 2026-10-06), the claim’s I, me and my bind to the speaker’s entity, and its speaker_reference metadata names the speaker (see entity identity). The extractor applies its generic admission policy to participants, and the user-only literal recoveries do not run for them.

Use store_with_receipt() when the next operation needs the created node ID:

receipt = await engine.store_with_receipt( "The project uses PostgreSQL.", user_id="alice", node_type=NodeType.FACT, scope=Scope.PROJECT, ) await engine.promote(str(receipt.node_id), user_id="alice")

The immutable source ID is receipt.event_id; lifecycle methods accept receipt.node_id. receipt.node is the exact created node and receipt.processing_status reports its durable materialization state. Resolution follows the source event, so a concurrent write cannot be mistaken for this node. Existing store() callers retain the event-ID return for compatibility.

For example, retain a complete tool trajectory without spending the retrieval budget on raw SDK payloads:

receipt = await engine.store_with_receipt( raw_trace_json, retrieval_content="Traveler: Alice\nFinal plan:\n...", user_id="alice", metadata={"record_kind": "agent_trace"}, ) source = await engine.get_event(str(receipt.event_id), user_id="alice") assert source.content == raw_trace_json assert receipt.node.content.startswith("Traveler: Alice")

The projection is caller-supplied data, not a generated summary or a claim that the source supports it. It is durably journaled with the initial node and reused exactly during restart recovery.

For raw imports that do not need typed-node overrides or model extraction, use ingest_fast_many(items, user_id=...). Each FastIngestItem carries content, role, speaker, session_id, scope, metadata, and an optional timezone-aware event_time. PRME validates and snapshots the complete list before I/O, then admits every immutable event and materialization job in one transaction. It returns event IDs in input order; an empty Python batch is a no-op. Run process_pending() until pending == 0 to build the deterministic raw NOTE and both indexes. Pass a persisted UUID as request_id to make a lost-response retry return the original event IDs. The identity is owner scoped and survives restart; reusing it with different items raises an input conflict. HTTP POST /v1/ingest/fast uses the UUID Idempotency-Key header, while MCP memory_ingest_fast_many accepts request_id. Both expose the same owner-scoped admission contract; their empty request lists are rejected. MCP memory_process_materializations mirrors the HTTP processing endpoint.

An explicit EpistemicType.CONDITIONAL requires metadata={"condition": "..."}. New conditional memories always begin with condition_state="unknown"; setting a resolved state during creation is rejected so an evaluation cannot bypass its audit record.

Validity intervals use [valid_from, valid_to). Python, HTTP /v1/store, and MCP memory_store preserve the same fields. Timezone-free values, an end without an explicit start, and empty or inverted intervals fail before the immutable source is admitted. New extracted facts use their resolved source-effective time as valid_from; source-grounded replacements close the previous interval in the same derivation transaction when the stored interval can be closed safely.


engine.ingest()

async def ingest( self, content: str, *, user_id: str, role: str = "user", speaker: str | None = None, first_person_owner: bool = False, session_id: str | None = None, metadata: dict | None = None, wait_for_extraction: bool = False, scope: Scope = Scope.PERSONAL, ) -> str

Ingest with LLM-powered extraction. Two-phase pipeline:

  • Phase 1 (immediate): Atomically persist the source event and its raw indexing job.
  • Phase 2 (background): Extract and ground model output, save it in the operation log, then materialize the graph and indexes. Indexing retries reuse the saved output.
ParameterTypeDefaultDescription
contentstrrequiredMessage text to ingest
user_idstrrequiredOwner user ID
rolestr"user"Message role; see store()
speakerstr | NoneNoneOptional name of who said it; see store()
first_person_ownerboolFalseDeclare singular first person in this user/human message to be its owner; no named speaker; omit for pasted text
session_idstr | NoneNoneOptional session ID
metadatadict | NoneNoneOptional metadata
wait_for_extractionboolFalseIf True, block until extraction completes
scopeScopeScope.PERSONALMemory scope

See automatic correction rules and limits.

Returns: str — UUID of the persisted event. Falls back to store() if no pipeline configured.


engine.ingest_batch()

async def ingest_batch( self, messages: list[dict], *, user_id: str, session_id: str | None = None, wait_for_extraction: bool = False, scope: Scope = Scope.PERSONAL, ) -> list[str]

Ingest multiple messages sequentially (preserves conversation order). Each dict must have "content" and "role" keys, with optional "speaker", "first_person_owner", "metadata" and "event_time". Every attribution is checked before the first message is admitted.

Returns: list[str] — Event IDs, one per message.


Consumer node and source filters

retrieve() on the async engine and synchronous client accepts exclude_node_types and source_types. Filters apply within generated candidates before scoring and limiting, and to session expansion, supplementary evidence, packed context and cross-scope hints. They apply in DEFAULT and EXPLICIT modes. source_types=None allows all provenance types; an empty collection allows none. Invalid enum values fail before pending work is drained. Receipts record active filters in execution.parameters, and responses expose them in filter_metadata.

from prme import NodeType, SourceType response = await engine.retrieve( "What should I know about my coverage?", user_id="member", exclude_node_types={NodeType.NOTE, NodeType.ENTITY}, source_types={SourceType.USER_STATED}, )

This retrieves source-supported derived claims without serving raw messages or bare entities. Raw NOTE messages still use node-based modality defaults: a question or hypothetical may be labelled ASSERTED. These controls do not perform sentence classification or semantic entailment, repair older extracted claims, or prove that pasted text describes its owner. USER_STATED includes user, human and participant roles, so it is not an exact owner-authorship filter.

New direct stores and raw notes from assistant/system roles default to INFERRED and SYSTEM_INFERRED; caller-supplied epistemic types win. INFERRED remains eligible in DEFAULT mode, so use the source allowlist to exclude generated content entirely. Existing nodes and saved direct-store snapshots retain their original labels; no historical records are rewritten.

engine.retrieve()

async def retrieve( self, query: str, *, user_id: str, scope: Scope | list[Scope] | None = None, time_from: datetime | None = None, time_to: datetime | None = None, token_budget: int | None = None, exclude_node_types: Collection[NodeType] | None = None, source_types: Collection[SourceType] | None = None, weights: ScoringWeights | None = None, min_fidelity: RepresentationLevel | None = None, include_cross_scope: bool = True, ) -> RetrievalResponse

Hybrid retrieval through the 6-stage pipeline.

ParameterTypeDefaultDescription
querystrrequiredNatural language query
user_idstrrequiredUser ID for scoping
scopeScope | list[Scope] | NoneNoneScope filter. None = all scopes
time_fromdatetime | NoneNoneStart of temporal window
time_todatetime | NoneNoneEnd of temporal window
token_budgetint | NoneNoneOverride default token budget (default: 4096)
exclude_node_typesCollection[NodeType] | NoneNoneOmit selected node types before result limiting and packing
source_typesCollection[SourceType] | NoneNoneProvenance allowlist; empty means no results
weightsScoringWeights | NoneNoneOverride scoring weights
min_fidelityRepresentationLevel | NoneNoneOverride minimum representation level. STRUCTURED, PROSE or FULL keeps text-free KEY_VALUE and REFERENCE fallbacks, and blank records, out of the context
include_cross_scopeboolTrueInclude cross-scope hints when scope is filtered

Returns: RetrievalResponse with bundle, scored results, metadata, score traces.

retrieval_mode accepts DEFAULT or EXPLICIT across the async engine, sync client, HTTP API, and MCP tool. DEFAULT excludes hypothetical and deprecated claims; EXPLICIT includes them within the generated candidate pool.


engine.record_answer_citations()

async def record_answer_citations( self, submission: AnswerCitationSubmission, *, user_id: str, ) -> AnswerCitationRecord

Append the memory citations for an answer created from a saved retrieval. The submission includes the retrieval request_id, a caller answer reference, zero or more cited node IDs, an optional answer SHA-256 digest, a collection method, and a caller-generated citation UUID used for safe retries. Every cited node must be content-bearing and included in the receipt’s rendered context.

An empty citation tuple explicitly records that the answer reported no memory citations. The record remains valid after graph changes or archival. It is telemetry and does not modify memory state or ranking. Use get_answer_citations() and list_answer_citations() to read owned records.


engine.get_node()

async def get_node( self, node_id: str, *, include_superseded: bool = False, ) -> MemoryNode | None

Retrieve a node by ID. Returns None if not found or not visible (superseded/archived, unless include_superseded=True).


engine.query_nodes()

async def query_nodes(self, **kwargs) -> list[MemoryNode]

Query nodes with flexible filters. Defaults to active lifecycle states (tentative, stable and contested). Filters include user_id, node_type, scope or scopes, session_ids, lifecycle_states, valid_at, min_confidence, min_salience, content_contains_any, created_before, oldest_first and limit. Use the plural lifecycle_states; there is no offset argument. Pass the owner explicitly in application queries.


engine.get_event() / engine.get_events()

async def get_event(self, event_id: str, *, user_id: str | None = None) -> Event | None async def get_events(self, user_id: str, **kwargs) -> list[Event] async def get_extraction(self, event_id: str, *, user_id: str) -> ExtractionRecord | None

Retrieve events by ID or by user. get_events() accepts session_id, limit, offset. get_extraction() reads saved grounded model output without inference or pending processing. None means no owned record exists. A record includes the source hash, provider/model, schema and grounding versions, and structured output; it does not acknowledge graph completion or prove its claims. The synchronous MemoryClient has the same method. HTTP and MCP expose the scoped source record through /v1/events/{event_id}/extraction and memory_get_extraction.


engine.consolidate_knowledge()

created = await engine.consolidate_knowledge( user_id="alice", scope=Scope.PROJECT, entity_names=["Aurora"], max_profile_tokens=1000, )

Build inferred entity profiles from complete same-owner, same-scope source excerpts. Each replacement commits atomically after index preparation. Source changes or competing rebuilds raise StaleProfileError; embedding/storage errors also propagate. MemoryClient provides the equivalent synchronous API. See the entity profile guide for requirements and recovery.


Lifecycle Transitions

async def promote( self, node_id: str, *, user_id: str | None = None, request_id: str | UUID | None = None, ) -> None # TENTATIVE → STABLE async def supersede( self, old_node_id: str, new_node_id: str, *, evidence_id: str | None = None, user_id: str | None = None, actor_id: str | None = None, ) -> None # → SUPERSEDED async def archive( self, node_id: str, *, user_id: str | None = None, request_id: str | UUID | None = None, ) -> None # → ARCHIVED (terminal)

All raise ValueError if the transition is invalid per the lifecycle state machine. Supersedence commits a deterministic edge and checksummed before/after record with the state change. Repeating the same ordered nodes, evidence, and actor is safe across restarts; changing an input after publication is rejected. HTTP exposes POST /v1/supersedences, and MCP exposes memory_supersede. Promotion and archival accept an optional UUID request_id; reuse it with the same node, action, and actor after an ambiguous response. HTTP accepts that UUID as Idempotency-Key, and the MCP lifecycle tools accept request_id. Reusing a key with different inputs raises a conflict. Calls without a request ID retain strict lifecycle errors and reject a repeated transition.

engine.evaluate_condition()

updated = await engine.evaluate_condition( node_id, ConditionState.TRUE, user_id="alice", evidence_id=approval_event_id, request_id="9ee0440b-4ea4-48c5-87ed-c1f43546475b", evaluation_method=ConditionEvaluationMethod.TOOL, reason="Approval service confirmed", )

This is an explicit recorded evaluation, not an automatic truth inference. Mutation and a checksummed EPISTEMIC_TRANSITION record with the complete before/after claim commit atomically. Evidence must belong to the same owner and scope. Reusing request_id safely retries the same evaluation; changing its inputs raises a conflict. The conditional epistemic type remains intact so a changing condition can be evaluated again. MemoryClient.evaluate_condition() provides the synchronous equivalent. HTTP uses PUT /v1/nodes/{node_id}/condition with an optional UUID Idempotency-Key header; MCP exposes memory_evaluate_condition.

engine.get_provenance()

history = await engine.get_provenance( node_id, user_id="alice", operation_limit=100, ) next_page = await engine.get_provenance( node_id, user_id="alice", operation_cursor=history.next_operation_cursor, )

Returns the current node, owned source events, references whose source event is missing or outside the node scope, same-owner/same-scope contradiction edges, and a chronological page of raw operation records. The page limit is 1–1000; the opaque cursor is stable for append-only operation history. Missing and foreign nodes return None. MemoryClient.get_provenance() is the synchronous equivalent. HTTP exposes GET /v1/nodes/{node_id}/provenance; MCP exposes memory_get_provenance.

engine.contradict() and engine.resolve_contradiction()

first, second = await engine.contradict( first_claim_id, second_claim_id, user_id="alice", actor_id="reviewer", evidence_id=review_event_id, ) winner, loser = await engine.resolve_contradiction( second_claim_id, first_claim_id, user_id="alice", resolver_actor_id="reviewer", evidence_id=review_event_id, )

Each operation commits claim states, the contradiction edge, and its audit record atomically. Claims and optional evidence must share one owner and scope. An exact retry with the same ordered claims, actor, and evidence is a no-op; changing those inputs after the operation raises ValueError instead of silently accepting a different decision. Resolution returns the stable winner followed by the deprecated loser and removes the loser from derived search indexes after the durable transaction. MemoryClient provides synchronous methods with the same names. HTTP uses POST /v1/contradictions and POST /v1/contradictions/resolve; MCP uses memory_mark_contradiction and memory_resolve_contradiction.


engine.close()

async def close(self) -> None

Shuts down ingestion pipeline, drains write queue, saves vector index, closes lexical index, and closes the DuckDB connection or PostgreSQL pool. Always call this (use try/finally).


4. Data Models

All models are Pydantic BaseModel subclasses. Import from prme.models.

MemoryObject (base class)

from prme.models import MemoryObject class MemoryObject(BaseModel): id: UUID # default: uuid4() user_id: str # required — owner user ID session_id: str | None # default: None scope: Scope # default: Scope.PERSONAL created_at: datetime # default: now(UTC) updated_at: datetime # default: now(UTC)

Event

Immutable event in the append-only log. Frozen (all fields read-only after creation).

from prme.models import Event class Event(MemoryObject): # Frozen (immutable) timestamp: datetime # default: now(UTC) role: str # required — "user", "participant", "assistant", "tool", or "system" content: str # required — event content text content_hash: str # auto-computed SHA-256 of content metadata: dict | None # default: None

MemoryNode

A typed node in the memory graph.

from prme.models import MemoryNode class MemoryNode(MemoryObject): node_type: NodeType # required content: str # required metadata: dict | None # default: None confidence: float # default: 0.5, range [0.0, 1.0] salience: float # default: 0.5, range [0.0, 1.0] epistemic_type: EpistemicType # default: EpistemicType.ASSERTED source_type: SourceType # default: SourceType.USER_STATED lifecycle_state: LifecycleState # default: LifecycleState.TENTATIVE valid_from: datetime # default: now(UTC) valid_to: datetime | None # default: None (still valid) superseded_by: UUID | None # default: None evidence_refs: list[UUID] # default: [] — event IDs as evidence

MemoryEdge

A typed edge connecting two nodes.

from prme.models import MemoryEdge class MemoryEdge(BaseModel): id: UUID # default: uuid4() source_id: UUID # required target_id: UUID # required edge_type: EdgeType # required user_id: str # required confidence: float # default: 0.5, range [0.0, 1.0] valid_from: datetime # default: now(UTC) valid_to: datetime | None # default: None provenance_event_id: UUID | None # default: None metadata: dict | None # default: None created_at: datetime # default: now(UTC)

5. Type Reference

All enums are str, Enum subclasses. Import from prme.types.

NodeType

Eight node types for the memory graph:

ValueDescription
"entity"Named entity (person, org, tool)
"event"Conversational event
"fact"Factual assertion
"decision"Decision record
"preference"User preference
"task"Active task
"summary"Summarization output
"note"Generic catch-all (default for store())

EdgeType

Nine relationship types:

ValueDescription
"relates_to"General relationship
"supersedes"New fact replaces old
"derived_from"Derivation provenance
"mentions"Entity mention
"part_of"Part-whole relationship
"caused_by"Causal link
"supports"Supporting evidence
"contradicts"Contradiction (triggers CONTESTED state)
"has_fact"Entity → Fact link

Scope

Six namespace isolation levels:

ValueDescription
"personal"Single user. Highest trust.
"project"Shared across actors on a common goal
"organisation"Cross-project org-wide facts
"agent"Private to a specific AI agent
"system"System-generated (summaries, organizer)
"sandbox"Temporary isolated scope for testing

LifecycleState

Six states with forward-only transitions:

ValueDescription
"tentative"Initial state for all new nodes
"stable"Promoted after verification
"contested"Unresolved contradiction detected
"superseded"Replaced by a newer node
"deprecated"Confirmed incorrect
"archived"Terminal state

State machine transitions:

TENTATIVE ──► STABLE ──► SUPERSEDED ──► ARCHIVED │ │ ▲ │ └──► CONTESTED │ │ │ └──► STABLE │ │ └──► DEPRECATED ───┘ └──► SUPERSEDED / CONTESTED / ARCHIVED

Valid transitions:

  • TENTATIVE → STABLE, SUPERSEDED, CONTESTED, ARCHIVED
  • STABLE → SUPERSEDED, CONTESTED, ARCHIVED
  • CONTESTED → STABLE, DEPRECATED, ARCHIVED
  • SUPERSEDED → ARCHIVED
  • DEPRECATED → ARCHIVED
  • ARCHIVED → (terminal, no transitions)

EpistemicType

Seven epistemic classifications:

ValueWeightDescription
"observed"1.0Directly observed
"asserted"0.9User stated
"inferred"0.7System inferred
"hypothetical"0.3Speculative (excluded in DEFAULT retrieval)
"conditional"0.5Conditionally true
"deprecated"0.1Confirmed wrong (excluded in DEFAULT retrieval). Not assignable at creation — lifecycle transition only.
"unverified"0.5Not yet verified

SourceType

Five source provenance types:

ValueDescription
"user_stated"User explicitly stated
"user_demonstrated"Inferred from user behavior
"system_inferred"System/LLM inferred
"external_document"From external documents
"tool_output"From tool/API output

QueryIntent

ValueDescription
"semantic"Meaning-based similarity search
"factual"Looking for specific facts
"entity_lookup"Looking up a named entity
"temporal"Time-scoped query
"relational"Relationship-based query

RetrievalMode

ValueDescription
"default"Excludes HYPOTHETICAL and DEPRECATED
"explicit"Includes everything

RepresentationLevel

Ordered by fidelity (lowest → highest):

ValueDescription
"reference"Type and ID only, no memory text
"key_value"ID, type and confidence, no memory text
"structured"Type label plus the complete text
"prose"The complete text (no separate prose rendering exists)
"full"Complete original content

6. Configuration

All configuration uses pydantic-settings. Env vars use PRME_ prefix with __ delimiter for nesting.

PRMEConfig

from prme.config import PRMEConfig config = PRMEConfig( # PostgreSQL backend (when set, all storage uses PostgreSQL) database_url=None, # PRME_DATABASE_URL # Storage paths (DuckDB mode only, ignored when database_url is set) db_path="./memory.duckdb", # PRME_DB_PATH vector_path="./vectors.usearch", # PRME_VECTOR_PATH lexical_path="./lexical_index", # PRME_LEXICAL_PATH # Single-file pack (opt-in; when set, the three paths above are ignored, # and it cannot be combined with database_url). See "Single-file packs". pack_path=None, # PRME_PACK_PATH # Nested configs (see below) embedding=EmbeddingConfig(...), extraction=ExtractionConfig(...), # Write queue write_queue_size=1000, # PRME_WRITE_QUEUE_SIZE # Retrieval scoring and packing scoring=ScoringWeights(...), packing=PackingConfig(...), # Epistemic parameters epistemic_weights={ # PRME_EPISTEMIC_WEIGHTS "observed": 1.0, "asserted": 0.9, "inferred": 0.7, "hypothetical": 0.3, "conditional": 0.5, "deprecated": 0.1, "unverified": 0.5, }, unverified_confidence_threshold=0.30, # PRME_UNVERIFIED_CONFIDENCE_THRESHOLD # Confidence matrix overrides confidence_overrides={}, # Keys: "epistemic:source" e.g. "observed:user_stated" )

Environment variable prefix: PRME_ Nested delimiter: __ (e.g., PRME_EMBEDDING__DIMENSION=384)

Single-file packs

A local pack can also be one file. This layout is opt-in: directory packs remain the default, and their layout and behavior are unchanged. Give MemoryClient a path that ends in .prme, or set pack_path (PRME_PACK_PATH):

from prme import MemoryClient, MemoryEngine, config_from_pack # A path ending in .prme opens a single-file pack, creating it if needed. with MemoryClient("./memories.prme") as client: client.store("Alice prefers dark mode", user_id="alice") # The same pack through the async engine. config = config_from_pack("./memories.prme") # PRMEConfig(pack_path=<absolute path>) assert config.single_file_pack engine = await MemoryEngine.create(config)

A .prme path that names an existing directory still opens as a directory pack, and config_from_directory() always selects one, even with PRME_PACK_PATH set. config_from_pack() creates the parent directory and ignores a PRME_DATABASE_URL from the environment; the file itself is created when an engine first opens it. While pack_path is set, db_path, vector_path and lexical_path are ignored, and combining it with database_url fails validation. An empty value, such as PRME_PACK_PATH=, means unset. The tables inside the file are listed under Memory Pack in Section 2.

On open, before it serves any read, the engine:

  1. Writes the prme_manifest row of a pack that has none, naming the embedding model most of its stored vectors were built with, or the configured model when it has no vectors. A reopen updates prme_version and updated_at only when another PRME version first opens the pack, such as after an upgrade. It logs a warning, without refusing to open, when the configured embedding model’s name or dimension differs from the recorded one.
  2. Loads the USearch index from the vector_snapshot row and reconciles it against vector_payloads and vector_metadata exactly as it reconciles a vectors.usearch file. When the row is missing, unreadable or inconsistent with its recorded dimension or count, every vector is restored from its payload.
  3. Builds the in-memory Tantivy index from active nodes with the same documents, fields and id order as prme rebuild, which gives lexical documents to the same nodes as the write path (see Retrieval below). Engines in one process that open the same pack share this index: the first one builds it, and it is dropped when the last one closes.

The snapshot row is rewritten on close when the vector index changed, and after every vector_save_interval changes to the vector index, where an addition and a removal each count as one. Each save rewrites the whole snapshot (about 168 MB per 100,000 384-dimension vectors), so a large index may want a rarer interval. A directory pack’s vectors.usearch is also saved after every removal; the row is not, so archiving many nodes does not rewrite the snapshot once per node. A removal the row does not show yet survives a crash, because the next open drops snapshot keys whose vector metadata is gone. An open that only reads therefore leaves the file byte-identical, except that the first open after a session that added vectors moves the vector key sequence past them once; retrieve() saves its receipt, which is a write. prme rebuild rebuilds both indexes and records the rebuilding embedding model in the manifest. While a pack is open, DuckDB keeps memories.prme.wal beside it, and if DuckDB spills to disk, its temporary files go to a memories.prme.tmp directory beside it, for a plain and an encrypted pack alike. The pack is one file at rest, so copy it after its engine closes.

Encryption. With encryption_enabled=True and an encryption_key, a single-file pack uses DuckDB’s native encryption instead of Fernet file encryption. DuckDB encrypts the file and its write-ahead log with AES-256-GCM. It does not encrypt its temporary files by default, even for an encrypted database, so PRME turns on DuckDB’s temp_file_encryption for an encrypted pack and sets its temp_directory to memories.prme.tmp. Stored text therefore does not appear in clear on disk while the engine runs.

encryption_key takes the same forms as for a directory pack. A raw key, which is a 44-character url-safe base64 Fernet key or any value with the raw_key: prefix, reaches DuckDB as its 32 bytes in hex and does not depend on a salt. Any other value, or one with the passphrase: prefix, is a passphrase: PRME derives a 32-byte key from it with PBKDF2-HMAC-SHA256 (600,000 iterations) and the fixed, versioned salt prme-single-file-pack-v1. A per-pack salt cannot be stored inside a file that needs the key to open, so identical passphrases give identical keys across packs; each guess still costs the full iteration count. Use a strong passphrase, and do not reuse it across packs.

An encrypted pack cannot be opened without its key. A missing key, a wrong key, or encryption configured for an unencrypted pack raises EncryptionError: the pack is neither opened nor encrypted, the file and any WAL beside it stay byte-identical, and the message never contains the key. An encrypted pack can be open in only one engine per process; a second open raises PackInUseError, whatever spelling of the path it uses. Engines that open the same plain pack in one process share one DuckDB instance and one in-memory lexical index, so a node stored through one engine is found by lexical search through the others at once, as engines on one directory pack share its on-disk index. This holds for MemoryClient objects too, although each runs its engine on its own event loop. The first engine’s lexical_commit_interval and lexical_commit_max_delay_s apply to the shared index. Each engine still keeps its own vector index, so vector search through one engine finds a vector added through another only after it reopens the pack, as on a directory pack. engine.lock() and engine.unlock() belong to Fernet directory packs and raise RuntimeError for a single-file pack.

Compaction. DuckDB never shrinks a database file: deleted rows and replaced snapshot rows leave free blocks behind. prme compact ./memories.prme copies the pack with COPY FROM DATABASE into a fresh hidden file beside it, keeping its storage version and, for an encrypted pack, its key and cipher, then replaces the original with one rename and reports the size before and after. A failure leaves the original untouched. Through a symbolic link, the file the link names is compacted and the link stays, and the new file keeps the pack’s permission bits. Close the pack first: compaction refuses a pack with memories.prme.wal beside it, DuckDB’s file lock refuses one that another process has open, and in the same process every engine that uses it must be closed. Rows, node IDs and vector keys do not change, so prme compact ./my_memories/memory.duckdb compacts a directory pack’s database too. On a copy of a LoCoMo benchmark pack measured on 2026-10-14, compaction took a 743.5 MiB memory.duckdb to 510.5 MiB in 1.0 s, with identical retrieval results. The live data is about 510 MiB, of which 492.7 MiB is 404 stored retrieval receipts (RETRIEVAL_REQUEST rows in operations.payload, up to 2.4 MiB each). How large stored receipts should be is a separate matter.

Migration. prme migrate ./my_memories ./memories.prme copies a directory pack into a new single-file pack and leaves the directory in place. The new name must end in .prme, and migration never overwrites an existing file. The directory pack is opened once with an engine and copied through that engine’s connection, so a legacy Fernet-encrypted directory pack migrates with its key and is encrypted again when that engine closes. The copy is built in a hidden temporary file, opened once as a single-file pack to write its vector snapshot and manifest, checked for both, and only then moved into place, so a failure never leaves a partial pack. With a key, the new pack is encrypted natively under that key, even when the directory pack was not encrypted; without one, it is not encrypted. The LoCoMo benchmark pack above, with 1,413 nodes, 556 edges, 419 events and 1,413 vectors, migrated into a 521.5 MiB .prme file in 1.4 s. Directory packs keep opening as before, so migration is never required.

Retrieval. A single-file pack is searched with the same indexes, scoring and packing as a directory pack, and vector hits are identical. The write path, prme rebuild and the open of a single-file pack give lexical documents to the same nodes (has_lexical_document in prme.storage.lexical_index): every active node with content gets one, except an entity node that extraction created, which gets only a vector. An entity stored directly with store() gets both. A reopen therefore keeps the lexical documents of a single-file pack’s active nodes, and a pack migrated from a directory pack built with ingest() holds the same documents as its source: the LoCoMo source above held 827 of its 1,413 active nodes in its Tantivy index, and so does the migrated pack. Because prme rebuild follows the same rule, rebuilding a pack built with ingest(), a directory pack included, no longer adds lexical documents for its extracted entities, which keep their vectors. A node a session retires leaves both indexes when it is retired, whether the engine’s supersede(), archive() or resolve_contradiction() retired it or the derivation commit of ingest() replaced it, as with an earlier copy of a repeated claim; the eviction is best effort, and the index_compaction organizer job retries what it left. A pack whose claims ingest() retired before this change keeps their entries until index_compaction or prme rebuild removes them (a single-file pack’s open drops the lexical ones). Tantivy keeps a deleted document in its segment’s BM25 statistics until a merge, so a single-file pack’s in-memory index rebuilds itself from its live documents before the next search after such a deletion (one pass over the live documents: 12 ms for 1,413 documents and 0.88 s for 100,000, measured on 2026-10-15), and a session, a reopen and a rebuild give the same documents and scores. BM25 results can still differ in two cases:

  1. A directory pack keeps Tantivy’s deleted-document markers on disk, so it scores the same before and after a restart but can differ slightly from a prme rebuild until Tantivy merges the segment.
  2. Tied BM25 scores come back in Tantivy’s segment order, and lexical_k cuts candidates in that order, so a tie can reach the final ranks. A single-file pack’s open and its in-memory rebuild add documents in id order on one writer thread into one segment (measured up to 100,000 documents), so two opens of the same .prme file give the same order, and so does prme rebuild on a single-file pack, which ends by settling the index into that one segment. A session that only added documents holds them in write order across several segments, as a directory index does (prme rebuild on a directory pack commits in batches and so makes several segments), so their tie order can differ from the next open’s.

Directory-pack retrieval is unchanged, except after prme rebuild on a pack built with ingest(): on the LoCoMo half of the offline evidence gate (1,540 questions), contexts from directory packs were byte-identical with and without single-file pack support. That gate ran before the lexical policy above was shared. The LongMemEval-S half could not be run, because its saved control captures were not present on the machine that ran the gate. See the RFC-0014 amendment for details.

Tools. Every prme command that takes a db_path also opens a .prme file, among them prme rebuild, prme export and prme info. prme init ./memories.prme creates exactly that one file, encrypted when encryption is configured, and writes no .env.example beside it; it prints a hint instead. prme doctor ./memories.prme checks the file, its required tables and its manifest, and warns about a WAL beside it and about a missing or stale vector snapshot. prme doctor, prme compact and prme migrate take --encryption-key KEY, where an empty value means no key. Without it, for a .prme pack, they use PRME_ENCRYPTION_KEY when PRME_ENCRYPTION_ENABLED is true, which is where prme info and the other commands always read the key; they read only those two settings, from the environment or a .env file. A directory pack’s memory.duckdb never takes them, because for a directory pack they configure Fernet file encryption. See the CLI reference.

prme-mcp --db-path ./memories.prme serves a single-file pack by setting PRME_PACK_PATH, and sets PRME_DATABASE_URL to an empty value so a database URL from the environment cannot redirect it; given a directory, it sets PRME_PACK_PATH to an empty value, so a pack path from the environment or a .env file cannot take the directory’s place. MemoryWorkspace(..., single_file_packs=True), which MemoryWorkspace.open() also accepts, keeps each namespace’s pack in one memory.prme file. The registry does not record the layout, so pass the same setting every time the workspace opens; an initialized namespace whose pack is missing in the requested layout is refused rather than recreated. In the directory layout, a pack_path in the workspace configuration is cleared for every namespace. prme.client.is_pack_file(path) reports whether a path selects a single-file pack. Native encryption needs DuckDB 1.4.0 or later, and PRME requires duckdb>=1.4.0.

EmbeddingConfig

from prme.config import EmbeddingConfig EmbeddingConfig( provider="fastembed", # PRME_EMBEDDING_PROVIDER model_name="BAAI/bge-small-en-v1.5", # PRME_EMBEDDING_MODEL_NAME dimension=384, # PRME_EMBEDDING_DIMENSION; optional for registered models api_key=None, # PRME_EMBEDDING_API_KEY )

Supported providers: "fastembed" (local, default), "openai" (requires API key). When dimension is omitted, PRME reads registered FastEmbed model metadata or uses the known OpenAI model dimension without downloading weights. Selecting provider="openai" alone chooses text-embedding-3-small and 1,536 dimensions. Unknown or newly released model names require an explicit positive dimension, and fail during configuration instead of after an index has been opened.

ExtractionConfig

from prme.config import ExtractionConfig ExtractionConfig( provider="openai", # PRME_EXTRACTION_PROVIDER model="gpt-4o-mini", # PRME_EXTRACTION_MODEL max_retries=3, # PRME_EXTRACTION_MAX_RETRIES timeout=30.0, # PRME_EXTRACTION_TIMEOUT temperature=0.0, # PRME_EXTRACTION_TEMPERATURE reasoning_effort=None, # PRME_EXTRACTION_REASONING_EFFORT api_key=None, # PRME_EXTRACTION_API_KEY base_url=None, # PRME_EXTRACTION_BASE_URL )

Without api_key or base_url, OpenAI and Anthropic use their own OPENAI_API_KEY/OPENAI_BASE_URL or ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL, from the process environment first and then .env; Ollama uses its local server. Opt-in query reformulation (enable_query_reformulation=True) calls the same provider, model, endpoint and credential, and gives up after the same timeout. It keeps its own sampling: the provider’s default temperature, and for Ollama a reasoning effort of "none".

Supported providers: "openai", "anthropic", "ollama". Temperature zero favors repeatable schema-constrained extraction. Ollama resolves an omitted reasoning effort to "none", preventing thinking traces from exhausting the structured response window. Ollama also uses its constrained JSON output mode instead of requiring a tool-call envelope. Other providers retain their native default. Benchmark before increasing either setting.

ScoringWeights

Scoring fields are frozen. The six additive weights must sum to 1.0. Epistemic weight is multiplicative; paths weight is a tiebreaker.

PRMEConfig() scores with reciprocal rank fusion (fusion="rrf", rrf_k=60, RFC-0005 Section 7.2), which does not use the additive weights. A ScoringWeights object built in code, like the one below, defaults to fusion="weighted" and scores with them. Set PRME_SCORING__FUSION=weighted to keep the weighted formula with environment settings; other PRME_SCORING__* variables keep rank fusion unless it is set.

Scoring and packing configuration reject NaN and positive/negative infinity at construction or environment loading, including nested node-type boosts and scoped scoring overrides. Validation errors identify the offending field before retrieval starts. Finite defaults and their version identifiers are unchanged.

from prme.retrieval.config import ScoringWeights ScoringWeights( w_semantic=0.30, # Semantic similarity w_lexical=0.15, # Lexical relevance w_graph=0.20, # Graph proximity w_recency=0.10, # Recency factor w_salience=0.10, # Salience w_confidence=0.15, # Confidence w_epistemic=0.05, # Epistemic (multiplicative) w_paths=0.00, # Multi-path corroboration (tiebreaker) recency_lambda=0.02, # Decay rate: exp(-lambda * days) ) # .version_id → deterministic SHA-256 hash (12 chars) for traceability

PackingConfig

multipath_ordering="balanced", the PRMEConfig default and PackingConfig’s own field default, reserves the highest-scored ordinary multi-path candidate, then uses a quarter-length penalty. "score" selects composite-score ordering within the multi-path priority tier. Pins, instructions, active tasks, other tiers and measured whole-output budgets keep their existing rules. Balanced was chosen for JSON records after a complete 119-question answer trial at 4K: balanced scored 83 versus density at 67, with 26 wins and 10 losses. A separately registered 381-question answer confirmation scored 250 versus 185, with 88 wins and 23 losses. Both source partitions had already been inspected, so these results do not establish superior behavior for every workload. Score order became the default with the one-line reader format and rank fusion, which passed a paired answer test twice. Balanced order with those settings then passed the same test twice against score order and is the default again (see Default retrieval settings).

from prme import MemoryClient, config_from_directory from prme.retrieval.config import PackingConfig config = config_from_directory("./my_memories") config.packing = config.packing.model_copy(update={"multipath_ordering": "density"}) with MemoryClient(config=config) as client: client.store("Aurora requires deployment approval.", user_id="alice") result = client.retrieve("Aurora deployment policy", user_id="alice") print(result.bundle.render())

config_from_directory() creates the directory and resolves the database, vector and lexical paths together. Set typed options before opening the client. Passing a config to MemoryClient uses its paths as-is; its separate directory argument is ignored. The equivalent environment setting is PRME_PACKING__MULTIPATH_ORDERING=density. Temporal context guidance is enabled by default; disable it with PRME_PACKING__CONTEXT_GUIDANCE_MODE=off. all additionally enables experimental current-state and personalization prompts. Retrieval receipts retain ordering, guidance, context format, episode and evidence-projection settings in receipt.packing, and the current-update multiplier in receipt.scoring. The default context format is reader: one plain line per record (date, state tags, text) with the audit envelope kept in the bundle and receipt. Set PRME_PACKING__CONTEXT_CITATIONS=true to add [m3] references, which answerability checks and verify_bundle() need. Set PRME_PACKING__CONTEXT_FORMAT=auditable for self-describing JSON records, or compact for schema-declared JSON arrays and bundle-local references. Scoring defaults to reciprocal rank fusion of semantic and lexical ranks (PRME_SCORING__FUSION=rrf, RFC-0005 Section 7.2); set PRME_SCORING__FUSION=weighted for the weighted sum. Rank-fused scores are rank-based, so min_score then compares against each result’s semantic_relevance, its semantic cosine similarity, and a floor tuned on weighted scores does not carry over. When vector search fails or detects an embedding model mismatch and no result has a cosine, the floor is skipped instead of returning nothing, metadata.min_score_skipped is set, and the receipt uses version 18. The LangChain and LlamaIndex retrievers accept an optional min_score, and add min_score_skipped: True to every result’s metadata when the floor was skipped. Fused scores are compressed, so session neighbors at the weighted decay of 0.85 could crowd primary evidence out of the context; PRME_PACKING__SESSION_CONTEXT_RANK_FUSION_SCORE_DECAY (default 0.6) gives rank-fused triggers their own decay. Rank fusion alone ignores recency, so on a current-state question the older of two conflicting memories could rank above the newer one; PRME_SCORING__RRF_RECENCY_BOOST (default 0.25) applies the weighted formula’s current-state recency to the fused score, and PRME_SCORING__RRF_TIE_BREAK=event_time (the default) orders equal fused scores newest first instead of by node ID. Default retrievals write schema version 22 receipts, which record those settings, the decay and folded repeated text (on by default since 2026-10-06), or version 19 with PRME_PACKING__FOLD_REPEATED_TEXT=false. Rank fusion without the recency settings writes version 16, 17 with the decay or 18 with a skipped min_score; the reader format with weighted scoring writes version 14, and weighted retrievals in the auditable or compact format write version 12. Weighted retrievals with PRME_SCORING__RECENCY_TIME=event_time write version 20 in any format. Retrievals with PRME_PACKING__SESSION_CONTEXT_PACKING set write version 21 under either formula. Retrievals with PRME_PACKING__FOLD_REPEATED_TEXT=true write version 22 under either formula. Versions 1–7 mean episode routing was disabled. Versions 1–6 retain their original canonical JSON and feedback checksums and always mean auditable rendering. Versions 1–5 also mean context guidance was off. For source blocks or bounded dialogue episodes stored under meaningful session IDs, set PRME_PACKING__EPISODE_CONTEXT_TOP_K=2 to trial deterministic episode routing; the default 0 disables it. Set PRME_PACKING__EVIDENCE_PROJECTION_TOP_K=50 to trial source projection for the top exact evidence groups; its default 0 also disables it. Set PRME_PACKING__EVIDENCE_AUGMENTATION_TOP_K=10 to retain the derived claims and add bounded direct sources beside them. Projection and augmentation are mutually exclusive and disabled by default. PRME_PACKING__EVIDENCE_AUGMENTATION_ANCHOR_POLICY=non_entity prevents an entity-name match from routing its whole source passage; the default all preserves version 11 behavior.

Current-state retrieval gives the newest record that explicitly presents itself as an update a bounded, relevance-capped multiplier. Configure PRME_SCORING__CURRENT_UPDATE_MULTIPLIER between 1.0 and 2.0; the default is the provisional 1.30, and 1.0 disables it. Receipts record the exact applied coefficient. This ranking signal does not supersede or validate either claim.

This example chooses smaller candidate limits explicitly; it is not a list of defaults. A PackingConfig built from scratch starts from its field defaults (auditable, balanced, no rank fusion session decay), not from PRMEConfig().packing; copy config.packing with model_copy(update=...) to keep the product defaults.

from prme.retrieval.config import PackingConfig from prme.types import RepresentationLevel PackingConfig( token_budget=4096, # Context budget in tokens min_fidelity=RepresentationLevel.REFERENCE, # Minimum fidelity overhead_tokens=100, # Additional caller reserve beyond measured context context_format="auditable", # PRMEConfig default "reader" (plain lines); or "compact" for arrays context_citations=False, # Reader only: add [m3] references and context_references episode_context_top_k=0, # Opt-in session-scoped episode routing episode_context_local_k=8, # Records reserved per selected episode episode_context_score_decay=0.95,# Inherited episode-evidence score graph_max_candidates=50, # Max from graph traversal vector_k=50, # Max from vector search lexical_k=50, # Max from lexical search graph_max_hops=3, # Max graph hops (1-3) cross_scope_top_n=5, # Top-N cross-scope hints )

Context packing counts the complete rendered output with the configured tokenizer. The legacy chars_per_token and cross_scope_token_budget names remain accepted for configuration and receipt compatibility, but non-default values are ignored with a warning. Cross-scope hints live outside the packed context and are bounded by cross_scope_top_n.


7. Retrieval Pipeline Deep Dive

The 6-stage pipeline transforms a natural language query into a token-budgeted MemoryBundle.

Stage 1: Query Analysis

Input: Raw query string, optional temporal overrides. Output: QueryAnalysis with intent, entities, temporal signals, retrieval mode.

class QueryAnalysis(BaseModel): query: str intent: QueryIntent # semantic, factual, entity_lookup, temporal, relational entities: list[str] # extracted entity names temporal_signals: list[dict] # [{type, value, resolved}, ...] time_from: datetime | None time_to: datetime | None retrieval_mode: RetrievalMode # default or explicit request_id: UUID # unique per retrieval

Intent is the first pattern that matches: entity lookup, temporal, relational, factual, then semantic. Only the temporal intent is read downstream, by temporal affinity scoring, the current-state path and the context guidance. PRMEConfig.query_intent_order="temporal_first" (PRME_QUERY_INTENT_ORDER) checks temporal wording and dates before the entity patterns, so a temporal question that names someone gets temporal affinity (RFC-0005 Section 3, issue #85). The default, entity_first, is unchanged.

Stages 2-3: Candidate Generation + Merging

Input: QueryAnalysis, user_id, scope, temporal window, packing config. Output: Deduplicated list[RetrievalCandidate], per-backend candidate counts.

Runs three backends in parallel:

  1. Graph neighborhood — entities from query analysis → graph traversal up to graph_max_hops hops
  2. Vector similarity — ANN search over embeddings, top-vector_k
  3. Lexical search — BM25-style full-text search, top-lexical_k

Candidates are deduplicated by node_id. Each candidate tracks which backends produced it (paths field) and multi-path count.

For extracted stores, set max_per_source=1 on retrieve() when the consuming surface presents node.content as a list of passages. This limits only results with the same exact nonempty evidence set and byte-identical content, so sibling claims cannot consume the result budget with repeated source text. The default is disabled while answer-quality trials establish when to promote it.

Use max_per_evidence=1 when that surface needs one ranked representative per exact cited evidence set even if extracted sibling nodes have different text. This broader option fills the requested result limit from later evidence groups. Nodes without evidence remain independent, and identical text citing distinct events remains distinct. The result metadata and retrieval receipt record the applied value; exclusions use evidence_limit.

Stage 4: Epistemic Filtering

Input: Merged candidates, retrieval mode. Output: Filtered candidates, excluded candidates list.

In DEFAULT mode:

  • Excludes nodes with epistemic_type of HYPOTHETICAL or DEPRECATED
  • Filters UNVERIFIED nodes below unverified_confidence_threshold (default: 0.30)

In EXPLICIT mode: no filtering.

Stage 5: Scoring + Ranking

Input: Filtered candidates, scoring weights, epistemic weights. Output: Scored + sorted candidates, score traces.

Composite score formula:

additive = ( w_semantic * semantic_score + w_lexical * lexical_score + w_graph * graph_proximity + w_recency * recency_factor + w_salience * salience + w_confidence * confidence ) composite = additive * epistemic_weight

The epistemic weight is a direct multiplier on the additive sum (not 1 + w_epistemic * ...). The w_paths / path_score value is used only as a sort tiebreaker, not in the composite score itself.

Where:

  • recency_factor = exp(-recency_lambda * days_since_update) — uses updated_at (falls back to created_at); with recency_time="event_time" (PRME_SCORING__RECENCY_TIME, weighted fusion only) it uses event_time, else created_at, so history imported with a past event time is not scored as brand new
  • graph_proximity: 1-hop = 1.0, 2-hop = 0.7, 3-hop = 0.4
  • epistemic_weight: looked up from epistemic_weights dict by EpistemicType
  • path_score: number of backends that independently found this candidate (tiebreaker only)

Candidates sorted by (-composite_score, -path_score) for deterministic ordering.

Every candidate gets a ScoreTrace for full explainability:

class ScoreTrace(BaseModel): semantic_similarity: float lexical_relevance: float graph_proximity: float recency_factor: float salience: float confidence: float epistemic_weight: float path_score: float composite_score: float

Stage 5.5: Conflict Metadata

CONTESTED candidates are annotated with conflict_flag=True and contradicts_id pointing to the counterpart node. Counterparts are not auto-injected — only included if independently relevant.

Cross-Scope Hints

When scope is filtered and include_cross_scope=True, a secondary vector+lexical pass runs without scope restriction. Results from outside the primary scope are scored and the top-N (default: 5) appear as cross_scope_hints in the response — never mixed into primary results.

Stage 6: Context Packing

Input: Scored candidates, packing config. Output: MemoryBundle.

Greedy bin-packing within the token budget:

  1. Count each serialized candidate with the configured tokenizer
  2. Reserve overhead_tokens for JSON envelope
  3. Pack candidates in score order, assigning representation levels:
    • High-budget: FULL or PROSE
    • Mid-budget: STRUCTURED or KEY_VALUE
    • Low-budget: REFERENCE
  4. Group packed candidates into sections: entity_snapshots, stable_facts, recent_decisions, active_tasks, provenance_refs, contested_claims
class MemoryBundle(BaseModel): sections: dict[str, list[RetrievalCandidate]] included_count: int excluded_ids: list[UUID] tokens_used: int token_budget: int budget_remaining: int min_fidelity: RepresentationLevel rendered_context: str coverage_notice: str | None # Counted system boundary when applicable

RetrievalResponse (full return type)

class RetrievalResponse(BaseModel): bundle: MemoryBundle # Context-packed output results: list[RetrievalCandidate] # Scored results (pre-packing) metadata: RetrievalMetadata # Timing, counts, config version score_traces: list[ScoreTrace] # Always-on, one per result filter_metadata: FilterMetadata | None # Active filters for debugging cross_scope_hints: list[RetrievalCandidate] # Results from outside scope
class RetrievalMetadata(BaseModel): request_id: UUID candidates_generated: dict[str, int] # Per-backend counts candidates_filtered: int candidates_included: int scoring_config_version: str # ScoringWeights.version_id timing_ms: float backends_used: list[str] embedding_mismatch: bool backend_failures: dict[str, str] min_score_skipped: bool # Rank fusion floor skipped: no cosine aggregation_coverage: AggregationCoverage | None

For detected natural-language counts and lists, aggregation_coverage reports exhaustive=False, candidate/selection/context counts, stable limitation codes, and candidate paths observed at their configured caps. The rendered bundle also contains a token-counted non-exhaustive warning. Use scan_nodes() or iter_nodes() for complete stored-record traversal; semantic retrieval cannot prove that every real-world item matching a natural-language criterion was found or deduplicated.

The same stored-record page is available to remote clients through GET /v1/nodes/scan and the MCP tool memory_scan_nodes. Both require an explicit or credential-bound owner, accept scope, node type, lifecycle, UUID cursor, and page-size filters, and return has_more plus next_cursor. Follow the cursor until has_more is false. The response reports order="id" and consistency="page": traversal is complete for an unchanged store but is not a transaction snapshot across requests. GET /v1/nodes remains a bounded query convenience and must not be used as an export or counting contract.

Structured assertion counts use the same complete scan without exposing page bookkeeping to the caller:

from datetime import datetime, timezone from prme import AssertionQuery result = memory.aggregate_assertions( AssertionQuery( subjects=["I"], predicates=["tried"], group_by=["object"], event_time_from=datetime(2025, 1, 1, tzinfo=timezone.utc), ), user_id="alice", )

matched_records counts assertion occurrences and distinct_count counts the normalized group_by keys. Each group carries occurrence/evidence counts, bounded provenance samples, and its earliest/latest event time. Selectors use exact NFKC/case/whitespace-normalized matching, with predicate spaces and hyphens normalized to underscores. Use retrieval_mode="explicit" to include all epistemic and lifecycle states, or pass explicit lifecycle states to narrow the exact state set independently.

HTTP exposes this at POST /v1/assertions/aggregate with {"user_id": "alice", "query": {...}}; MCP exposes memory_aggregate_assertions. Both bind the result to the authenticated owner. stored_set_exhaustive=true covers matching structured records for an unchanged store. Separate fields report unknown source-extraction and real-world coverage, with semantic equivalence limited to normalized exact values. Finish pending ingestion and prevent concurrent mutation for audited counts. This API counts and groups text values; it does not parse or sum numeric quantities.

For facts carrying grounding="object_decimal_v1", use the dedicated exact quantity operation:

from prme import QuantityAggregationQuery totals = memory.aggregate_quantities( QuantityAggregationQuery( predicate_prefixes=["raised"], units=["$"], group_by=["unit"], ), user_id="alice", )

Each group returns an exact Decimal total, minimum, maximum, value/evidence counts, temporal bounds, and bounded per-node source samples. JSON serializes decimals as strings. group_by must contain unit; normalization is limited to Unicode, case, and whitespace, and no conversion or currency inference occurs. predicate_prefixes is opt-in and token-bounded after predicate normalization: raised includes raised and raised_*, but not fundraised. A response using it reports semantic_equivalence="normalized_exact_and_predicate_prefix" rather than normalized_exact_only; no synonym or embedding inference is performed. The read path revalidates the stored decimal against its claim object and source evidence. HTTP exposes POST /v1/quantities/aggregate; MCP exposes memory_aggregate_quantities. These operations use the same unchanged-store and extraction/real-world coverage boundaries as assertion aggregation.

For supported simple questions, the convenience path returns its exact plan and execution together:

planned = memory.aggregate_quantities_from_text( "How many kilometers did I run?", user_id="alice", )

The planner recognizes only complete, qualifier-free amount/count shapes in a fixed action and unit table. It preserves the exact I or we subject from the question and selects positive default epistemic state, explicit predicate-prefix families, and unit-separated groups. Unsupported qualifiers, negation, future wording, named subjects, actions, or units return plan.status="unsupported" with no scan. Inspect plan.query and plan.assumptions; the convenience result does not hide a semantic model call. HTTP exposes POST /v1/quantities/aggregate-text; MCP exposes memory_aggregate_quantities_from_text.

Use the exact temporal state operation when the application already knows an assertion’s subject and predicate:

from datetime import datetime, timezone from prme import AssertionStateQuery, Scope state = memory.get_assertion_state( AssertionStateQuery( subject="Alice", predicate="lives_in", scope=Scope.PERSONAL, valid_at=datetime.now(timezone.utc), ), user_id="alice", )

This scans all matching FACT, DECISION, and PREFERENCE records for an unchanged store. It returns eligible current candidates separately from the bounded timeline, along with event time, ingestion time, validity windows, lifecycle, supersedence pointers, contradiction edges, and evidence IDs. Status is unknown, single, consistent, multiple, or contested. multiple does not imply a contradiction, and the operation never treats the latest record as truth. Scope and valid_at are required to prevent cross-scope state mixing and implicit wall-clock results. An optional knowledge_at remains an ingestion cutoff over current lifecycle state and returns exact_snapshot=false.

HTTP exposes POST /v1/assertions/state; MCP exposes memory_get_assertion_state. Subject and predicate matching use the same exact normalization contract as assertion aggregation. Source extraction and real-world coverage remain explicitly unknown.


8. Multi-User / Multi-Tenant

user_id Isolation

Every store(), ingest(), and retrieve() call requires a user_id. All backend queries are scoped to this user — a user’s data is never returned for another user’s queries.

# User A stores memories await engine.store("I like Python", user_id="user-a") # User B cannot see User A's memories response = await engine.retrieve("What languages?", user_id="user-b") # response.results → empty

Scope Layering

Scopes provide cross-cutting isolation within a user’s data:

# Store at different scopes await engine.store("My preference", user_id="alice", scope=Scope.PERSONAL) await engine.store("Team decision", user_id="alice", scope=Scope.PROJECT) # Retrieve only personal memories response = await engine.retrieve("...", user_id="alice", scope=Scope.PERSONAL) # Retrieve from multiple scopes response = await engine.retrieve("...", user_id="alice", scope=[Scope.PERSONAL, Scope.PROJECT]) # Retrieve all scopes (default) response = await engine.retrieve("...", user_id="alice") # scope=None

Cross-scope hints automatically surface relevant results from other scopes when filtering by scope, appearing in response.cross_scope_hints.


9. Working Examples

Basic Store and Retrieve

engine = await MemoryEngine.create() # Store different types await engine.store("Alice is a senior engineer", user_id="u1", node_type=NodeType.FACT) await engine.store("Use Python for backends", user_id="u1", node_type=NodeType.DECISION) await engine.store("Prefers dark mode", user_id="u1", node_type=NodeType.PREFERENCE) # Retrieve response = await engine.retrieve("What do we know about Alice?", user_id="u1") for r in response.results: print(f"[{r.node.node_type.value}] {r.node.content} (score: {r.composite_score:.3f})") await engine.close()

Chat App Integration Pattern

Core pattern: store every message, retrieve before each LLM call, inject memories into system prompt.

from openai import AsyncOpenAI from prme import MemoryEngine, PRMEConfig, NodeType, Scope client = AsyncOpenAI() engine = await MemoryEngine.create(PRMEConfig( db_path="./chat_memory.duckdb", vector_path="./chat_vectors.usearch", lexical_path="./chat_lexical", )) async def handle_message(user_input: str, user_id: str, session_id: str) -> str: # 1. Store the user message await engine.store( user_input, user_id=user_id, session_id=session_id, role="user", node_type=NodeType.EVENT, ) # 2. Retrieve relevant memories response = await engine.retrieve(user_input, user_id=user_id) # 3. Format memories for the system prompt memory_lines = [] for r in response.results[:10]: memory_lines.append(f"- [{r.node.node_type.value}] {r.node.content}") memory_block = "\n".join(memory_lines) # 4. Call LLM with memories injected system_prompt = f"""You are a helpful assistant with persistent memory. ## Relevant memories {memory_block} """ completion = await client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}, ], ) reply = completion.choices[0].message.content # 5. Store the assistant response await engine.store( reply, user_id=user_id, session_id=session_id, role="assistant", node_type=NodeType.EVENT, ) return reply

LLM Extraction with ingest()

# Requires OPENAI_API_KEY (or another extraction provider) engine = await MemoryEngine.create(PRMEConfig( extraction=ExtractionConfig(provider="openai", model="gpt-4o-mini"), )) # Ingest a conversation — LLM extracts entities, facts, relationships event_ids = await engine.ingest_batch( [ {"role": "user", "content": "I just started using Neovim and I love it."}, {"role": "assistant", "content": "Neovim has excellent plugin support!"}, {"role": "user", "content": "Sarah recommended it. She has used it for years."}, ], user_id="alice", session_id="session-1", wait_for_extraction=True, # block until extraction done scope=Scope.PROJECT, ) # Now retrieve — extraction created entities (Alice, Sarah, Neovim) and facts response = await engine.retrieve("What tools does Alice use?", user_id="alice")

When a fact object contains one exact numeric amount, built-in extraction may also populate node.metadata["quantity"] with decimal-string value, verbatim unit, verbatim source_text, and grounding="object_decimal_v1". Grounding requires the quantified phrase in both the object and source evidence and checks the parsed decimal. When JSON transport emits a float, built-in extraction can recover the decimal only by reparsing one exact supported token from the grounded source phrase; it never converts the float. Approximation or range cues in the surrounding evidence reject clipped exact-looking output. If model-authored quantity fields are absent or invalid, built-in extraction can recognize one verbatim currency or unit from its bounded physical, data and count-unit lexicon in an otherwise grounded fact object. It does not normalize that unit or accept an unlisted noun as a measure. Invalid optional quantity output is removed while the otherwise grounded fact remains. Ranges, approximations, scientific notation, locale decimal commas, and phrases with multiple numbers are not typed. No currency inference or unit conversion occurs.

V11 and later extraction can also recover one leading exact measure from a user-authored first-person completed action in a bounded verb lexicon, such as I just ran 5 kilometers. It retains the source phrase as the object and uses the same validators; modals, negations, examples, questions, conditions, approximations and ranges do not use this recovery path.

Custom Scoring Weights

from prme.retrieval.config import ScoringWeights # Emphasize semantic similarity and recency over graph proximity custom_weights = ScoringWeights( w_semantic=0.40, w_lexical=0.10, w_graph=0.10, w_recency=0.20, w_salience=0.05, w_confidence=0.15, ) response = await engine.retrieve( "What happened recently?", user_id="alice", weights=custom_weights, token_budget=2048, # smaller context window ) # Check the scoring config version used print(response.metadata.scoring_config_version)

Lifecycle Management

# Store a fact (starts TENTATIVE) eid = await engine.store("Project uses MySQL", user_id="u1", node_type=NodeType.FACT) # Find the node nodes = await engine.query_nodes(user_id="u1", node_type=NodeType.FACT) old = next(n for n in nodes if "MySQL" in n.content) # Promote to STABLE after verification await engine.promote(str(old.id)) # Store corrected fact and supersede await engine.store("Project uses PostgreSQL, not MySQL", user_id="u1", node_type=NodeType.FACT) nodes = await engine.query_nodes(user_id="u1", node_type=NodeType.FACT) new = next(n for n in nodes if "PostgreSQL" in n.content) await engine.supersede(str(old.id), str(new.id)) # Old node is now SUPERSEDED — excluded from default retrieval # New node is TENTATIVE — included in retrieval, can be promoted

10. PostgreSQL Backend

PRME supports PostgreSQL as an alternative storage backend. When database_url is set, all four storage layers (events, graph, vector, lexical) use a single PostgreSQL instance instead of file-based DuckDB/USearch/Tantivy.

Quick Start (PostgreSQL)

import asyncio from prme import MemoryEngine, PRMEConfig, NodeType, Scope async def main(): config = PRMEConfig( database_url="postgresql://user:pass@localhost:5432/myapp", ) engine = await MemoryEngine.create(config) try: await engine.store( "Alice prefers dark mode", user_id="alice", node_type=NodeType.PREFERENCE, scope=Scope.PERSONAL, ) response = await engine.retrieve("What does Alice prefer?", user_id="alice") for r in response.results: print(f"[{r.composite_score:.3f}] {r.node.content}") finally: await engine.close() asyncio.run(main())

Installation

PostgreSQL support requires optional dependencies:

pip install prme[postgres]

This installs asyncpg (async PostgreSQL driver) and pgvector (vector similarity extension support).

PostgreSQL requirements:

  • PostgreSQL 14+ with the pgvector extension installed
  • The pgcrypto extension (ships with PostgreSQL by default)

Configuration

Set database_url via config or environment variable:

# Via config config = PRMEConfig(database_url="postgresql://user:pass@host:5432/dbname") # Via environment variable # export PRME_DATABASE_URL=postgresql://user:pass@host:5432/dbname config = PRMEConfig() # auto-reads PRME_DATABASE_URL

When database_url is set, config.backend returns "postgres" and all file-path settings (db_path, vector_path, lexical_path) are ignored. The single-file pack_path setting is the exception: combining it with database_url fails validation.

Schema

MemoryEngine.create() automatically initializes the PostgreSQL schema on first use (all DDL is idempotent with IF NOT EXISTS). Tables created:

TablePurpose
eventsAppend-only event log (mirrors DuckDB events table)
nodesMemory graph nodes with embedding vector(N) column and content_tsv tsvector GENERATED column
edgesGraph edges with foreign key constraints and ON DELETE CASCADE
operationsOperation log for retrieval/ingestion tracking
lexical_documentsNon-node content indexed for full-text search (events, summaries)

Indexes:

  • HNSW index on nodes.embedding using vector_cosine_ops (pgvector)
  • GIN indexes on nodes.content_tsv and lexical_documents.content_tsv
  • B-tree indexes on user_id, node_type, scope, lifecycle_state, timestamps

How It Differs from DuckDB Mode

AspectDuckDB modePostgreSQL mode
ConcurrencySingle-writer via WriteQueueMulti-writer via connection pool (NoOpWriteQueue passthrough)
Vector searchUSearch HNSW + separate metadata tablepgvector HNSW on nodes.embedding column directly
Lexical searchTantivy (BM25)tsvector / tsquery with ts_rank_cd() scoring
Text indexingExplicit index() call for all contentGENERATED ALWAYS AS column auto-maintains tsvector for nodes; explicit for non-node content
Graph traversalDuckDB recursive CTEsPostgreSQL recursive CTEs with native CYCLE detection
PersistenceManual save() calls for vector indexAutomatic (PostgreSQL WAL)
PortabilityMemory pack (copyable files)Single database (use pg_dump for export)
Async modelasyncio.to_thread() wrappers around sync DuckDBNatively async end-to-end (asyncpg)

API Compatibility

The MemoryEngine API is identical in both modes. All methods — store(), ingest(), retrieve(), promote(), supersede(), archive(), close(), etc. — work the same way. No code changes needed when switching backends.

SST / Cloud Integration

For SST (or similar IaC) apps that provision a PostgreSQL database:

import os from prme import MemoryEngine, PRMEConfig # SST injects the database URL at runtime config = PRMEConfig( database_url=os.environ["DATABASE_URL"], ) engine = await MemoryEngine.create(config)

Connection Pool

PostgreSQL mode uses an asyncpg connection pool (default: min_size=2, max_size=10). The pool is created by MemoryEngine.create() and closed by engine.close().

Testing Against PostgreSQL

PostgreSQL tests require the PRME_TEST_DATABASE_URL environment variable. The repository’s docker-compose.yml starts a loopback-only test database for this; see CONTRIBUTING.md :

export PRME_TEST_DATABASE_URL=postgresql://prme_test:prme_test@127.0.0.1:5432/prme_test pytest tests/test_pg_*.py -v

Tests are automatically skipped when PRME_TEST_DATABASE_URL is not set. All existing DuckDB tests continue to pass without any PostgreSQL instance.


11. RFC Summary Table

RFCTitleTierOne-line Summary
RFC-0000Suite Overview and Design Philosophy0Design principles, tier system, [HYPOTHESIS] tagging convention
RFC-0001Core Data Model and Terminology0Node types, edge types, memory objects, temporal validity, evidence refs
RFC-0002Event Store and Append-Only Log1Immutable event log (DuckDB), content hashing, replay capability
RFC-0003Epistemic State Model1Epistemic types, confidence matrix, source provenance, filtering rules
RFC-0004Namespace and Scope Isolation1Six scope levels, cross-scope hints, sandbox hard-delete
RFC-0005Hybrid Retrieval Pipeline26-stage pipeline, 8-input composite scoring, deterministic ranking
RFC-0006Retrieval Cost and Context Efficiency2Token budgeting, representation levels, greedy bin-packing
RFC-0007Decay and Forgetting Model3Salience decay, TTL enforcement, policy-based archival
RFC-0008Confidence Evolution and Reinforcement3Confidence updates from retrieval feedback and corroboration
RFC-0009Memory Usage Feedback Loop3Track memory usage in retrieval to influence future ranking
RFC-0010Temporal Pattern Awareness4Detect recurring patterns and temporal clusters in memory
RFC-0011Multi-Agent Memory Semantics4Domain-scoped agent trust, shared memory coordination
RFC-0012Memory Branching and Simulation4Branch memory state for hypothetical exploration, merge back
RFC-0013Intent and Goal Memory4Track user goals and active intents (replaced emotional tracking)
RFC-0014Portability, Sync, and Federation4Memory pack export, sync protocol, multi-device federation

Conformance Tiers

TierRequired RFCsDescription
Tier 00000, 0001Core data model only
Tier 1+ 0002, 0003, 0004Persistent, epistemic, namespace-isolated storage
Tier 2+ 0005, 0006Hybrid retrieval with context-efficient bundling
Tier 3+ 0007, 0008, 0009Adaptive lifecycle: decay, confidence, feedback
Tier 4+ any of 0010-0014Advanced capabilities (each independently optional)

PRME’s current implementation covers Tiers 0-2 fully, with partial Tier 1 epistemic support (confidence matrix, epistemic filtering, supersedence detection).

Experimental retrieval composition policies

Two explicit policies are available for controlled trials through PRMEConfig: reranker_policy="anchored_score_envelope" with enable_reranker=True, and query_reformulation_merge_policy="max_signals" with enable_query_reformulation=True. The unanchored "score_envelope" reranker is also available, and reranker_prior_weight=0.0 with an envelope policy orders the reranked prefix by the cross-encoder alone while it keeps its fused scores (a weight other than 0.3 is refused with "legacy"). Default flags, the existing "legacy" / "new_only" policies and the 0.3 prior weight are unchanged. Both local and PostgreSQL engines use these settings.

See experimental retrieval policies for failure semantics, receipt versions, quality evidence and configuration examples. These options have not passed an untouched answer confirmation and are not recommended production defaults. Product alignment/Jev remains a separate explicit pair-selection and review workflow.


Source: docs/INTEGRATION.md  in PRME at 58caddb1.