Skip to main content

Persistent temporal memory layer for LLM conversational agents

Project description

echodb

A persistent, temporal memory layer for LLM-powered conversational agents. Drop it into any chat loop and your assistant will remember facts, track how they change over time, extract structured knowledge from conversations, and retrieve the right context at the right moment.

Runs fully locally with llama.cpp, or connects to OpenAI / Anthropic with a single line change.

pip install echodb

What it gives your agent

Without memory, every conversation starts from zero. With echodb:

  • Structured recall — the agent knows not just that the user mentioned something, but what kind of thing it was: a fact, a preference, a task, a technical procedure, or an episode
  • Temporal awareness — when a fact changes ("I moved to New York"), the old fact is preserved with timestamps and a new version is created. The agent always retrieves the latest version but can trace the history
  • Automatic entity linking — "Alex", "he", and "the user" resolve to the same person. Related concepts (person → project → technology) are wired as graph edges and retrieved together
  • Decay — episodic memories fade if never recalled again; stable facts and procedures persist indefinitely
  • Portable snapshots — save the entire memory state to a single .umc file and restore it anywhere, instantly

Installation

pip install echodb

Runtime requirements:

Requirement Notes
Python ≥ 3.11 Required for modern type hints
numpy Auto-installed
pyyaml Auto-installed
LLM backend llama.cpp server or OpenAI/Anthropic API key
Embedder llama.cpp embedding server or OpenAI embedding API

Backends

llama.cpp — fully local, no API key

Best for privacy and offline use. You need two server processes running:

# Terminal 1 — LLM server (knowledge extraction + chat)
llama-server \
  --model Qwen2.5-7B-Instruct-Q4_K_M.gguf \
  --port 8080 \
  --ctx-size 8192

# Terminal 2 — Embedding server (semantic search)
llama-server \
  --model nomic-embed-text-v1.5.Q4_K_M.gguf \
  --port 8081 \
  --embedding \
  --ctx-size 2048

Recommended model pairs for different hardware:

RAM available LLM Embedder
~6 GB Qwen2.5-7B-Instruct Q4_K_M (4.7 GB) nomic-embed-text-v1.5 Q4 (270 MB)
~3 GB Phi-3.5-mini-instruct Q4_K_M (2.2 GB) all-MiniLM-L6-v2 Q8 (45 MB)
~8 GB Llama-3.1-8B-Instruct Q4_K_M (4.9 GB) mxbai-embed-large-v1 Q4 (670 MB)
from brain import LlamaCppClient, LlamaCppEmbedder
from echodb import UnifiedMemoryLayer

mem = UnifiedMemoryLayer(
    llm_client = LlamaCppClient("http://localhost:8080"),
    embedder   = LlamaCppEmbedder("http://localhost:8081"),
)

OpenAI

pip install echodb openai
export OPENAI_API_KEY="sk-..."
from brain import OpenAIClient, OpenAIEmbedder
from echodb import UnifiedMemoryLayer

mem = UnifiedMemoryLayer(
    llm_client = OpenAIClient(model="gpt-4o-mini"),
    embedder   = OpenAIEmbedder(),               # text-embedding-3-small
)

Also works with any OpenAI-compatible endpoint:

# Together AI
OpenAIClient(
    api_key  = "...",
    base_url = "https://api.together.xyz/v1",
    model    = "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
)

# Groq
OpenAIClient(
    api_key  = "...",
    base_url = "https://api.groq.com/openai/v1",
    model    = "llama-3.1-8b-instant",
)

Anthropic

pip install echodb anthropic openai
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."      # for embeddings
from brain import AnthropicClient, OpenAIEmbedder
from echodb import UnifiedMemoryLayer

mem = UnifiedMemoryLayer(
    llm_client = AnthropicClient(api_key="sk-ant-..."),
    embedder   = OpenAIEmbedder(),
)

Ollama

from brain import OllamaClient, LlamaCppEmbedder
from echodb import UnifiedMemoryLayer

mem = UnifiedMemoryLayer(
    llm_client = OllamaClient(model="llama3.2"),
    embedder   = LlamaCppEmbedder("http://localhost:8081"),
)

Quickstart

from brain import OpenAIClient, OpenAIEmbedder
from echodb import UnifiedMemoryLayer

# Create memory — vault_path is optional (writes human-readable .md files)
mem = UnifiedMemoryLayer(
    llm_client = OpenAIClient(),
    embedder   = OpenAIEmbedder(),
    name       = "my_agent",
)

