Metadata-Version: 2.4
Name: echodb
Version: 1.0.0
Summary: Persistent temporal memory layer for LLM conversational agents
Project-URL: Homepage, https://github.com/ShivanshTyagi9/echo
Project-URL: Repository, https://github.com/ShivanshTyagi9/echo
Project-URL: Issues, https://github.com/ShivanshTyagi9/echo/issues
Author-email: Shivansh Tyagi <shivelite2003@gmail.com>
License: MIT License
        
        Copyright (c) [2026] [Shivansh Tyagi]
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: agents,ai,llm,memory,nlp,rag
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Requires-Dist: numpy>=1.24
Requires-Dist: openai==2.29.0
Requires-Dist: python-dotenv
Requires-Dist: pyyaml>=6.0
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.20; extra == 'anthropic'
Description-Content-Type: text/markdown

# 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](https://github.com/ggerganov/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

```bash
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:

```bash
# 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) |

```python
from brain import LlamaCppClient, LlamaCppEmbedder
from echodb import UnifiedMemoryLayer

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

### OpenAI

```bash
pip install echodb openai
export OPENAI_API_KEY="sk-..."
```

```python
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:

```python
# 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

```bash
pip install echodb anthropic openai
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."      # for embeddings
```

```python
from brain import AnthropicClient, OpenAIEmbedder
from echodb import UnifiedMemoryLayer

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

### Ollama

```python
from brain import OllamaClient, LlamaCppEmbedder
from echodb import UnifiedMemoryLayer

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

---

## Quickstart

```python
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.

```python
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

```python
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

```python
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.

```python
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

```python
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.

```python
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.

```python
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

```python
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

```python
# 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

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

---

### `stats()` — memory statistics

```python
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.

```python
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:

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

---

### `decay(threshold)` — prune weak memories

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

---

### `get_pending_tasks()` — list tasks extracted from conversation

```python
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.

```python
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.

```python
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.

```python
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

```python
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 |
