Skip to content

09 Memory System

Nikolay Vyahhi edited this page Feb 19, 2026 · 3 revisions

Memory System

Relevant source files

The following files were used as context for generating this wiki page:

Purpose and Scope

This page documents ZeroClaw's memory system — the persistent storage layer that allows agents to recall and save information across conversations. The Memory trait provides a pluggable architecture where different storage backends can be swapped through configuration.

For detailed comparison of specific backend implementations, see Memory Backends. For the technical details of hybrid search (vector + keyword), see Hybrid Search. For the export/import system that preserves core memories in Git-visible Markdown, see Memory Snapshot.

Architecture Overview

The memory system is a trait-driven abstraction that decouples the agent's recall/storage logic from the underlying persistence mechanism. Every backend implements the same Memory trait, allowing zero-code-change swaps between SQLite, PostgreSQL, Lucid, Markdown files, or a no-op backend.

graph TB
    Agent["Agent Core<br/>loop_.rs"]
    MemoryTrait["Memory Trait<br/>src/memory/traits.rs"]
    
    subgraph "Backend Implementations"
        SQLite["SqliteMemory<br/>Full hybrid search<br/>brain.db"]
        Postgres["PostgresMemory<br/>Remote storage"]
        Lucid["LucidMemory<br/>Lucid CLI bridge"]
        Markdown["MarkdownMemory<br/>workspace/*.md"]
        None["NoneMemory<br/>No persistence"]
    end
    
    subgraph "SQLite Subsystems"
        FTS5["FTS5 Virtual Table<br/>memories_fts<br/>BM25 keyword search"]
        VectorDB["Embedding BLOB<br/>memories.embedding<br/>Cosine similarity"]
        Cache["Embedding Cache<br/>embedding_cache<br/>LRU eviction"]
        Snapshot["Memory Snapshot<br/>MEMORY_SNAPSHOT.md<br/>Git-visible soul"]
    end
    
    Agent -->|"recall(query)<br/>store(key, content)"| MemoryTrait
    
    MemoryTrait --> SQLite
    MemoryTrait --> Postgres
    MemoryTrait --> Lucid
    MemoryTrait --> Markdown
    MemoryTrait --> None
    
    SQLite --> FTS5
    SQLite --> VectorDB
    SQLite --> Cache
    SQLite --> Snapshot
    
    FTS5 -.->|"Hybrid merge<br/>weighted scoring"| Agent
    VectorDB -.->|"Hybrid merge<br/>weighted scoring"| Agent
Loading

Key Design Principles:

  1. Zero External Dependencies: No Pinecone, no Elasticsearch, no LangChain. The SQLite backend implements a full-stack search engine (vector DB + keyword search + hybrid merge) in pure Rust with only rusqlite as a dependency.

  2. Trait-Driven Pluggability: backend = "sqlite" in config.toml instantly switches to a different implementation. No code changes, no recompilation.

  3. Automatic Context Enrichment: The agent automatically calls recall() before each LLM request to inject relevant historical context into the prompt.

  4. Atomic Soul Export: Core memories are continuously exported to MEMORY_SNAPSHOT.md so the agent's "soul" is always Git-visible and survives database loss.

Sources: README.md:330-343, src/memory/snapshot.rs:1-8

The Memory Trait Interface

The Memory trait defines five core operations that all backends must implement:

Method Purpose Example
recall(query, limit) Semantic search for relevant memories Agent recalls context before LLM request
store(key, content, category) Persist a new memory or update existing Agent stores user preferences, facts
forget(key) Delete a memory by key User requests data deletion
list_keys() Enumerate all stored keys Debugging, memory management tools
get(key) Retrieve exact memory by key Direct lookup for known keys

Trait Contract:

