Skip to content

Agentic Memory

Omnia gives agents durable, long-term memory that outlives a single conversation: memories are tiered by who and what they are about, recalled by rank, and decayed and expired over time.

Memory is distinct from sessions. A session is the transient transcript of one conversation; memory is the distilled, cross-session knowledge an agent accumulates about a user, about its own domain, and about the organisation it serves. Memory is served by the per-workspace memory-api service and reached by agents through the memory__remember, memory__recall, memory__list, and memory__forget platform tools.

A memory is a durable, typed observation: a fact, a preference, or a piece of context. It is stored with metadata that lets it be ranked, deduplicated, decayed, and (when a consent policy applies) classified. Each memory carries:

  • Content — the observation text, plus an optional title and summary.
  • Kind — e.g. fact, preference, observation; the accepted kinds come from the memory schema (built-in, or the MemoryPolicy’s schema).
  • Confidence — how strongly the agent believes the observation.
  • Embedding — an optional vector, when an embedding Provider is configured, enabling semantic recall.
  • Consent category — an optional privacy classification (Enterprise).
  • Scope — which tier the memory belongs to (see below).

A MemoryPolicy’s schema declares the kinds, relation types, and display names a workspace accepts; a workspace with no bound policy falls back to the built-in kinds. That schema reaches these places:

  • Agents and the MCP gateway — memory-api renders the schema into the memory tools’ descriptions and input schemas (GET /api/v1/memory/tool-guidance). The agent runtime and the dashboard’s memory MCP tools both use that one rendering. memory__remember’s type enum lists the types a user-tier write accepts; memory__recall’s types, kinds and relation_types enums list the policy’s declared types and relations, and stay open when no policy declares them. See Memory tools.
  • Recall — graph expansion follows stored edges whose relation type is in recall.graphExpansion.relationTypes, or every relation type when that is empty.

See MemoryPolicy for the schema’s field-by-field reference.

Memories are organised into tiers that scope who and what a memory is about. Recall queries fan out across the tiers and fuse the results into one ranked list.

TierScopeExample
UserA specific end-user, across all agents“Prefers metric units”
AgentA specific agent, across all users“This agent’s booking flow requires a confirmation step”
User-for-agentOne user as seen by one agent“This user asked this agent about refunds twice”
InstitutionalThe whole workspace / organisation“Company return policy is 30 days”

The user_for_agent tier is derived at recall time from the intersection of a user and an agent — it inherits the user tier’s ranking weight rather than carrying its own.

User and agent memories are written by the agent-facing tools. Institutional memory is authoritative workspace content, written by an operator or an ingestion populator.

flowchart LR
U[Identified user] --> UT[User memory]
A[Agent] --> AT[Agent memory]
U --> UA[User-for-agent view]
A --> UA
O[Operator or populator] --> IT[Institutional memory]
UT --> R[Multi-tier recall]
AT --> R
UA --> R
IT --> R
R --> C[Agent context]

A memory can belong to a container: a project, a client or a matter. The container is the memory’s facet, and it keeps one piece of work’s memories out of another’s recall inside the same tier.

A container is a relevance boundary, not a security boundary. The caller chooses it on each request, and a caller that names no container sees every container. Who may read a memory is decided by its tier and subject, as before.

  • A memory saved with a facet belongs to that container. A memory saved without one is cross-cutting.
  • A recall with a container in scope returns that container’s memories and the cross-cutting ones. It never returns another container’s, in any tier. Graph expansion from the hits follows the same rule.
  • A recall with no container in scope returns every container’s memories.
  • Deduplication, by about key or by similarity, collapses the same fact within one container only. The same fact saved under two containers is two memories.
  • List, search, open and forget apply the same rule. Another container’s memory is not found.

A facet is 1 to 128 ASCII letters, digits and . _ : / -, starting with a letter or digit, such as project:apollo or client/acme. Facets are compared exactly, so Acme and acme are two containers.