# Store a conversation turn
mem.remember([
    {"role": "user",      "content": "Hi, I'm Priya. I work at DeepMind on RL research."},
    {"role": "assistant", "content": "Nice to meet you, Priya!"},
    {"role": "user",      "content": "I'm building a reward shaping library in PyTorch."},
])

# Retrieve relevant context for the next turn
context = mem.recall("What is Priya working on?")

# Use it directly in your system prompt
system = f"You are a helpful assistant.\n\n{context}"

Integrating into a chat loop

This is the primary pattern. Memory is read before every reply and written after every turn.

from brain import OpenAIClient, OpenAIEmbedder
from echodb import UnifiedMemoryLayer

llm = OpenAIClient(model="gpt-4o-mini")
mem = UnifiedMemoryLayer(
    llm_client = llm,
    embedder   = OpenAIEmbedder(),
    name       = "chat_session",
)

history = []

def chat(user_message: str) -> str:
    # 1. Retrieve relevant context BEFORE answering
    context = mem.recall(user_message, top_k=5)

    system = "You are a helpful assistant with persistent memory. " \
             "Use the context naturally — don't announce that you remember."
    if context:
        system += f"\n\n{context}"

    # 2. Add to history and get LLM reply
    history.append({"role": "user", "content": user_message})
    reply = llm.chat(history[-12:], system=system)
    history.append({"role": "assistant", "content": reply})

    # 3. Store the last 6 turns into memory after replying
    mem.remember(history[-6:])

    return reply

# Normal conversation
print(chat("I'm building a fraud detection system with Kafka and FastAPI."))
print(chat("The main bottleneck is vector DB query latency."))
print(chat("I pinned the embedding model to VRAM — dropped from 4s to 200ms."))
print(chat("What was causing my latency issue again?"))  # Memory retrieves the fix

# Save when you decide — not on every turn
mem.snapshot("fraud_project_session.umc")

Loading a previous session

mem = UnifiedMemoryLayer.load(
    "fraud_project_session.umc",
    llm_client = OpenAIClient(),
    embedder   = OpenAIEmbedder(),
)

# Pick up where you left off — full context immediately available
print(chat("What were the Kafka config values I used?"))

Core API

UnifiedMemoryLayer(...) — constructor

mem = UnifiedMemoryLayer(
    llm_client = llm,        # LLMClient instance — required
    embedder   = embedder,   # Embedder with .embed() method — required
    vault_path = "./vault",  # Optional: also write human-readable .md files
    name       = "memory",   # Label used in snapshot filenames and logs
    verbose    = False,      # Print ingest details to stderr
)

remember(conversation) — store knowledge

Extracts structured knowledge from a conversation and stores it in the memory graph.

result = mem.remember([
    {"role": "user",      "content": "I moved from Seattle to New York last week."},
    {"role": "assistant", "content": "Big change! Still working remotely?"},
    {"role": "user",      "content": "Yes, still at Stripe. Also need to finish my Q4 report by Friday."},
])

print(result)
# {
#   "nodes_created": 4,
#   "node_types":    ["fact", "episode", "task"],
#   "entities":      ["Priya", "Stripe", "New York"],
#   "relations":     2,
#   "knowledge":     0,
#   "episodes":      1,
#   "procedures":    0,
# }

What happens internally:

  • The LLM extracts entities, facts, relations, knowledge artifacts, episodes, and tasks
  • Identity normalisation merges "User" / "I" / "me" into the known real name
  • Each fact is checked for duplication (cosine ≥ 0.92) — duplicates reinforce the existing node
  • If a fact contradicts an existing one, an UPDATES temporal chain is created
  • Technology stubs with no independent facts are collapsed into their parent entity

remember_text(text) — single string shortcut

mem.remember_text("Priya prefers asynchronous communication over meetings.")

recall(query) — retrieve a context string

Returns a formatted string ready to inject into a system prompt.

context = mem.recall(
    query       = "What is Priya's current project?",
    top_k       = 5,    # max memory nodes (default: 5)
    graph_depth = 2,    # hops through entity relations (default: 2)
)
# Returns a string like:
# ### Knowledge Base
# # Skills & Expertise
# - uses PyTorch for reward shaping research
#
# ### Memories
# - Priya: building a reward shaping library in PyTorch
# - Priya works_at DeepMind (RL research)

recall_full(query) — rich retrieval dict

Use when you need more than the context string — scores, individual nodes, KB sections.