#[async_trait]
pub trait Memory: Send + Sync {
    async fn recall(&self, query: &str, limit: usize) -> Result<Vec<MemoryEntry>>;
    async fn store(&self, key: &str, content: &str, category: MemoryCategory) -> Result<()>;
    async fn forget(&self, key: &str) -> Result<bool>;
    async fn list_keys(&self) -> Result<Vec<String>>;
    async fn get(&self, key: &str) -> Result<Option<MemoryEntry>>;
}

The MemoryEntry struct contains:

  • key: Unique identifier (e.g., "user_preference_lang", "fact_rust_memory_safety")
  • content: The actual memory text (plain text or markdown)
  • category: Enum of Core, Conversation, Fact, UserPreference, Other
  • similarity_score: Optional relevance score from hybrid search (0.0-1.0)

Sources: Referenced from architecture diagrams and README.md memory configuration

Backend Selection and Configuration

Backends are selected via [memory] section in config.toml:

[memory]
backend = "sqlite"              # "sqlite", "postgres", "lucid", "markdown", "none"
auto_save = true                # Agent automatically stores important facts
embedding_provider = "none"     # "none", "openai", "custom:https://..."
vector_weight = 0.7             # Hybrid search: vector importance
keyword_weight = 0.3            # Hybrid search: keyword importance

Backend Dispatch Logic:

flowchart TB
    Start["Config::load()"]
    ReadBackend["Read memory.backend"]
    
    Decision{"backend value?"}
    
    CreateSQLite["SqliteMemory::new()<br/>Path: workspace/memory/brain.db"]
    CreatePostgres["PostgresMemory::new()<br/>storage.provider.config.db_url"]
    CreateLucid["LucidMemory::new()<br/>Spawns lucid CLI subprocess"]
    CreateMarkdown["MarkdownMemory::new()<br/>Reads workspace/*.md"]
    CreateNone["NoneMemory::new()<br/>No-op stub"]
    
    CheckHydrate{"brain.db missing<br/>but MEMORY_SNAPSHOT.md<br/>exists?"}
    Hydrate["hydrate_from_snapshot()<br/>Import from Markdown"]
    
    Start --> ReadBackend
    ReadBackend --> Decision
    
    Decision -->|"'sqlite'"| CheckHydrate
    Decision -->|"'postgres'"| CreatePostgres
    Decision -->|"'lucid'"| CreateLucid
    Decision -->|"'markdown'"| CreateMarkdown
    Decision -->|"'none'"| CreateNone
    
    CheckHydrate -->|Yes| Hydrate
    CheckHydrate -->|No| CreateSQLite
    Hydrate --> CreateSQLite
Loading

For PostgreSQL remote storage, add:

[storage.provider.config]
provider = "postgres"
db_url = "postgres://user:password@host:5432/zeroclaw"
schema = "public"
table = "memories"
connect_timeout_secs = 15

Sources: README.md:346-376

SQLite Hybrid Search Architecture

The SQLite backend implements a dual-index hybrid search system that combines vector similarity (semantic) with keyword matching (lexical) for optimal recall:

graph TB
    Query["User Query:<br/>'What did I say about Rust?'"]
    
    subgraph "Dual Search Paths"
        VectorPath["Vector Search Path"]
        KeywordPath["Keyword Search Path"]
    end
    
    subgraph "Vector Pipeline"
        Embed["Embed query via<br/>EmbeddingProvider"]
        CheckCache["Check embedding_cache<br/>for cached embeddings"]
        Cosine["Cosine similarity<br/>against memories.embedding"]
    end
    
    subgraph "Keyword Pipeline"
        FTS["FTS5 query on<br/>memories_fts"]
        BM25["BM25 scoring"]
    end
    
    Merge["Weighted Merge<br/>vector_weight * vec_score +<br/>keyword_weight * kw_score"]
    Sort["Sort by combined score"]
    Return["Return top N entries"]
    
    Query --> VectorPath
    Query --> KeywordPath
    
    VectorPath --> Embed
    Embed --> CheckCache
    CheckCache --> Cosine
    
    KeywordPath --> FTS
    FTS --> BM25
    
    Cosine --> Merge
    BM25 --> Merge
    
    Merge --> Sort
    Sort --> Return