The dashboard’s MCP gateway takes the container from its project connection parameter: see Memory tools. A service calling memory-api sends it as facet: see Memory API. An agent’s conversation takes its container from the memory_facet parameter of the WebSocket connection it runs on: see Memory tools. The client sets it when it connects, and the model cannot change it. A conversation without one saves cross-cutting memories and recalls every container. A container the client chooses isolates relevance, not access: an operator who must hold an agent to one container sets the AgentRuntime’s spec.memory.facet, which applies on every facade and refuses a connection that names another.

Consolidation keeps containers apart too. A merge or a summary only combines memories of one container.

When an agent calls memory__recall, memory-api runs a ranked retrieval across all applicable tiers and returns a single fused, ranked list. How ranking works depends on whether an embedding Provider is configured.

Hybrid retrieval with Reciprocal Rank Fusion

Section titled “Hybrid retrieval with Reciprocal Rank Fusion”

With an embedding Provider configured and a non-empty query, recall is hybrid: two independent rankings are computed and fused.

  1. Full-text search (FTS) ranks memories lexically against the query.
  2. Vector search ranks memories by pgvector cosine similarity between the query embedding and each memory’s embedding.

The two ranked lists are combined with Reciprocal Rank Fusion (RRF) using a constant k = 60. RRF scores each memory by 1 / (k + rank) in each list and sums the contributions, so a memory that ranks well in either list surfaces. Semantic-only matches (no lexical overlap with the query) appear alongside keyword matches.

graph LR
Q[Query] --> FTS[FTS rank]
Q --> VEC[pgvector cosine rank]
FTS --> RRF["RRF fuse (k=60)"]
VEC --> RRF
RRF --> HL[Per-tier half-life decay]
HL --> TP[Tier precedence weights]
TP --> R[Ranked results]

On an embedding failure, or on an empty query, recall falls back to FTS-only multi-tier retrieval. Semantic-only matches are not available in that mode, but the tier fan-out and decay still apply.

After fusion, each memory’s score is multiplied by a per-tier recency-decay multiplier driven by a half-life. A memory whose age equals the tier’s half-life scores at 0.5× the recency multiplier; at 5× the half-life it is effectively gone. The defaults are 30 days for user memories, 90 days for agent memories, and no recency decay for institutional memories. With Enterprise enabled, the half-life is configurable per tier via spec.recall.halfLife.{user,agent,institutional}; Core deployments use the defaults.

Finally, a per-tier multiplier biases the fused score across tiers — for example to make institutional policy outrank a casual user preference. Ordering within a tier is preserved; the weights bias across tiers. This is configured via spec.tierPrecedence.multiplicative.

Recall returns previews; memory__open returns the rest. A memory whose body exceeds spec.recall.inlineThresholdBytes (default 8192 bytes) comes back from memory__recall as a content_preview with has_full_body: true and an empty content, so a recall that matches long documents does not flood the model’s context on every turn. When the preview is not enough, the agent calls memory__open with the memory’s id and reads the full body (and its title).

The two tools travel together. The agent runtime and the dashboard’s MCP gateway register memory__open only beside memory__recall, and recall asks memory-api for previews only when open is there to read them. Open applies recall’s visibility to the id (the user’s own memories and the shared tiers the same scope recalls) and answers an id it cannot see exactly as it answers one that does not exist, so an id is never a capability and open cannot probe which ids exist. See Memory tools.

A stored memory is read into later sessions, and an institutional-tier memory reaches every conversation in the workspace. Text that instructs the AI, once stored, would act in sessions its author never saw. The agent runtime scans every memory before the model sees it, and drops one that reads as an instruction to the AI rather than as knowledge.

The scan covers every path that returns stored text:

  • ambient retrieval into {{memory_context}}, both the profile pull and the per-turn search;
  • memory__recall and memory__list results;
  • memory__open, which answers not_found for a flagged memory.

The rules match text that overrides the model’s instructions, addresses future agents or models, or carries a chat-template role marker such as <|im_start|>. An ordinary imperative, such as “always escalate refunds over £500”, passes. The scan reads a memory’s content and every text value in its metadata, including values nested in lists and objects such as tags, because recall returns the metadata, title and preview included.

The shared-tier write gate applies the same rules before a shared memory is stored. The read-side scan also covers user-tier memories, which skip that gate, and rows stored before a rule existed.