result = mem.recall_full(
    query        = "What is Priya working on?",
    top_k        = 5,
    graph_depth  = 2,
    include_kb   = True,    # inject KB sections (default: True)
    min_score    = 0.0,     # minimum hybrid score floor
)

# result keys:
# "memories"       → list[str]          — content of top-k nodes
# "nodes"          → list[MemoryNode]   — node objects for inspection
# "scores"         → list[(str, float)] — (node_id, hybrid_score)
# "kb_sections"    → list[(str, str)]   — (section_name, markdown_content)
# "entity_context" → list[str]          — entity relation strings
# "context_block"  → str                — assembled string for prompt injection

get_entity_memory(name) — look up a person or entity

em = mem.get_entity_memory("Priya", include_history=True)

print(em["found"])    # True
print(em["nodes"])    # list of MemoryNode objects

# With include_history=True, also returns version timeline:
for entry in em["timeline"]:
    print(f"v{entry['version']}: {entry['content']}")
    print(f"  believed from {entry['believed_from']} until {entry['believed_until']}")
    print(f"  ({entry['belief_duration']}) — reason: {entry['update_reason']}")

snapshot(path) — save to a portable archive

# Saves to "{name}.umc" by default
path = mem.snapshot()

# Custom path
path = mem.snapshot("sessions/2024_q4.umc")
print(f"Saved to {path}")

The .umc file is a ZIP containing the full graph, all embeddings, and KB sections. It restores instantly with no warm-up — embeddings are baked in.


load(path, ...) — restore from archive

mem = UnifiedMemoryLayer.load(
    "sessions/2024_q4.umc",
    llm_client = OpenAIClient(),
    embedder   = OpenAIEmbedder(),
    vault_path = "./vault",   # optional — resume vault writing
)

stats() — memory statistics

s = mem.stats()
# {
#   "name":          "chat_session",
#   "ingest_count":  12,
#   "total_nodes":   87,
#   "latest_nodes":  71,
#   "superseded":    16,
#   "by_type":       {"fact": 52, "episode": 18, "procedural": 9, "task": 3, "derived": 5},
#   "total_edges":   134,
#   "vector_count":  87,
#   "kb_sections":   ["facts", "skills", "events"],
#   "entity_count":  9,
# }

consolidate() — synthesise episodes into derived facts

Run this periodically to keep memory clean and the KB sections fresh.

n = mem.consolidate(
    min_episodes   = 3,     # min episodes per branch before synthesising
    regenerate_kb  = True,  # also regenerate all 7 KB sections
    verbose        = True,
)
print(f"{n} derived facts created")

A good pattern: consolidate + decay + snapshot at the end of a long session:

mem.consolidate(min_episodes=3, regenerate_kb=True)
mem.decay(threshold=0.05)
mem.snapshot("end_of_session.umc")

decay(threshold) — prune weak memories

removed = mem.decay(threshold=0.05, verbose=True)
print(f"Pruned {removed} nodes")

get_pending_tasks() — list tasks extracted from conversation

tasks = mem.get_pending_tasks()
for t in tasks:
    due = f" — due {t.due_date}" if t.due_date else ""
    print(f"□ {t.task_title or t.content[:60]}{due}")

# Mark done:
mem.graph.mark_task_done(tasks[0].id)

# Mark cancelled:
mem.graph.mark_task_cancelled(tasks[0].id)

visualize() — D3 graph explorer

Generates a standalone HTML file. Open it in any browser — no server needed.

path = mem.visualize("memory_graph.html", open_browser=True)

The explorer shows:

  • Nodes coloured by memory type (fact / preference / episode / procedural / task / derived)
  • Node size proportional to current memory strength
  • Temporal chain edges in red, EXTENDS in blue, DERIVES in purple
  • Superseded (old) nodes shown faded with a dashed border
  • Click any node to see full content, creation time, and version timeline

get_socket() — multi-agent memory sharing

Multiple agents share one live memory graph. When agent A ingests, agent B sees it immediately.

from brain.capsule.socket import AccessMode

socket = mem.get_socket("shared-project")

researcher = socket.connect("researcher", mode=AccessMode.READ_WRITE)
summariser = socket.connect("summariser", mode=AccessMode.READ_ONLY)
loader     = socket.connect("loader",     mode=AccessMode.WRITE_ONLY)

# Agent writes — others see it immediately (same in-memory object)
researcher.ingest("The API passed load testing at 10k req/s.")
results = summariser.query("load test results", top_k=3)

# Save shared state
socket.export("shared.umc")

