Skip to content

Agent Memory ​

Uranus uses a four-tier memory model that combines transient working memory, episodic message history, semantic recall over a vector index, and reflective learnings produced by self-review.

Why four tiers ​

A single flat memory blob can't simultaneously:

  • Hold the current turn's tool results without bloating long-term storage
  • Replay recent messages efficiently
  • Answer "do I know anything relevant to this user message" via similarity
  • Carry forward distilled lessons across context-window compactions

Each tier solves one of those.

TierWhat it isWhere it livesLifetime
L1 WorkingCurrent turn's tool results, turn checkpoints, per-conversation notesDurable Object RAM + DO storage (turn:*)one turn / one conversation
L2 EpisodicMessage-level history + range-bound compaction overlayschat_messages + conversation_overlays (+ legacy conversation_summary:*)weeks–months
L3 SemanticExtracted facts with embeddings and a keyword indexagent_state (memory:*) + Vectorize + memory_fts + memory_embeddings registryindefinite, with consolidation
L4 ReflectiveReflections, goals, learningsagent_reflections + agent_goals + memory:reflection:* keysindefinite

L3 — Semantic memory ​

The most consequential tier. Two write paths and two recall paths.

Write paths ​

Per-turn extraction. After every chat turn, extractAndPersistMemories runs via ctx.waitUntil (DO-eviction-safe). It asks an LLM to lift durable facts from the exchange (preferences, identities, recurring patterns) and writes them to agent_state keys:

  • memory:{key} — agent-wide
  • memory:contact:{contact_id}:{key} — scoped to one contact

Each write also embeds the value into Vectorize via embedAndUpsertMemory and records the linkage in memory_embeddings.

Pre-compaction flush. When the chat loop's paginated history fetch hits the token budget, summarizeAndStoreHistory runs in parallel with the summary generation. It extracts up to 10 durable facts from the about-to-be-trimmed window. Same write path, but tagged source='pre-compaction'.

Reflection. Reflective memories from self-review write to memory:reflection:{date}:{topic} with confidence=0.85. These outrank per-turn facts (0.5) and pre-compaction facts (0.6) in recall.

Memory kinds ​

Every memory carries a fact type (preference, identity, event, instruction, task, workflow-outcome, …) that kindOf() in memory.ts folds into one of four kinds — the same four Cloudflare Agent Memory uses:

KindFact typesIndexed how
factpreference, identity, relationship, decision, reflection (default)vector + keyword
eventevent, workflow-outcome, workflow-failurevector + keyword
instructioninstruction, skillvector + keyword
tasktaskkeyword only — never vectorised

Task memories are deliberately kept out of the vector index: a stale "I was booking a table" must not surface semantically weeks later.

Write-time verification ​

Before a new memory is stored, verifyAndSupersede (on by default; settings.memory.verify = false disables it) finds up to three existing memories with cosine ≥ 0.6 and asks the Jev decision model two yes/no questions per pair: does the new memory replace the existing one? and do they contradict? A replaces probability ≥ 0.75 marks the older memory superseded_by the new key, so recall stops returning it; contradictions that are not replacements are only logged. Task memories skip the verifier. See Typed Decisions for the model and the data-flow policy that gates it.

Recall paths ​

fusedRecallMemories(query, topK) runs three channels in parallel and merges them with reciprocal rank fusion (score += weight / (60 + rank)):

ChannelSourceWeight
VectorsemanticRecallMemories — embeds the message, queries Vectorize filtered to { type: "memory", agentId }1
Keywordmemory_fts (FTS5, porter + unicode61), BM25-ordered, top 151
Exact keyagent_state keys whose segment equals a query token1.5

Superseded memories are dropped from every channel. The caller resolves the fused keys back to values from agent_state. A SQL relevance pass (0.7 × recency_decay + 0.3 × log(access_count + 1), 30-day decay) still fills the remainder of the prompt budget up to 20 memories.

Memory providers ​

getMemoryProvider(env, settings) picks the backend behind remember / recall and the pre-compaction flush:

settings.memory.providerImplementation
local (default)LocalMemoryProvider — agent_state + Vectorize + memory_fts, with the Jev verifier
cloudflareCloudflareMemoryProvider — the Cloudflare Agent Memory binding (AGENT_MEMORY, private beta), one profile per agent. Falls back to local when the binding is absent.

The Cloudflare provider is a partner pipeline, so it is only used where the data-flow policy permits partner processing. Settings → Runtime in the dashboard switches the backend and the verifier.

Contact scoping ​

Memory is contact-scoped by default. The same person reaching the agent via Telegram on Monday and email on Tuesday hits the same memory bucket — memory:contact:{contact_id}:* keys are loaded for any conversation linked to that contact.

