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
.umcfile 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f73e57a3d8aac140a2254bc7398b0bb2bafe1213921d43e6ea5744177ae15ff0
|
|
| MD5 |
12d94be82dcaa74b125c2c7908c4e7ca
|
|
| BLAKE2b-256 |
40d3201e8524409aba8f5a72919867b0fbec86a6d9b557c67a63e770adc51adb
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4d7c7b18c22ee9f84075eb32b3e8a9aa33bb46976718ad1f2d815a2593d8887e
|
|
| MD5 |
139436934f3235afeceded504bbb08b3
|
|
| BLAKE2b-256 |
ed90f3f3448c212c083a95637018b3303603f84900a5cb8d2725b621d7911511
|