A dropped memory stays in memory-api. Each drop is logged as memory injection blocked with the memory’s id, tier, the path and the matched rule, never its text, and counted in omnia_runtime_memory_injection_blocked_total. A memory dropped again on a later turn is counted again but logged at info level only the first time, so the counter measures drops rather than distinct memories. The scan has no setting and cannot be turned off.

A memory expires at its expires_at. The writer can set one, and otherwise the service group’s MemoryPolicy sets one at write time from the tier’s ttl.default, capped at the tier’s ttl.maxAge. A tier with no ttl sets no expiry. An expired memory is not recalled, listed or opened, and memory-api’s maintenance worker deletes it on its next run (hourly by default).

Deletion depends on the tier. Forgetting a user’s memory deletes it at once. Forgetting a shared (agent or institutional) memory moves it to a trash, from which it can be restored for the trash window (seven days by default) before maintenance purges it. A consent revocation deletes the user’s memories in the revoked category at once, unless the policy’s consentRevocation.action is Stop. Full field-by-field configuration lives in the MemoryPolicy CRD reference.

Two mechanisms keep the store from filling with near-duplicates:

  • Structured-key dedup — identity-class kinds (e.g. fact, preference) can be required to carry an about={kind, key} hint, so repeated observations about the same entity collapse onto one row.
  • Embedding-similarity dedup — when embeddings are available, a new memory whose cosine similarity to an existing one is above autoSupersedeAbove (default 0.95) automatically supersedes it; matches above surfaceDuplicatesAbove (default 0.85) are surfaced to the agent as potential_duplicates to reconcile on a later turn.

Near-duplicates merge automatically, and reinforce

Section titled “Near-duplicates merge automatically, and reinforce”

A user-tier save that matches one of the same user’s memories at or above the merge threshold is merged into it at write time: it is the same fact confirmed again. The threshold is the MemoryPolicy’s dedup.embeddingSimilarity.autoSupersedeAbove (default 0.95). Lower it to merge more near-duplicates automatically; it must stay above 0.5. Below surfaceDuplicatesAbove, every match merges and none is only surfaced. With dedup.embeddingSimilarity.enabled: false, every save inserts a new memory and nothing is merged or reported. The match compares one user’s memories of the same container, agent scope and evidence class, scored exactly. Policy changes reach memory-api within 30 seconds, without a restart. While memory-api cannot read the policy at all (a cold start with the API server unreachable), it does not merge, because the policy might disable merging.

A merge keeps the newer wording, and the previous body stays in the item’s revision history. A confidence the save states replaces the memory’s, and can lower it. Otherwise confidence and expiry are reinforced rather than overwritten:

  • Confidence becomes 1 − (1 − old)(1 − 0.7), capped at 0.99, where 0.7 is the default a save without a confidence gets. Two such saves give 0.91. A casual repeat never demotes a fact stated firmly.
  • Expiry becomes the later of the memory’s and the tier’s TTL default. A memory that never expires keeps not expiring, and with no TTL default the deadline lifts. The result is then capped at the tier’s ttl.maxAge, so a memory without an expiry under a tier with maxAge gets one.
  • Evidence count goes up by one. Reads return it as the evidence_count metadata key, so a client can show how many times a memory was confirmed.

A memory is reinforced at most once an hour, counted from when it was saved or last reinforced. A save repeated within the hour, such as a retry, a loop or the same fact said twice in one conversation, still updates the wording and any stated confidence, but leaves the confidence, expiry and evidence count as they were.

The save answers auto_superseded with the stored confidence, expiry and count. Merging happens only when a memory is saved: lowering the threshold does not merge pairs already stored below the old one. An explicit correction, such as a save with the same about key or a Dreaming run’s rewrite, sets confidence and expiry to the values it carries.

The shared-tier gate (Enterprise) adds a candidate that matches a published agent or institutional memory to that memory as evidence instead of publishing it again. When the candidate comes from a user the memory has not counted yet, the memory is reinforced: its confidence combines with the candidate’s (0.7 when the candidate states none). Its expiry moves only to a later expiry the candidate states; a candidate without one leaves the expiry as it is. A repeat from a user already counted raises evidence_count but leaves confidence and expiry alone, so one user saying the same thing many times does not make shared knowledge more certain. The same applies to held candidates that corroborate a candidate as it is published.