Agent-wide memories (memory:* without the contact: infix) exist for facts not tied to a specific person — e.g. business hours, operating preferences, identity of the agent itself.

Embedding details ​

SettingValue
Model@cf/baai/bge-base-en-v1.5
Vector indexshared agents-dashboard-embeddings (filtered by metadata.type = "memory")
Vector ID formatmem:{agentId}:{memory_key}
Confidence floor0.1 (after decay), default 0.5
Re-embed triggerevery write; idempotent on identical content

L4 — Reflective memory ​

Reflections write a row to agent_reflections and additionally produce reflective_memories — short distilled facts the agent wants to carry forward. These flow through the same L3 path but at higher confidence.

See Goals & Reflection for the full pipeline.

Consolidation ​

A daily cron at 04:00 UTC (one hour after reflection) runs consolidateOnePass:

  1. Decay. Memories not accessed in 30+ days have their confidence multiplied by 0.8 (floor 0.1).
  2. Sample. Up to 30 most-recent active memories.
  3. Cluster. For each, query Vectorize at cosine ≥ 0.85 to find near-duplicates.
  4. Merge. Clusters of size ≥ 2 go to glm-4.7-flash with a "merge these into one canonical statement" prompt.
  5. Supersede. Members are marked superseded_by the new canonical key. Old rows are kept for audit; they're filtered out of recall paths.
  6. Cap. At most 5 clusters merged per pass to keep the cron cheap.

Result: 10 turns about "user lives in Singapore" become one memory instead of ten.

Tools ​

ToolEffect
remember(key, value, category?)Explicit write. Contact-scoped if a contact is in the conversation context; agent-wide otherwise. Confidence 0.9 (user-explicit).
recall(key?)Read by key (or list all). Tries contact-scoped first, then agent-wide. Bumps access_count.
recall_conversation(query, mode?, limit?, role?, scope?)Semantic or keyword search over chat_messages. Scope: contact (default, all gateways for that contact), conversation, or all.
set_context(notes, mode?)Per-conversation working notes (replace / append / clear), shown back to the model at the start of every turn. Not a memory — see Chat.
set_ongoing_task / get_ongoing_task / clear_ongoing_taskMulti-turn task tracking, scoped to the conversation. See Tasks.

L2 — Episodic memory ​

Plain chat_messages rows. Messages are never deleted or rewritten; what changes is how much of them the model sees.

Compaction overlays ​

compaction.ts splits the history token budget three ways:

ShareContent
70 %Tail — the most recent messages, verbatim
10 %Head — the conversation's first two messages, verbatim (a row that does not fit is cut, not dropped)
20 %Overlays — summaries bound to a message range (conversation_overlays: from_created_at … to_created_at, message_count, summary, tokens), rendered in order between head and tail

When a stretch between head and tail is not yet covered by an overlay, the oldest uncovered range (3–60 messages) is summarised in the background and stored as a new overlay. The pre-compaction memory flush (above) runs on the same event. The older single-blob summary (conversation_summary:{id}, archived to conversation_summary_history:{id}:{timestamp} on each rewrite) is kept as a fallback.

History is still fetched in pages of 10 rows, newest first, stopping when estimateTokens exceeds the budget (hard cap 200 rows scanned).

L1 — Working memory ​

In-DO RAM plus two small persisted helpers:

  • Turn checkpoints (turn-checkpoint.ts). After every tool round, both chat loops write a trimmed record of the turn — tool calls, tool-result previews (3 000 chars each, at most 40 rows), browser session ids — to the Durable Object's own storage under turn:{turnId}. It is deleted when the assistant message is saved. If the object is evicted or a deploy lands mid-turn, the next request in that conversation finds a turn untouched for more than 45 s and prepends its completed results to the model input instead of re-running the tools (and tells the model to reuse the open browser sessions). Checkpoints expire after 24 h.
  • Working notes (set_context). Per-conversation scratchpad text the model keeps for itself; see Chat.

Everything else — tool results in flight, transient state — is lost on DO eviction by design.

Token estimation ​

estimateTokens is content-aware (see index.ts for the heuristic):

Content typeDetectionRatio
CJK-heavy>30% Han/Hiragana/Katakana/Hangul chars1.5 chars/token
JSON / code-dense>8% { } [ ] : " chars3.0 chars/token
English prose (default)everything else3.5 chars/token

This drives both history trimming (so the budget is realistic) and trackTokenUsage (so billing matches the provider invoice).

Schema ​

See migrations:

  • 060_memory_embeddings.sql — registry table bridging keys to Vectorize IDs
  • 075_conversation_overlays.sql — range-bound compaction summaries
  • 076_memory_fts.sql — FTS5 keyword index over memory:* values (backfilled on apply)