mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## Description
On Apple-Silicon Macs — especially fanless models like the MacBook Air
(M5) — running the proxy with memory context injection can pin the CPU
while embedding. The embedding work runs an uncapped session on the CPU,
saturating multiple cores, which starves the proxy's asyncio loop and
leads to request timeouts.
This PR adds an **opt-in** runtime that offloads the memory embedder to
the Apple GPU (MPS). Setting `HEADROOM_EMBEDDER_RUNTIME=pytorch_mps`
routes embedding through the torch `sentence-transformers` backend on
MPS instead of the default ONNX CPU embedder, moving the work off the
CPU and keeping the proxy responsive.
The default behavior is unchanged — the feature is strictly opt-in,
env-var only, and falls through to the existing default embedder
selection (with a warning) whenever MPS or the torch dependencies are
unavailable.
Fixes: N/A — no tracking issue (surfaced while running codex auto-review
through
the proxy on a fanless MacBook Air (M5)).
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **Runtime selection** (`headroom/proxy/memory_handler.py`): read
`HEADROOM_EMBEDDER_RUNTIME`; when set to `pytorch_mps` **and** MPS is
actually available, route the memory embedder to the torch
`sentence-transformers` backend (Apple GPU).
- If MPS is unavailable or torch/sentence-transformers is not installed,
log a warning and fall through to the existing default embedder
selection (ONNX when available, else the pre-existing local
sentence-transformers fallback) — no crash. The default (env var unset)
is unchanged. Env-var only
- **MPS serialization** (`headroom/memory/adapters/embedders.py`):
`LocalEmbedder` now funnels every `encode()` through a dedicated
single-worker `ThreadPoolExecutor` when the resolved device is MPS.
torch-MPS is not thread-safe, and the existing `run_in_executor(None,
...)` dispatch would otherwise let concurrent proxy requests call MPS
from multiple threads. CPU/CUDA keep the shared default executor
(behavior unchanged). `close()` also drops the cached model so re-use
after close re-initializes cleanly.
- **Packaging** (`pyproject.toml`): new `pytorch-mps` extra (`torch` +
`sentence-transformers`), **platform-gated to macOS** (`; sys_platform
== 'darwin'`) since MPS is Apple-Silicon-only. Deliberately left out of
`[all]` (its deps already arrive via `[ml]`/`[memory]`).
- **Tests** (`tests/test_memory/test_embedder_mps_serialization.py`):
regression coverage for the serialized executor, concurrency safety (no
SIGABRT), CPU-path default behavior, and close/re-use re-initialization.
- **Docs**: `wiki/{configuration,memory,macos-deployment}.md`,
`docs/content/docs/{configuration,installation,memory}.mdx`,
`README.md`, `CHANGELOG.md`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed (CPU-offload + concurrency profiling on
Apple Silicon)
## Test Output
```
$ pytest -v tests/test_memory/test_embedder_mps_serialization.py
collected 4 items
tests/test_memory/test_embedder_mps_serialization.py::test_cpu_uses_shared_executor PASSED [ 25%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_creates_single_worker_executor PASSED [ 50%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_concurrent_embeds_do_not_crash PASSED [ 75%]
tests/test_memory/test_embedder_mps_serialization.py::test_mps_reembed_after_close_recreates_executor PASSED [100%]
============================== 4 passed in 6.92s ===============================
$ ruff check headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py tests/test_memory/test_embedder_mps_serialization.py
All checks passed!
$ mypy headroom/memory/adapters/embedders.py headroom/proxy/memory_handler.py
Success: no issues found in 2 source files
$ pytest -q tests/test_memory/ tests/test_memory_handler_concurrent_init.py tests/test_memory_handler_native_ops.py
553 passed, 1 skipped in 13.51s
```
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
**Why MPS (and not CoreML or a thread cap):** measured on an
Apple-Silicon Mac, the default uncapped CPU embedding session saturates
the cores; the same model on MPS runs at a fraction of the CPU (≈8x
lower sustained CPU utilization in profiling) while producing
**byte-identical embeddings** (cosine distance ≈ 0 across runtimes), so
relevance/ranking is unchanged. A CoreML execution-provider path was
evaluated and rejected: the default optimized ONNX model uses fused ops
that fall back to CPU under CoreML (no offload), and a full-precision
re-export was impractical (very low throughput + multi-GB memory). MPS
via `sentence-transformers` was the only practical GPU offload.
**Why serialization is mandatory:** torch-MPS is not thread-safe —
concurrent encode calls from a multi-worker executor abort with
`-[IOGPUMetalCommandBuffer validate]: failed assertion 'commit an
already committed command buffer'` (reproduced deterministically; a
single-worker executor resolves it).
Under concurrent load the serialized single-GPU-stream throughput meets
or exceeds the parallel CPU path while using a fraction of the cores.
**Scope / boundary:** this targets the Python **memory** embedder, which
is live on the proxy request path (memory context injection). The
Rust-backed SmartCrusher compression path is unaffected and remains
non-configurable from Python by design.
**Safety:** default behavior is unchanged (ONNX, no torch). The feature
is opt-in, env-var only, macOS-gated at the packaging layer, and
degrades gracefully (warn + the existing default embedder selection)
when MPS or the dependencies are unavailable.
748 lines
26 KiB
Markdown
748 lines
26 KiB
Markdown
# Memory
|
|
|
|
**Hierarchical, temporal memory for LLM applications.** Enable your AI to remember across conversations with intelligent scoping and versioning.
|
|
|
|
## Why Memory?
|
|
|
|
LLMs have two fundamental limitations:
|
|
1. **Context windows overflow** - Too much history, need to truncate
|
|
2. **No persistence** - Every conversation starts from zero
|
|
|
|
Memory solves both: **extract key facts, persist them, inject when relevant.**
|
|
|
|
This is *temporal compression* - instead of carrying 10,000 tokens of conversation history, carry 100 tokens of extracted memories.
|
|
|
|
---
|
|
|
|
## What Makes Headroom Memory Different?
|
|
|
|
| Feature | Headroom | Letta (MemGPT) | Mem0 |
|
|
|---------|----------|----------------|------|
|
|
| **Cross-Agent Memory** | Any agent shares one DB via proxy | Per-agent only | Per-user, no cross-agent |
|
|
| **Agent Provenance** | Tracks which agent saved/updated each memory | No | No |
|
|
| **LLM-Mediated Dedup** | Piggybacks on user's own LLM for merge decisions | No | Separate LLM call ($) |
|
|
| **Transparent Proxy** | Zero code changes — just route through proxy | Requires agent framework | Requires SDK integration |
|
|
| **Hierarchical Scoping** | User → Session → Agent → Turn | Flat (per-agent) | Flat (per-user) |
|
|
| **Temporal Versioning** | Full supersession chains | No | No |
|
|
| **Zero-Latency Extraction** | Inline (Letta-style) | Inline | Separate call |
|
|
| **One-Liner Integration** | `with_memory(client)` | Requires agent setup | Requires separate client |
|
|
| **Pluggable Backends** | SQLite, HNSW, FTS5, any embedder | PostgreSQL | Qdrant/Chroma |
|
|
| **Semantic + Full-Text Search** | Both | Semantic only | Semantic only |
|
|
| **Memory Bubbling** | Auto-promote important memories | No | No |
|
|
| **Protocol-Based Architecture** | Yes (dependency injection) | No | No |
|
|
|
|
---
|
|
|
|
## Cross-Agent Memory (Proxy)
|
|
|
|
The most powerful way to use memory: **any agent that routes through the proxy shares the same memory store.** Claude saves a fact, Codex reads it back. Zero configuration needed.
|
|
|
|
```bash
|
|
# Start the proxy with memory enabled
|
|
headroom proxy --memory
|
|
|
|
# Or use wrap (auto-starts proxy)
|
|
headroom wrap claude --memory # Claude Code with persistent memory
|
|
headroom wrap codex --memory # Codex with the SAME memory store
|
|
headroom wrap aider --memory # Aider shares it too
|
|
```
|
|
|
|
### How It Works
|
|
|
|
```
|
|
Claude Code Codex CLI Gemini CLI
|
|
│ │ │
|
|
└── /v1/messages ──┐ └── /v1/chat/completions ──┤ └── /generateContent ──┐
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ Headroom Proxy (--memory) │
|
|
│ │
|
|
│ 1. Search memory DB for relevant context │
|
|
│ 2. Inject memories as system context (provider-native format) │
|
|
│ 3. Add memory_save/search/update/delete tools │
|
|
│ 4. Forward to upstream LLM │
|
|
│ 5. Handle memory tool calls in response │
|
|
│ 6. Async background dedup (>92% cosine → auto-remove) │
|
|
│ │
|
|
└──────────────────────┬───────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
.headroom/memory.db
|
|
(project-scoped SQLite)
|
|
```
|
|
|
|
### Project-Scoped Database
|
|
|
|
Memory is stored per-project at `{cwd}/.headroom/memory.db`. Each
|
|
project has its own memory — no cross-project contamination. Override
|
|
with `--memory-db-path` for a custom location.
|
|
|
|
> **Filesystem contract note.** Project-scoped memory paths resolve
|
|
> relative to the current working directory and **do not** obey the
|
|
> canonical `HEADROOM_WORKSPACE_DIR` env var. This preserves the
|
|
> project-memory isolation invariant. Users who want a single central
|
|
> memory store should pass `--memory-db-path` explicitly. See the
|
|
> [Filesystem Contract](filesystem-contract.md) for the rationale.
|
|
|
|
### User Identity
|
|
|
|
User ID is auto-detected from `$USER` (your OS username). Override per-request with the `x-headroom-user-id` header. All memories are scoped to the user — multiple developers on the same project have separate memory stores.
|
|
|
|
### Agent Provenance
|
|
|
|
Every memory tracks which agent created or updated it:
|
|
|
|
```json
|
|
{
|
|
"content": "Project uses alembic for migrations",
|
|
"metadata": {
|
|
"source_agent": "claude",
|
|
"source_provider": "anthropic",
|
|
"created_via": "tool_call",
|
|
"created_at_utc": "2026-04-10T17:30:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
When an agent updates a memory, the update is tracked:
|
|
|
|
```json
|
|
{
|
|
"reason": "Updated by codex via openai: Added version info"
|
|
}
|
|
```
|
|
|
|
### Intelligent Deduplication
|
|
|
|
When the LLM calls `memory_save`, headroom:
|
|
|
|
1. **Saves immediately** (zero latency)
|
|
2. **Searches for similar existing memories** (cosine similarity)
|
|
3. **Returns an enriched hint** if duplicates found:
|
|
|
|
```json
|
|
{
|
|
"status": "saved",
|
|
"memory_id": "abc123",
|
|
"note": "Similar memory exists (id: def456, 89% match, saved by codex):
|
|
'DB migration tool is alembic'. Call memory_update('def456',
|
|
'<merged content>') to consolidate."
|
|
}
|
|
```
|
|
|
|
The LLM then decides whether to merge — using the user's own LLM, not a separate model. No extra cost to headroom.
|
|
|
|
4. **Background auto-dedup**: If similarity >92%, the older duplicate is automatically removed (async, non-blocking).
|
|
|
|
### Supported Providers
|
|
|
|
Memory works with ALL providers routing through the proxy:
|
|
|
|
| Provider | Context Injection | Memory Tools | Format |
|
|
|----------|-------------------|--------------|--------|
|
|
| **Anthropic** (Claude) | System parameter | Anthropic tool_use | Native |
|
|
| **OpenAI** (Codex, GPT) | System message | OpenAI function calling | Native |
|
|
| **Gemini** | systemInstruction | functionDeclarations | Native |
|
|
| **Any OpenAI-compatible** | System message | Function calling | OpenAI format |
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
```python
|
|
from openai import OpenAI
|
|
from headroom import with_memory
|
|
|
|
# One line - that's it
|
|
client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
# Use exactly like normal
|
|
response = client.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{"role": "user", "content": "I prefer Python for backend work"}]
|
|
)
|
|
# Memory extracted INLINE - zero extra latency
|
|
|
|
# Later, in a new conversation...
|
|
response = client.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{"role": "user", "content": "What language should I use?"}]
|
|
)
|
|
# → Response uses the Python preference from memory
|
|
```
|
|
|
|
---
|
|
|
|
## How It Works
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ with_memory() │
|
|
│ │
|
|
│ 1. INJECT: Semantic search → prepend to user message │
|
|
│ 2. INSTRUCT: Add memory extraction instruction │
|
|
│ 3. CALL: Forward to LLM │
|
|
│ 4. PARSE: Extract <memory> block from response │
|
|
│ 5. STORE: Save with embeddings + vector index + FTS │
|
|
│ 6. RETURN: Clean response (without memory block) │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Key insight**: Memory extraction happens *inline* as part of the LLM response (Letta-style). No extra API calls, no extra latency.
|
|
|
|
---
|
|
|
|
## Hierarchical Scoping
|
|
|
|
Memories exist at different scope levels, enabling fine-grained control:
|
|
|
|
```
|
|
USER (broadest)
|
|
└── SESSION
|
|
└── AGENT
|
|
└── TURN (narrowest)
|
|
```
|
|
|
|
### Scope Levels
|
|
|
|
| Scope | Persists Across | Use Case |
|
|
|-------|-----------------|----------|
|
|
| **USER** | All sessions, all time | Long-term preferences, identity |
|
|
| **SESSION** | Current session only | Current task context |
|
|
| **AGENT** | Current agent in session | Agent-specific context |
|
|
| **TURN** | Single turn only | Ephemeral working memory |
|
|
|
|
### Example: Multi-Session Memory
|
|
|
|
```python
|
|
from openai import OpenAI
|
|
from headroom import with_memory
|
|
|
|
# Session 1: Morning
|
|
client1 = with_memory(
|
|
OpenAI(),
|
|
user_id="bob",
|
|
session_id="morning-session",
|
|
)
|
|
response = client1.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{"role": "user", "content": "I prefer Go for performance-critical code"}]
|
|
)
|
|
# Memory stored at USER level (persists across sessions)
|
|
|
|
# Session 2: Afternoon (different session, same user)
|
|
client2 = with_memory(
|
|
OpenAI(),
|
|
user_id="bob", # Same user
|
|
session_id="afternoon-session", # Different session
|
|
)
|
|
response = client2.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{"role": "user", "content": "What language for my new microservice?"}]
|
|
)
|
|
# → Recalls Go preference from morning session!
|
|
```
|
|
|
|
---
|
|
|
|
## Temporal Versioning (Supersession)
|
|
|
|
Memories evolve over time. When facts change, Headroom creates a **supersession chain** preserving history:
|
|
|
|
```python
|
|
from headroom.memory import HierarchicalMemory, MemoryConfig
|
|
|
|
memory = await HierarchicalMemory.create()
|
|
|
|
# Original fact
|
|
orig = await memory.add(
|
|
content="User works at Google",
|
|
user_id="alice",
|
|
category=MemoryCategory.FACT,
|
|
)
|
|
|
|
# User changes jobs - supersede the old memory
|
|
new = await memory.supersede(
|
|
old_memory_id=orig.id,
|
|
new_content="User now works at Anthropic",
|
|
)
|
|
|
|
# Query current state (excludes superseded)
|
|
current = await memory.query(MemoryFilter(
|
|
user_id="alice",
|
|
include_superseded=False, # Default
|
|
))
|
|
# → Returns only "User now works at Anthropic"
|
|
|
|
# Query full history (includes superseded)
|
|
history = await memory.query(MemoryFilter(
|
|
user_id="alice",
|
|
include_superseded=True,
|
|
))
|
|
# → Returns both memories with validity timestamps
|
|
|
|
# Get the chain
|
|
chain = await memory.get_history(new.id)
|
|
# → [
|
|
# Memory(content="User works at Google", valid_until=..., is_current=False),
|
|
# Memory(content="User now works at Anthropic", valid_until=None, is_current=True),
|
|
# ]
|
|
```
|
|
|
|
### Why Temporal Versioning Matters
|
|
|
|
1. **Audit trail** - Know what was true at any point in time
|
|
2. **Debugging** - Understand why the LLM made certain decisions
|
|
3. **Rollback** - Restore previous state if needed
|
|
4. **Analytics** - Track how user preferences evolve
|
|
|
|
---
|
|
|
|
## Memory Categories
|
|
|
|
Memories are categorized for better organization and retrieval:
|
|
|
|
| Category | Description | Examples |
|
|
|----------|-------------|----------|
|
|
| `PREFERENCE` | Likes, dislikes, preferred approaches | "Prefers Python", "Likes dark mode" |
|
|
| `FACT` | Identity, role, constraints | "Works at fintech startup", "Senior engineer" |
|
|
| `CONTEXT` | Current goals, ongoing tasks | "Migrating to microservices", "Working on auth" |
|
|
| `ENTITY` | Information about entities | "Project Apollo uses React", "Team lead is Sarah" |
|
|
| `DECISION` | Decisions made | "Chose PostgreSQL over MySQL", "Using REST not GraphQL" |
|
|
| `INSIGHT` | Derived insights | "User tends to prefer typed languages" |
|
|
|
|
---
|
|
|
|
## Memory API
|
|
|
|
The `with_memory()` wrapper provides a `.memory` API for direct access:
|
|
|
|
```python
|
|
client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
# Search memories (semantic)
|
|
results = client.memory.search("python preferences", top_k=5)
|
|
for memory in results:
|
|
print(f"{memory.content}")
|
|
|
|
# Add manual memory
|
|
client.memory.add(
|
|
"User is a senior engineer",
|
|
category="fact",
|
|
importance=0.9,
|
|
)
|
|
|
|
# Get all memories
|
|
all_memories = client.memory.get_all()
|
|
|
|
# Clear memories
|
|
client.memory.clear()
|
|
|
|
# Get stats
|
|
stats = client.memory.stats()
|
|
print(f"Total memories: {stats['total']}")
|
|
print(f"By category: {stats['categories']}")
|
|
```
|
|
|
|
---
|
|
|
|
## Advanced Usage: Direct HierarchicalMemory API
|
|
|
|
For full control, use the `HierarchicalMemory` class directly:
|
|
|
|
```python
|
|
import asyncio
|
|
from headroom.memory import (
|
|
HierarchicalMemory,
|
|
MemoryConfig,
|
|
MemoryCategory,
|
|
EmbedderBackend,
|
|
)
|
|
from headroom.memory.ports import MemoryFilter, VectorFilter
|
|
|
|
async def main():
|
|
# Create with custom configuration
|
|
config = MemoryConfig(
|
|
db_path="my_memory.db",
|
|
embedder_backend=EmbedderBackend.LOCAL, # or OPENAI, OLLAMA
|
|
vector_dimension=384,
|
|
cache_max_size=2000,
|
|
)
|
|
memory = await HierarchicalMemory.create(config)
|
|
|
|
# Add memory with full control
|
|
mem = await memory.add(
|
|
content="User prefers functional programming",
|
|
user_id="alice",
|
|
session_id="sess-123",
|
|
agent_id="code-assistant",
|
|
category=MemoryCategory.PREFERENCE,
|
|
importance=0.9,
|
|
entity_refs=["functional-programming", "coding-style"],
|
|
metadata={"source": "conversation", "confidence": 0.95},
|
|
)
|
|
|
|
# Semantic search
|
|
results = await memory.search(
|
|
query="programming paradigm preferences",
|
|
user_id="alice",
|
|
top_k=5,
|
|
min_similarity=0.5,
|
|
categories=[MemoryCategory.PREFERENCE],
|
|
)
|
|
for r in results:
|
|
print(f"[{r.similarity:.3f}] {r.memory.content}")
|
|
|
|
# Full-text search
|
|
text_results = await memory.text_search(
|
|
query="functional",
|
|
user_id="alice",
|
|
)
|
|
|
|
# Query with filters
|
|
memories = await memory.query(MemoryFilter(
|
|
user_id="alice",
|
|
categories=[MemoryCategory.PREFERENCE, MemoryCategory.FACT],
|
|
min_importance=0.7,
|
|
limit=10,
|
|
))
|
|
|
|
# Convenience methods
|
|
await memory.remember("Likes coffee", user_id="alice", importance=0.6)
|
|
relevant = await memory.recall("beverage preferences", user_id="alice")
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
### Embedder Backends
|
|
|
|
```python
|
|
from headroom.memory import MemoryConfig, EmbedderBackend
|
|
|
|
# Local embeddings (recommended - fast, free, private)
|
|
config = MemoryConfig(
|
|
embedder_backend=EmbedderBackend.LOCAL,
|
|
embedder_model="all-MiniLM-L6-v2", # 384 dimensions, fast
|
|
)
|
|
|
|
# OpenAI embeddings (higher quality, costs money)
|
|
config = MemoryConfig(
|
|
embedder_backend=EmbedderBackend.OPENAI,
|
|
openai_api_key="sk-...",
|
|
embedder_model="text-embedding-3-small",
|
|
)
|
|
|
|
# Ollama embeddings (local server, many models)
|
|
config = MemoryConfig(
|
|
embedder_backend=EmbedderBackend.OLLAMA,
|
|
ollama_base_url="http://localhost:11434",
|
|
embedder_model="nomic-embed-text",
|
|
)
|
|
```
|
|
|
|
### Embedding Runtime / GPU Offload (Apple Silicon)
|
|
|
|
By default the proxy's memory embedder runs on the **ONNX CPU** backend. This
|
|
is fast and dependency-light, but it is CPU-only — under sustained load the
|
|
embedding step can saturate the CPU and make the proxy less responsive.
|
|
|
|
On Apple Silicon you can opt in to running the embedder on the **Apple GPU
|
|
(MPS)** instead, which offloads that work off the CPU and keeps the proxy
|
|
responsive. This is especially useful on fanless Macs (e.g. the M5 Air) that
|
|
are prone to CPU-saturation timeouts.
|
|
|
|
Enable it by installing the extra and setting the env var:
|
|
|
|
```bash
|
|
pip install 'headroom-ai[pytorch-mps]' # also works as [pytorch_mps]
|
|
export HEADROOM_EMBEDDER_RUNTIME=pytorch_mps
|
|
```
|
|
|
|
When set, the embedder runs via the torch sentence-transformers backend on the
|
|
Apple GPU instead of the default ONNX CPU embedder. Notes:
|
|
|
|
- **Strictly opt-in.** `pytorch_mps` is the only accepted value; anything else
|
|
(or unset) keeps the default ONNX CPU embedder. Default behavior is unchanged.
|
|
- **Auto-fallback.** It only activates when Apple MPS is actually available
|
|
(Apple Silicon + torch). If MPS is unavailable or torch/sentence-transformers
|
|
is not installed, it logs a warning and uses the existing default embedder
|
|
selection path: ONNX when available, then the pre-existing local
|
|
sentence-transformers fallback.
|
|
- **MPS serialization.** torch-MPS is not thread-safe, so the embedder
|
|
serializes MPS encode calls internally via a single-worker executor. This is
|
|
automatic — there is nothing to configure.
|
|
|
|
### Storage Configuration
|
|
|
|
```python
|
|
config = MemoryConfig(
|
|
db_path="memory.db", # SQLite database path
|
|
vector_dimension=384, # Must match embedder output
|
|
hnsw_ef_construction=200, # HNSW index quality (higher = better, slower)
|
|
hnsw_m=16, # HNSW connections per node
|
|
hnsw_ef_search=50, # HNSW search quality
|
|
cache_enabled=True, # Enable LRU cache
|
|
cache_max_size=1000, # Max cached memories
|
|
)
|
|
```
|
|
|
|
### Wrapper Configuration
|
|
|
|
```python
|
|
client = with_memory(
|
|
OpenAI(),
|
|
user_id="alice",
|
|
db_path="memory.db",
|
|
top_k=5, # Memories to inject per request
|
|
session_id="optional-session",
|
|
agent_id="optional-agent",
|
|
embedder_backend=EmbedderBackend.LOCAL,
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Protocol-Based Design
|
|
|
|
Headroom Memory uses **Protocol interfaces** (ports) for all components, enabling easy swapping:
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ HierarchicalMemory │
|
|
│ (Orchestrator) │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ MemoryStore │ │ VectorIndex │ │ TextIndex │ │
|
|
│ │ Protocol │ │ Protocol │ │ Protocol │ │
|
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
|
│ │ │ │ │
|
|
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
|
|
│ │ SQLite │ │ HNSW │ │ FTS5 │ │
|
|
│ │ Adapter │ │ Adapter │ │ Adapter │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
│ │
|
|
│ ┌─────────────┐ ┌─────────────┐ │
|
|
│ │ Embedder │ │ MemoryCache │ │
|
|
│ │ Protocol │ │ Protocol │ │
|
|
│ └──────┬──────┘ └──────┬──────┘ │
|
|
│ │ │ │
|
|
│ ┌──────▼──────┐ ┌──────▼──────┐ │
|
|
│ │Local/OpenAI/│ │ LRU Cache │ │
|
|
│ │ Ollama │ │ │ │
|
|
│ └─────────────┘ └─────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Components
|
|
|
|
| Component | Protocol | Default Adapter | Purpose |
|
|
|-----------|----------|-----------------|---------|
|
|
| **MemoryStore** | `MemoryStore` | `SQLiteMemoryStore` | CRUD + filtering + supersession |
|
|
| **VectorIndex** | `VectorIndex` | `HNSWVectorIndex` | Semantic similarity search |
|
|
| **TextIndex** | `TextIndex` | `FTS5TextIndex` | Full-text keyword search |
|
|
| **Embedder** | `Embedder` | `LocalEmbedder` | Text → vector conversion |
|
|
| **Cache** | `MemoryCache` | `LRUMemoryCache` | Hot memory caching |
|
|
|
|
---
|
|
|
|
## Comparison with State of the Art
|
|
|
|
### vs Letta (MemGPT)
|
|
|
|
**Letta** pioneered inline memory extraction. Headroom builds on this with:
|
|
|
|
| Aspect | Headroom | Letta |
|
|
|--------|----------|-------|
|
|
| **Scoping** | 4-level hierarchy (user/session/agent/turn) | Flat per-agent |
|
|
| **Temporal** | Full supersession chains with history | No versioning |
|
|
| **Integration** | One-liner wrapper for any client | Requires Letta agent framework |
|
|
| **Search** | Semantic + full-text | Semantic only |
|
|
| **Storage** | SQLite + HNSW (embedded) | PostgreSQL (external) |
|
|
| **Extensibility** | Protocol-based adapters | Monolithic |
|
|
|
|
**When to use Letta**: You want a full agent framework with built-in memory.
|
|
**When to use Headroom**: You want memory as a layer on your existing stack.
|
|
|
|
### vs Mem0
|
|
|
|
**Mem0** provides a managed memory service. Headroom differs:
|
|
|
|
| Aspect | Headroom | Mem0 |
|
|
|--------|----------|------|
|
|
| **Deployment** | Embedded (no server) | Managed service or self-hosted |
|
|
| **Scoping** | 4-level hierarchy | Flat per-user |
|
|
| **Temporal** | Supersession chains | No versioning |
|
|
| **Extraction** | Inline (zero latency) | Separate API call |
|
|
| **Search** | Semantic + full-text | Semantic only |
|
|
| **Cost** | Free (local embeddings) | API costs or infra costs |
|
|
| **Privacy** | All local | Data leaves your infra |
|
|
|
|
**When to use Mem0**: You want a managed service and don't mind external dependencies.
|
|
**When to use Headroom**: You want embedded memory with no external services.
|
|
|
|
### Feature Matrix
|
|
|
|
| Feature | Headroom | Letta | Mem0 |
|
|
|---------|:--------:|:-----:|:----:|
|
|
| Cross-agent sharing (proxy) | ✅ | ❌ | ❌ |
|
|
| Agent provenance tracking | ✅ | ❌ | ❌ |
|
|
| LLM-mediated dedup (no extra cost) | ✅ | ❌ | ❌ (uses separate LLM) |
|
|
| Transparent proxy (zero code) | ✅ | ❌ | ❌ |
|
|
| Hierarchical scoping | ✅ | ❌ | ❌ |
|
|
| Temporal versioning | ✅ | ❌ | ❌ |
|
|
| Zero-latency extraction | ✅ | ✅ | ❌ |
|
|
| Full-text search | ✅ | ❌ | ❌ |
|
|
| Embedded (no server) | ✅ | ❌ | ❌ |
|
|
| One-liner integration | ✅ | ❌ | ❌ |
|
|
| Protocol-based extensibility | ✅ | ❌ | ❌ |
|
|
| Memory bubbling | ✅ | ❌ | ❌ |
|
|
| Local embeddings | ✅ | ❌ | ✅ |
|
|
| Managed service option | ❌ | ❌ | ✅ |
|
|
|
|
---
|
|
|
|
## Multi-User Isolation
|
|
|
|
Memories are isolated by `user_id`:
|
|
|
|
```python
|
|
# Alice's memories
|
|
alice_client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
# Bob's memories (completely separate)
|
|
bob_client = with_memory(OpenAI(), user_id="bob")
|
|
|
|
# Bob cannot see Alice's memories, even with the same database
|
|
```
|
|
|
|
---
|
|
|
|
## Performance
|
|
|
|
| Operation | Latency | Notes |
|
|
|-----------|---------|-------|
|
|
| Memory injection | <50ms | Local embeddings + HNSW search |
|
|
| Memory extraction | +50-100 tokens | Part of LLM response (inline) |
|
|
| Memory storage | <10ms | SQLite + HNSW + FTS5 indexing |
|
|
| Cache hit | <1ms | LRU cache lookup |
|
|
|
|
**Overhead**: ~100 extra output tokens per response for the `<memory>` block.
|
|
|
|
---
|
|
|
|
## Providers
|
|
|
|
Memory works with any OpenAI-compatible client:
|
|
|
|
```python
|
|
from openai import OpenAI
|
|
from headroom import with_memory
|
|
|
|
# OpenAI
|
|
client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
# Azure OpenAI
|
|
client = with_memory(
|
|
OpenAI(base_url="https://your-resource.openai.azure.com/..."),
|
|
user_id="alice",
|
|
)
|
|
|
|
# Groq
|
|
from groq import Groq
|
|
client = with_memory(Groq(), user_id="alice")
|
|
|
|
# Any OpenAI-compatible client
|
|
client = with_memory(YourClient(), user_id="alice")
|
|
```
|
|
|
|
---
|
|
|
|
## Example: Full Conversation Flow
|
|
|
|
```python
|
|
from openai import OpenAI
|
|
from headroom import with_memory
|
|
|
|
client = with_memory(OpenAI(), user_id="developer_jane")
|
|
|
|
# Conversation 1: User shares context
|
|
response = client.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{
|
|
"role": "user",
|
|
"content": "I'm a Python developer at a fintech startup. We use PostgreSQL and FastAPI."
|
|
}]
|
|
)
|
|
# Memories extracted:
|
|
# - [FACT] Python developer at fintech startup
|
|
# - [PREFERENCE] Uses PostgreSQL for databases
|
|
# - [PREFERENCE] Uses FastAPI for web APIs
|
|
|
|
# Conversation 2 (new session): User asks question
|
|
response = client.chat.completions.create(
|
|
model="gpt-4o",
|
|
messages=[{
|
|
"role": "user",
|
|
"content": "What database should I use for my new project?"
|
|
}]
|
|
)
|
|
# Response references PostgreSQL preference from memory:
|
|
# → "Given your experience with PostgreSQL at your fintech company,
|
|
# I'd recommend sticking with it for consistency..."
|
|
|
|
# Check stored memories
|
|
print("Stored memories:")
|
|
for m in client.memory.get_all():
|
|
print(f" [{m.category.value}] {m.content}")
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Memories not being extracted
|
|
|
|
1. Check if the conversation has memory-worthy content (not just greetings)
|
|
2. Verify the LLM is following the memory instruction
|
|
3. Enable logging: `import logging; logging.basicConfig(level=logging.DEBUG)`
|
|
|
|
### Memories not being retrieved
|
|
|
|
1. Verify `user_id` matches between sessions
|
|
2. Check if memories exist: `client.memory.get_all()`
|
|
3. Try a more specific search query
|
|
4. Check similarity threshold
|
|
|
|
### High latency
|
|
|
|
1. Use local embeddings: `embedder_backend=EmbedderBackend.LOCAL`
|
|
2. Reduce `top_k` for fewer memories to retrieve
|
|
3. Enable caching (enabled by default)
|
|
|
|
### Memory not persisting
|
|
|
|
1. Check `db_path` is the same across sessions
|
|
2. Ensure the database file is writable
|
|
3. Check for exceptions in logs
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Use consistent `user_id`** - Same ID across sessions for continuity
|
|
2. **Use session scoping** - Set `session_id` for session-specific context
|
|
3. **Start with local embeddings** - Faster, free, good enough for most cases
|
|
4. **Monitor memory growth** - Use `client.memory.stats()` to track
|
|
5. **Use importance scores** - Higher importance = more likely to be retrieved
|
|
6. **Leverage categories** - Helps with debugging and selective retrieval
|
|
7. **Consider supersession** - Use `supersede()` when facts change, not `add()`
|