Reverting a Dreaming run undoes the confidence and expiry its evidence changed, as long as the memory still holds the values that evidence left. When a later corroboration or edit has changed them since, the revert keeps the memory as it is, so another user’s reinforcement is not lost; memory-api logs each memory it kept.

Semantic recall, embedding-similarity dedup, and consent-embedding classification all depend on an embedding Provider (spec.role: embedding). memory-api sizes its pgvector columns at startup to the Provider’s declared spec.embedding.dimensions (1–4000, the CRD’s range and memory-api’s) when it is set, and otherwise to the dimension the provider implementation reports: Azure/OpenAI 1536, ollama nomic-embed-text 768, mxbai-embed-large 1024, all-minilm 384. For type: openai the reported value comes from the model name: 1536 for text-embedding-3-small and text-embedding-ada-002, 3072 for text-embedding-3-large. For any other model the provider reports none and the schema uses 1536, so an OpenAI-compatible gateway serving another model must declare spec.embedding.dimensions to match the model’s output. The declared value is also passed to the provider as the requested output size. type: openai sends it as the request’s dimensions, which text-embedding-3-* honours by shortening the vector and a fixed-size model rejects. If the first vectors the provider returns do not match the schema’s dimension, memory-api logs embedding dimension mismatch once, naming both sizes. memory-api refuses to start when its Provider’s model or dimension differs from the one its database holds, and changing the field between two concrete values produces an admission warning that says so. A different model is a change of its own: memory-api embeds every memory with the next model beside the current vectors, swaps recall over in one step, and keeps serving throughout. See Change the memory embedding model.

Memory requires an embedding Provider. A managed service group that configures memory without providerRef gets no memory-api: its EmbeddingProviderReady condition is False with reason ProviderNotConfigured, and its session-api keeps running.

Embedding Providers use the provider types the PromptKit SDK supplies; Omnia does not keep a second, Omnia-owned provider catalogue.

The Memory Galaxy is a clustering overview of a workspace’s memories for an operator view, drawn in 3D over the memory store’s cluster tree. memory-api’s cluster worker builds the tree from the memories’ vectors.

Every memory contributes a point so the cluster shape stays faithful, but points whose consent category is PII-sensitive (memory:identity, memory:location, memory:health) are masked server-side before serialization: their identifying and content fields are stripped, leaving an anonymous, non-interactive dot. Dropping the id is the security boundary — a masked dot cannot be clicked through to the underlying memory. Masking is applied at read time on every serve and is scope-independent, so an operator cannot enumerate users to defeat it.

When enterprise privacy enforcement is enabled, a consent-category validator runs on every memory write. It merges several signals using upgrade-only semantics — a detected category overrides the caller’s claim only when it is more restrictive:

  1. The caller’s declared consent_category is the primary signal.
  2. A PII regex pass classifies content into memory:health, memory:identity, memory:location.
  3. When an embedding Provider is configured, an embedding-similarity pass classifies content against per-category exemplar centroids (memory:preferences, memory:context, memory:history, plus reinforcement of the regex categories).

The classification drives per-category expiry overrides (spec.tiers.<tier>.perCategory), Galaxy masking, and consent-revocation handling. In Core deployments the validator is off and consent_category stays NULL — a binary opt-out is the only gate.

  • Configure an embedding Provider (role: embedding) for every memory service group. Memory requires one: without it the operator provisions no memory-api. See Configure workspace memory.
  • Leave ttl unset on the institutional tier. Authoritative content then leaves only through an explicit delete, memory__forget or a DSAR delete.
  • Set recall.halfLife per tier to match the shelf life of the data: short for volatile user context, long for durable policy. This is an Enterprise setting.
  • Require about hints for identity-class kinds with dedup.requireAboutForKinds. The hint engages the structured-key dedup path, so identity memories do not pile up as duplicates.
  • Before a demo, confirm that your consent policy classifies the categories you expect the Galaxy to mask (memory:identity, memory:location, memory:health).