Vault files (Obsidian integration)

Pass vault_path to write human-readable Markdown files alongside the graph.

mem = UnifiedMemoryLayer(
    llm_client = OpenAIClient(),
    embedder   = OpenAIEmbedder(),
    vault_path = "./my_vault",
)

Files written per entity:

my_vault/
├── Priya.md              ← ## Facts, ## Relations, ## Knowledge
├── DeepMind.md
├── Reward Shaping.md
├── .episodes.json        ← episodic memories
├── .procedures.json      ← procedural workflows
└── demo_raw_memories.md  ← append-only extraction log

Each .md is valid Obsidian Markdown with YAML frontmatter and [[wikilinks]]. You can open the folder in Obsidian and browse or edit the memory directly.


Memory types

Type Extracted from Decay rate Base salience Notes
fact Stable assertions: job, name, location Very slow 0.90 Persists for months
preference Likes, dislikes, habits Slow 0.85
episode Specific events that happened Fast 0.70 Fades if never recalled
procedural Step-by-step workflows, technical solutions Near-zero 0.95 Almost never decays
task Action items, reminders, deadlines Slow 0.95 High urgency salience
derived Synthesised from multiple episodes Medium 0.80 Created by consolidate()

Memory strength formula:

strength = clamp(salience × e^(−rate × elapsed_seconds) + 0.025 × access_count, 0, 1)

Retrieval touches a node (boosts access_count), which counteracts decay — frequently retrieved memories stay strong.


Files produced

File Created by Contents
{name}.umc mem.snapshot() Full graph + embeddings + KB (ZIP archive)
{name}_raw_memories.md Every remember() call Append-only log of all extracted nodes
{vault_path}/*.md Every remember() (if vault_path set) Human-readable entity files
{vault_path}/.episodes.json Every remember() Episodic memory store
{vault_path}/.procedures.json Every remember() Procedural memory store
*.html mem.visualize() Standalone D3 graph explorer

Full constructor reference

UnifiedMemoryLayer(
    llm_client,          # LLMClient — required
    embedder,            # Embedder  — required
    vault_path = None,   # str | None — enable Obsidian vault writing
    name       = "memory",
    verbose    = False,
)

UnifiedMemoryLayer.load(
    path,                # str — path to .umc file
    llm_client,          # LLMClient — required
    embedder,            # Embedder  — required
    vault_path = None,
    verbose    = False,
)

Full method reference

Method Returns Description
remember(conversation) dict Extract and store from [{role, content}]
remember_text(text) dict Shortcut for a single string
recall(query, top_k, graph_depth) str Context string for prompt injection
recall_full(query, top_k, graph_depth, include_kb, min_score) dict Rich retrieval dict
get_entity_memory(name, include_history) dict All nodes for a named entity
get_pending_tasks() list[MemoryNode] Pending tasks sorted by due date
stats() dict Memory statistics
snapshot(path) str Save .umc archive, returns absolute path
load(path, llm_client, embedder, ...) UnifiedMemoryLayer Classmethod — restore from .umc
consolidate(min_episodes, regenerate_kb, verbose) int Episodes → derived facts
decay(threshold, verbose) int Prune weak nodes, returns count
visualize(output_path, open_browser) str Write D3 HTML, returns path
export_graph_json() dict Raw graph dict for custom visualisers
get_socket(name) MemorySocket Multi-agent memory sharing

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

echodb-1.0.0.tar.gz (88.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

echodb-1.0.0-py3-none-any.whl (89.3 kB view details)

Uploaded Python 3

File details

Details for the file echodb-1.0.0.tar.gz.

File metadata

  • Download URL: echodb-1.0.0.tar.gz
  • Upload date:
  • Size: 88.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for echodb-1.0.0.tar.gz
Algorithm Hash digest
SHA256 f73e57a3d8aac140a2254bc7398b0bb2bafe1213921d43e6ea5744177ae15ff0
MD5 12d94be82dcaa74b125c2c7908c4e7ca
BLAKE2b-256 40d3201e8524409aba8f5a72919867b0fbec86a6d9b557c67a63e770adc51adb

See more details on using hashes here.

File details

Details for the file echodb-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: echodb-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 89.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for echodb-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4d7c7b18c22ee9f84075eb32b3e8a9aa33bb46976718ad1f2d815a2593d8887e
MD5 139436934f3235afeceded504bbb08b3
BLAKE2b-256 ed90f3f3448c212c083a95637018b3303603f84900a5cb8d2725b621d7911511

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page