Loading

Why Hybrid?

  • Vector search handles synonyms and semantic similarity ("Rust memory safety" matches "zero-cost abstractions")
  • Keyword search handles exact terms and rare words ("tokio::spawn" matches code snippets)
  • Weighted merge balances both approaches (default: 70% vector, 30% keyword)

The embedding cache prevents redundant API calls for repeated queries — cached embeddings are stored as (content_hash, embedding, created_at) with LRU eviction.

Sources: README.md:330-343, src/memory/snapshot.rs:117-140

Memory Categories

Memories are tagged with categories to organize information and control export behavior:

Category Purpose Exported to Snapshot?
Core Agent identity, core values, user instructions ✅ Yes
UserPreference User preferences (language, style, tools) ✅ Yes
Fact Long-term factual knowledge ✅ Yes
Conversation Dialogue history, ephemeral context ❌ No
Other Uncategorized memories ❌ No

Only Core category memories are exported to MEMORY_SNAPSHOT.md (the agent's "soul"). This ensures Git commits remain small and focused on identity/preferences rather than full conversation logs.

graph LR
    Agent["Agent stores memory<br/>with category tag"]
    
    Core["Core:<br/>Identity, values, rules<br/>→ MEMORY_SNAPSHOT.md"]
    UserPref["UserPreference:<br/>Language, style<br/>→ MEMORY_SNAPSHOT.md"]
    Fact["Fact:<br/>Learned knowledge<br/>→ MEMORY_SNAPSHOT.md"]
    Conv["Conversation:<br/>Ephemeral dialogue<br/>→ brain.db only"]
    Other["Other:<br/>Uncategorized<br/>→ brain.db only"]
    
    Agent --> Core
    Agent --> UserPref
    Agent --> Fact
    Agent --> Conv
    Agent --> Other
Loading

Sources: src/memory/snapshot.rs:37-56

Auto-Save and Recall Workflow

When auto_save = true, the agent automatically integrates memory into the turn cycle:

sequenceDiagram
    participant User
    participant Agent as Agent Core
    participant Memory as Memory Backend
    participant LLM as Provider
    
    User->>Agent: Send message
    
    Agent->>Memory: recall(user_message, limit=5)
    Memory-->>Agent: [Relevant memories with scores]
    
    Agent->>Agent: Build system prompt<br/>+ inject recalled context
    
    Agent->>LLM: chat(messages, tools)
    LLM-->>Agent: Response + tool calls
    
    opt Auto-save important facts
        Agent->>Memory: store(key, content, category)
        Memory-->>Agent: OK
    end
    
    Agent->>Memory: Auto-export snapshot<br/>if Core memories changed
    Memory-->>Agent: Exported N entries
    
    Agent-->>User: Final response
Loading

Recall Timing: Before every LLM request, the agent calls recall() with the user's message as the query. The top-N relevant memories are injected into the system prompt as context.

Store Timing: The agent uses memory tools (memory_store, memory_recall, memory_forget) to explicitly manage memories. Additionally, the backend may auto-save certain categories (configured per-backend).

Sources: Referenced from agent workflow in high-level diagrams

Memory Snapshot System

The snapshot system provides disaster recovery and Git visibility for core memories:

flowchart TB
    Start["Agent starts"]
    CheckDB{"brain.db exists<br/>and populated?"}
    CheckSnapshot{"MEMORY_SNAPSHOT.md<br/>exists?"}
    
    Hydrate["hydrate_from_snapshot()<br/>Parse Markdown → SQLite"]
    NormalBoot["Normal boot<br/>Use existing brain.db"]
    
    Export["On shutdown or<br/>periodic timer"]
    ExportSnapshot["export_snapshot()<br/>SELECT * WHERE category='core'<br/>→ MEMORY_SNAPSHOT.md"]
    
    Start --> CheckDB
    CheckDB -->|No| CheckSnapshot
    CheckDB -->|Yes| NormalBoot
    
    CheckSnapshot -->|Yes| Hydrate
    CheckSnapshot -->|No| NormalBoot
    
    Hydrate --> NormalBoot
    NormalBoot --> Export
    Export --> ExportSnapshot
Loading

File Format:

# 🧠 ZeroClaw Memory Snapshot

> Auto-generated. Do not edit manually unless you know what you're doing.
> This file is the "soul" of your agent.

**Last exported:** 2025-01-15 14:30:00
**Total core memories:** 3

---

### 🔑 `identity`

I am ZeroClaw, a self-preserving AI agent built with Rust.

*Created: 2025-01-15 | Updated: 2025-01-15*

---

### 🔑 `user_preference_lang`

The user prefers Rust for systems programming.

*Created: 2025-01-14 | Updated: 2025-01-15*

---

Auto-Hydration: If brain.db is missing or empty but MEMORY_SNAPSHOT.md exists, the agent automatically re-indexes all entries back into a fresh SQLite database on startup. This "atomic soul recovery" ensures the agent never loses its identity.

See Memory Snapshot for implementation details.

Sources: src/memory/snapshot.rs:1-90, src/memory/snapshot.rs:180-199

Embedding Providers

Vector search requires embeddings (dense numerical representations of text). ZeroClaw supports:

Provider Configuration Use Case
none embedding_provider = "none" No vector search (keyword-only)
openai embedding_provider = "openai"
OPENAI_API_KEY
Production deployments
custom embedding_provider = "custom:https://..." Self-hosted embedding models

Embedding Cache:

The SQLite backend includes a transparent cache layer:

CREATE TABLE IF NOT EXISTS embedding_cache (
    content_hash TEXT PRIMARY KEY,
    embedding    BLOB NOT NULL,
    created_at   TEXT NOT NULL
);

When the same query is recalled multiple times, the embedding is fetched from the cache rather than calling the API again. This dramatically reduces latency and API costs for repeated searches.

Sources: README.md:346-376, src/memory/snapshot.rs:136-139

Backend Comparison Summary

Feature SQLite PostgreSQL Lucid Markdown None
Hybrid Search ✅ Full ❌ No ✅ Via CLI ❌ No ❌ No
Vector DB ✅ Embedded ✅ pgvector ✅ External ❌ No ❌ No
Keyword Search ✅ FTS5 ✅ SQL LIKE ✅ External ✅ grep ❌ No
Multi-User ❌ Single ✅ Yes ✅ Yes ❌ Single ❌ No
Git-Friendly ⚠️ Binary ❌ Remote ❌ Remote ✅ Text ✅ N/A
Snapshot Export ✅ Yes ❌ No ❌ No ✅ Native ❌ N/A
Setup Complexity Low Medium High Low None

For detailed analysis and usage guidelines for each backend, see Memory Backends.

Sources: README.md:330-376

Configuration Examples

Full-Stack SQLite (Default)

[memory]
backend = "sqlite"
auto_save = true
embedding_provider = "openai"
vector_weight = 0.7
keyword_weight = 0.3
sqlite_open_timeout_secs = 30  # Optional: handle locked database

Remote PostgreSQL

[memory]
backend = "postgres"
auto_save = true
embedding_provider = "openai"

[storage.provider.config]
provider = "postgres"
db_url = "postgres://user:password@host:5432/zeroclaw"
schema = "public"
table = "memories"
connect_timeout_secs = 15

Lucid CLI Bridge

[memory]
backend = "lucid"
auto_save = true

# Optional environment variables:
# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid
# ZEROCLAW_LUCID_BUDGET=200
# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120
# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800

Markdown Files (Git-Native)

[memory]
backend = "markdown"
auto_save = true
# Reads/writes workspace/*.md files directly

No Persistence

[memory]
backend = "none"
auto_save = false
# All recall() calls return empty results
# All store() calls are no-ops

Sources: README.md:346-376


Next Steps:


Clone this wiki locally