Spaces:
Build error
Replace legacy memory system with HierarchicalMemory
Browse filesMajor refactor of the memory module:
- Add hierarchical scoping (user → session → agent → turn)
- Add temporal versioning with supersession support
- Add pluggable adapters (SQLite store, HNSW vectors, FTS5 text search)
- Add protocol interfaces (ports) for all memory components
- Update LRUMemoryCache to implement async MemoryCache protocol
- Update wrapper.py to use HierarchicalMemory backend
- Preserve with_memory() one-liner API with zero-latency inline extraction
New files:
- adapters/: sqlite.py, hnsw.py, fts5.py, cache.py, embedders.py
- core.py: HierarchicalMemory orchestrator
- models.py: Memory, MemoryCategory, ScopeLevel
- ports.py: Protocol interfaces (MemoryStore, VectorIndex, etc.)
- config.py: MemoryConfig with backend selection
- factory.py: Component creation from config
Removed legacy files:
- store.py, fast_store.py, extractor.py, worker.py, fast_wrapper.py
Breaking change: Removes legacy memory API (pre-0.3.0)
- headroom/__init__.py +18 -6
- headroom/memory/__init__.py +96 -25
- headroom/memory/adapters/__init__.py +73 -0
- headroom/memory/adapters/cache.py +285 -0
- headroom/memory/adapters/embedders.py +716 -0
- headroom/memory/adapters/fts5.py +478 -0
- headroom/memory/adapters/hnsw.py +803 -0
- headroom/memory/adapters/sqlite.py +752 -0
- headroom/memory/config.py +144 -0
- headroom/memory/core.py +893 -0
- headroom/memory/extractor.py +0 -390
- headroom/memory/factory.py +190 -0
- headroom/memory/fast_store.py +0 -621
- headroom/memory/fast_wrapper.py +0 -311
- headroom/memory/models.py +149 -0
- headroom/memory/ports.py +664 -0
- headroom/memory/store.py +0 -434
- headroom/memory/worker.py +0 -260
- headroom/memory/wrapper.py +233 -110
- pyproject.toml +1 -1
- tests/conftest.py +6 -0
- tests/test_memory/conftest.py +4 -143
- tests/test_memory/test_extractor.py +0 -337
- tests/test_memory/test_hierarchical.py +804 -0
- tests/test_memory/test_inline_extractor.py +0 -196
- tests/test_memory/test_store.py +0 -278
- tests/test_memory/test_wrapper.py +0 -375
- uv.lock +7 -7
|
@@ -113,8 +113,16 @@ from .exceptions import (
|
|
| 113 |
ValidationError,
|
| 114 |
)
|
| 115 |
|
| 116 |
-
# Memory module -
|
| 117 |
-
from .memory import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
from .providers import AnthropicProvider, OpenAIProvider, Provider, TokenCounter
|
| 119 |
from .relevance import (
|
| 120 |
BM25Scorer,
|
|
@@ -135,7 +143,7 @@ from .transforms import (
|
|
| 135 |
TransformPipeline,
|
| 136 |
)
|
| 137 |
|
| 138 |
-
__version__ = "0.
|
| 139 |
|
| 140 |
__all__ = [
|
| 141 |
# Main client
|
|
@@ -205,8 +213,12 @@ __all__ = [
|
|
| 205 |
"count_tokens_text",
|
| 206 |
"count_tokens_messages",
|
| 207 |
"generate_report",
|
| 208 |
-
# Memory -
|
| 209 |
-
"with_memory",
|
| 210 |
"Memory",
|
| 211 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
]
|
|
|
|
| 113 |
ValidationError,
|
| 114 |
)
|
| 115 |
|
| 116 |
+
# Memory module - hierarchical memory system
|
| 117 |
+
from .memory import (
|
| 118 |
+
EmbedderBackend,
|
| 119 |
+
HierarchicalMemory,
|
| 120 |
+
Memory,
|
| 121 |
+
MemoryCategory,
|
| 122 |
+
MemoryConfig,
|
| 123 |
+
ScopeLevel,
|
| 124 |
+
with_memory,
|
| 125 |
+
)
|
| 126 |
from .providers import AnthropicProvider, OpenAIProvider, Provider, TokenCounter
|
| 127 |
from .relevance import (
|
| 128 |
BM25Scorer,
|
|
|
|
| 143 |
TransformPipeline,
|
| 144 |
)
|
| 145 |
|
| 146 |
+
__version__ = "0.3.0"
|
| 147 |
|
| 148 |
__all__ = [
|
| 149 |
# Main client
|
|
|
|
| 213 |
"count_tokens_text",
|
| 214 |
"count_tokens_messages",
|
| 215 |
"generate_report",
|
| 216 |
+
# Memory - hierarchical memory system
|
| 217 |
+
"with_memory", # Main user-facing API
|
| 218 |
"Memory",
|
| 219 |
+
"MemoryCategory",
|
| 220 |
+
"ScopeLevel",
|
| 221 |
+
"HierarchicalMemory",
|
| 222 |
+
"MemoryConfig",
|
| 223 |
+
"EmbedderBackend",
|
| 224 |
]
|
|
@@ -1,37 +1,108 @@
|
|
| 1 |
-
"""Headroom Memory -
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
1. Background extraction (original):
|
| 6 |
-
from headroom import with_memory
|
| 7 |
client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
from headroom.memory import
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
|
| 15 |
-
from headroom.memory.
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
)
|
| 21 |
-
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
__all__ = [
|
| 25 |
-
#
|
| 26 |
"with_memory",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"Memory",
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
]
|
|
|
|
| 1 |
+
"""Headroom Memory - Hierarchical memory system for AI applications.
|
| 2 |
|
| 3 |
+
This module provides a sophisticated memory system with:
|
| 4 |
+
- Hierarchical scoping (user -> session -> agent -> turn)
|
| 5 |
+
- Temporal versioning with supersession
|
| 6 |
+
- Vector and text search capabilities
|
| 7 |
+
- Pluggable storage backends via Protocol interfaces
|
| 8 |
+
- Zero-latency inline memory extraction (Letta-style)
|
| 9 |
+
|
| 10 |
+
Quick Start (One-liner with any LLM client):
|
| 11 |
+
from openai import OpenAI
|
| 12 |
+
from headroom.memory import with_memory
|
| 13 |
|
|
|
|
|
|
|
| 14 |
client = with_memory(OpenAI(), user_id="alice")
|
| 15 |
+
response = client.chat.completions.create(
|
| 16 |
+
model="gpt-4o",
|
| 17 |
+
messages=[{"role": "user", "content": "I prefer Python"}]
|
| 18 |
+
)
|
| 19 |
+
# Memory automatically extracted and stored!
|
| 20 |
+
|
| 21 |
+
Advanced Usage (Direct API):
|
| 22 |
+
from headroom.memory import HierarchicalMemory, MemoryConfig, MemoryCategory
|
| 23 |
+
|
| 24 |
+
memory = await HierarchicalMemory.create()
|
| 25 |
+
await memory.add(
|
| 26 |
+
content="User prefers Python over JavaScript",
|
| 27 |
+
user_id="alice",
|
| 28 |
+
category=MemoryCategory.PREFERENCE,
|
| 29 |
+
)
|
| 30 |
+
results = await memory.search("programming preferences", user_id="alice")
|
| 31 |
|
| 32 |
+
Configuration:
|
| 33 |
+
from headroom.memory import MemoryConfig, EmbedderBackend
|
| 34 |
+
|
| 35 |
+
config = MemoryConfig(
|
| 36 |
+
embedder_backend=EmbedderBackend.OPENAI,
|
| 37 |
+
openai_api_key="sk-...",
|
| 38 |
+
)
|
| 39 |
+
memory = await HierarchicalMemory.create(config)
|
| 40 |
"""
|
| 41 |
|
| 42 |
+
# Configuration
|
| 43 |
+
from headroom.memory.config import (
|
| 44 |
+
EmbedderBackend,
|
| 45 |
+
MemoryConfig,
|
| 46 |
+
StoreBackend,
|
| 47 |
+
TextBackend,
|
| 48 |
+
VectorBackend,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Core orchestrator
|
| 52 |
+
from headroom.memory.core import HierarchicalMemory
|
| 53 |
+
|
| 54 |
+
# Factory
|
| 55 |
+
from headroom.memory.factory import create_memory_system
|
| 56 |
+
|
| 57 |
+
# Data models
|
| 58 |
+
from headroom.memory.models import Memory, MemoryCategory, ScopeLevel
|
| 59 |
+
|
| 60 |
+
# Protocol interfaces (ports)
|
| 61 |
+
from headroom.memory.ports import (
|
| 62 |
+
Embedder,
|
| 63 |
+
MemoryCache,
|
| 64 |
+
MemoryFilter,
|
| 65 |
+
MemoryStore,
|
| 66 |
+
TextFilter,
|
| 67 |
+
TextIndex,
|
| 68 |
+
TextSearchResult,
|
| 69 |
+
VectorFilter,
|
| 70 |
+
VectorIndex,
|
| 71 |
+
VectorSearchResult,
|
| 72 |
)
|
| 73 |
+
|
| 74 |
+
# Wrapper for LLM clients (main user-facing API)
|
| 75 |
+
from headroom.memory.wrapper import MemoryWrapper, with_memory
|
| 76 |
|
| 77 |
__all__ = [
|
| 78 |
+
# Main user-facing API
|
| 79 |
"with_memory",
|
| 80 |
+
"MemoryWrapper",
|
| 81 |
+
# Core orchestrator
|
| 82 |
+
"HierarchicalMemory",
|
| 83 |
+
# Data models
|
| 84 |
"Memory",
|
| 85 |
+
"MemoryCategory",
|
| 86 |
+
"ScopeLevel",
|
| 87 |
+
# Protocol interfaces (ports)
|
| 88 |
+
"MemoryStore",
|
| 89 |
+
"VectorIndex",
|
| 90 |
+
"TextIndex",
|
| 91 |
+
"Embedder",
|
| 92 |
+
"MemoryCache",
|
| 93 |
+
# Filter dataclasses
|
| 94 |
+
"MemoryFilter",
|
| 95 |
+
"VectorFilter",
|
| 96 |
+
"TextFilter",
|
| 97 |
+
# Search result dataclasses
|
| 98 |
+
"VectorSearchResult",
|
| 99 |
+
"TextSearchResult",
|
| 100 |
+
# Configuration
|
| 101 |
+
"MemoryConfig",
|
| 102 |
+
"StoreBackend",
|
| 103 |
+
"VectorBackend",
|
| 104 |
+
"TextBackend",
|
| 105 |
+
"EmbedderBackend",
|
| 106 |
+
# Factory
|
| 107 |
+
"create_memory_system",
|
| 108 |
]
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Memory adapters for Headroom's hierarchical memory system.
|
| 2 |
+
|
| 3 |
+
This module provides concrete implementations of the memory system's ports:
|
| 4 |
+
- SQLiteMemoryStore: SQLite-based memory persistence
|
| 5 |
+
- FTS5TextIndex: SQLite FTS5 full-text search index
|
| 6 |
+
- HNSWVectorIndex: HNSW-based vector index using hnswlib (optional)
|
| 7 |
+
- LRUMemoryCache: Thread-safe LRU cache for hot memories
|
| 8 |
+
- LocalEmbedder: sentence-transformers embedding (local, optional)
|
| 9 |
+
- OpenAIEmbedder: OpenAI API embedding (cloud, optional)
|
| 10 |
+
- OllamaEmbedder: Ollama API embedding (local server, optional)
|
| 11 |
+
|
| 12 |
+
Note: Some adapters require optional dependencies. Import errors are
|
| 13 |
+
deferred until the adapter is actually used.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
# Core adapters (no external dependencies beyond sqlite3)
|
| 17 |
+
from headroom.memory.adapters.cache import LRUMemoryCache
|
| 18 |
+
from headroom.memory.adapters.fts5 import FTS5TextIndex
|
| 19 |
+
from headroom.memory.adapters.sqlite import SQLiteMemoryStore
|
| 20 |
+
|
| 21 |
+
# Lazy imports for optional adapters
|
| 22 |
+
_HNSWVectorIndex = None
|
| 23 |
+
_LocalEmbedder = None
|
| 24 |
+
_OpenAIEmbedder = None
|
| 25 |
+
_OllamaEmbedder = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def __getattr__(name: str) -> type:
|
| 29 |
+
"""Lazy import for optional adapters."""
|
| 30 |
+
global _HNSWVectorIndex, _LocalEmbedder, _OpenAIEmbedder, _OllamaEmbedder
|
| 31 |
+
|
| 32 |
+
if name == "HNSWVectorIndex":
|
| 33 |
+
if _HNSWVectorIndex is None:
|
| 34 |
+
from headroom.memory.adapters.hnsw import HNSWVectorIndex
|
| 35 |
+
|
| 36 |
+
_HNSWVectorIndex = HNSWVectorIndex
|
| 37 |
+
return _HNSWVectorIndex
|
| 38 |
+
|
| 39 |
+
if name == "LocalEmbedder":
|
| 40 |
+
if _LocalEmbedder is None:
|
| 41 |
+
from headroom.memory.adapters.embedders import LocalEmbedder
|
| 42 |
+
|
| 43 |
+
_LocalEmbedder = LocalEmbedder
|
| 44 |
+
return _LocalEmbedder
|
| 45 |
+
|
| 46 |
+
if name == "OpenAIEmbedder":
|
| 47 |
+
if _OpenAIEmbedder is None:
|
| 48 |
+
from headroom.memory.adapters.embedders import OpenAIEmbedder
|
| 49 |
+
|
| 50 |
+
_OpenAIEmbedder = OpenAIEmbedder
|
| 51 |
+
return _OpenAIEmbedder
|
| 52 |
+
|
| 53 |
+
if name == "OllamaEmbedder":
|
| 54 |
+
if _OllamaEmbedder is None:
|
| 55 |
+
from headroom.memory.adapters.embedders import OllamaEmbedder
|
| 56 |
+
|
| 57 |
+
_OllamaEmbedder = OllamaEmbedder
|
| 58 |
+
return _OllamaEmbedder
|
| 59 |
+
|
| 60 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
__all__ = [
|
| 64 |
+
# Core adapters (always available)
|
| 65 |
+
"FTS5TextIndex",
|
| 66 |
+
"LRUMemoryCache",
|
| 67 |
+
"SQLiteMemoryStore",
|
| 68 |
+
# Optional adapters (lazy-loaded)
|
| 69 |
+
"HNSWVectorIndex",
|
| 70 |
+
"LocalEmbedder",
|
| 71 |
+
"OllamaEmbedder",
|
| 72 |
+
"OpenAIEmbedder",
|
| 73 |
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thread-safe LRU cache for hot memories in Headroom Memory.
|
| 2 |
+
|
| 3 |
+
Provides O(1) get/set operations with configurable size limits
|
| 4 |
+
and automatic eviction of least-recently-used entries.
|
| 5 |
+
|
| 6 |
+
Implements the MemoryCache protocol with async methods.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections import OrderedDict
|
| 12 |
+
from threading import Lock
|
| 13 |
+
from typing import TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from ..models import Memory
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class LRUMemoryCache:
|
| 20 |
+
"""Thread-safe LRU (Least Recently Used) cache for Memory objects.
|
| 21 |
+
|
| 22 |
+
Implements the MemoryCache protocol with async methods that wrap
|
| 23 |
+
synchronous operations.
|
| 24 |
+
|
| 25 |
+
Features:
|
| 26 |
+
- O(1) get and set operations using OrderedDict
|
| 27 |
+
- Automatic eviction of least-recently-used entries when at capacity
|
| 28 |
+
- Thread-safe with Lock for concurrent access
|
| 29 |
+
- Move-to-end on access to maintain LRU ordering
|
| 30 |
+
- Batch operations for efficiency
|
| 31 |
+
|
| 32 |
+
Usage:
|
| 33 |
+
cache = LRUMemoryCache(max_size=1000)
|
| 34 |
+
await cache.put(memory_obj)
|
| 35 |
+
memory = await cache.get("mem-123") # Returns Memory or None
|
| 36 |
+
|
| 37 |
+
The cache uses an OrderedDict internally where:
|
| 38 |
+
- Most recently used items are at the end
|
| 39 |
+
- Least recently used items are at the beginning
|
| 40 |
+
- On capacity overflow, the first (oldest) item is evicted
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, max_size: int = 1000) -> None:
|
| 44 |
+
"""Initialize the LRU cache.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
max_size: Maximum number of entries to store. When exceeded,
|
| 48 |
+
the least recently used entry is evicted.
|
| 49 |
+
|
| 50 |
+
Raises:
|
| 51 |
+
ValueError: If max_size is less than 1.
|
| 52 |
+
"""
|
| 53 |
+
if max_size < 1:
|
| 54 |
+
raise ValueError(f"max_size must be at least 1, got {max_size}")
|
| 55 |
+
|
| 56 |
+
self._max_size = max_size
|
| 57 |
+
self._cache: OrderedDict[str, Memory] = OrderedDict()
|
| 58 |
+
self._lock = Lock()
|
| 59 |
+
|
| 60 |
+
async def get(self, memory_id: str) -> Memory | None:
|
| 61 |
+
"""Get a memory from the cache.
|
| 62 |
+
|
| 63 |
+
Moves the accessed item to the end (most recently used position).
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
memory_id: The memory ID to retrieve.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
The Memory object if found, None otherwise.
|
| 70 |
+
"""
|
| 71 |
+
with self._lock:
|
| 72 |
+
if memory_id not in self._cache:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
# Move to end (most recently used)
|
| 76 |
+
self._cache.move_to_end(memory_id)
|
| 77 |
+
return self._cache[memory_id]
|
| 78 |
+
|
| 79 |
+
async def get_batch(self, memory_ids: list[str]) -> dict[str, Memory]:
|
| 80 |
+
"""Get multiple memories from the cache.
|
| 81 |
+
|
| 82 |
+
Moves all accessed items to the end in the order they were requested.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
memory_ids: List of memory IDs to retrieve.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Dict mapping memory IDs to Memory objects for all found in cache.
|
| 89 |
+
IDs not in cache are omitted from the result.
|
| 90 |
+
"""
|
| 91 |
+
with self._lock:
|
| 92 |
+
result: dict[str, Memory] = {}
|
| 93 |
+
for memory_id in memory_ids:
|
| 94 |
+
if memory_id in self._cache:
|
| 95 |
+
# Move to end (most recently used)
|
| 96 |
+
self._cache.move_to_end(memory_id)
|
| 97 |
+
result[memory_id] = self._cache[memory_id]
|
| 98 |
+
return result
|
| 99 |
+
|
| 100 |
+
async def put(
|
| 101 |
+
self,
|
| 102 |
+
memory: Memory,
|
| 103 |
+
ttl_seconds: int | None = None,
|
| 104 |
+
) -> None:
|
| 105 |
+
"""Put a memory in the cache.
|
| 106 |
+
|
| 107 |
+
If the memory already exists, updates the value and moves to end.
|
| 108 |
+
If at capacity, evicts the least recently used entry first.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
memory: The Memory object to cache.
|
| 112 |
+
ttl_seconds: Time-to-live in seconds. Currently ignored in this
|
| 113 |
+
basic LRU implementation (reserved for future use).
|
| 114 |
+
"""
|
| 115 |
+
# Note: ttl_seconds is accepted but ignored in this basic LRU implementation.
|
| 116 |
+
# A TTL-aware version would need a background cleanup thread or lazy expiration.
|
| 117 |
+
_ = ttl_seconds # Explicitly ignore
|
| 118 |
+
|
| 119 |
+
with self._lock:
|
| 120 |
+
key = memory.id
|
| 121 |
+
if key in self._cache:
|
| 122 |
+
# Update existing entry and move to end
|
| 123 |
+
self._cache[key] = memory
|
| 124 |
+
self._cache.move_to_end(key)
|
| 125 |
+
else:
|
| 126 |
+
# Add new entry
|
| 127 |
+
self._cache[key] = memory
|
| 128 |
+
|
| 129 |
+
# Evict oldest if at capacity
|
| 130 |
+
while len(self._cache) > self._max_size:
|
| 131 |
+
# popitem(last=False) removes the first (oldest) item
|
| 132 |
+
self._cache.popitem(last=False)
|
| 133 |
+
|
| 134 |
+
async def put_batch(
|
| 135 |
+
self,
|
| 136 |
+
memories: list[Memory],
|
| 137 |
+
ttl_seconds: int | None = None,
|
| 138 |
+
) -> None:
|
| 139 |
+
"""Put multiple memories in the cache.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
memories: List of Memory objects to cache.
|
| 143 |
+
ttl_seconds: Time-to-live in seconds. Currently ignored in this
|
| 144 |
+
basic LRU implementation (reserved for future use).
|
| 145 |
+
"""
|
| 146 |
+
# Note: ttl_seconds is accepted but ignored in this basic LRU implementation.
|
| 147 |
+
_ = ttl_seconds # Explicitly ignore
|
| 148 |
+
|
| 149 |
+
with self._lock:
|
| 150 |
+
for memory in memories:
|
| 151 |
+
key = memory.id
|
| 152 |
+
if key in self._cache:
|
| 153 |
+
self._cache[key] = memory
|
| 154 |
+
self._cache.move_to_end(key)
|
| 155 |
+
else:
|
| 156 |
+
self._cache[key] = memory
|
| 157 |
+
|
| 158 |
+
# Evict oldest entries if over capacity
|
| 159 |
+
while len(self._cache) > self._max_size:
|
| 160 |
+
self._cache.popitem(last=False)
|
| 161 |
+
|
| 162 |
+
async def invalidate(self, memory_id: str) -> bool:
|
| 163 |
+
"""Invalidate (remove) a memory from cache.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
memory_id: The memory ID to remove.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
True if the memory was in cache, False otherwise.
|
| 170 |
+
"""
|
| 171 |
+
with self._lock:
|
| 172 |
+
if memory_id in self._cache:
|
| 173 |
+
del self._cache[memory_id]
|
| 174 |
+
return True
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
async def invalidate_batch(self, memory_ids: list[str]) -> int:
|
| 178 |
+
"""Invalidate multiple memories from cache.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
memory_ids: List of memory IDs to invalidate.
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
Number of memories that were in cache.
|
| 185 |
+
"""
|
| 186 |
+
with self._lock:
|
| 187 |
+
count = 0
|
| 188 |
+
for memory_id in memory_ids:
|
| 189 |
+
if memory_id in self._cache:
|
| 190 |
+
del self._cache[memory_id]
|
| 191 |
+
count += 1
|
| 192 |
+
return count
|
| 193 |
+
|
| 194 |
+
async def invalidate_scope(
|
| 195 |
+
self,
|
| 196 |
+
user_id: str,
|
| 197 |
+
session_id: str | None = None,
|
| 198 |
+
agent_id: str | None = None,
|
| 199 |
+
) -> int:
|
| 200 |
+
"""Invalidate all cached memories at or below a scope.
|
| 201 |
+
|
| 202 |
+
Args:
|
| 203 |
+
user_id: Required user scope.
|
| 204 |
+
session_id: If provided, invalidate session and below.
|
| 205 |
+
agent_id: If provided, invalidate agent and below.
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Number of memories invalidated.
|
| 209 |
+
"""
|
| 210 |
+
with self._lock:
|
| 211 |
+
# Find all matching memory IDs
|
| 212 |
+
to_remove = []
|
| 213 |
+
for memory_id, memory in self._cache.items():
|
| 214 |
+
if memory.user_id != user_id:
|
| 215 |
+
continue
|
| 216 |
+
if session_id is not None and memory.session_id != session_id:
|
| 217 |
+
continue
|
| 218 |
+
if agent_id is not None and memory.agent_id != agent_id:
|
| 219 |
+
continue
|
| 220 |
+
to_remove.append(memory_id)
|
| 221 |
+
|
| 222 |
+
# Remove them
|
| 223 |
+
for memory_id in to_remove:
|
| 224 |
+
del self._cache[memory_id]
|
| 225 |
+
|
| 226 |
+
return len(to_remove)
|
| 227 |
+
|
| 228 |
+
async def clear(self) -> None:
|
| 229 |
+
"""Remove all entries from the cache."""
|
| 230 |
+
with self._lock:
|
| 231 |
+
self._cache.clear()
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def size(self) -> int:
|
| 235 |
+
"""Get the current number of entries in the cache.
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Number of entries currently stored.
|
| 239 |
+
"""
|
| 240 |
+
with self._lock:
|
| 241 |
+
return len(self._cache)
|
| 242 |
+
|
| 243 |
+
@property
|
| 244 |
+
def max_size(self) -> int | None:
|
| 245 |
+
"""Get the maximum cache size.
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
Maximum number of entries allowed.
|
| 249 |
+
"""
|
| 250 |
+
return self._max_size
|
| 251 |
+
|
| 252 |
+
def contains(self, memory_id: str) -> bool:
|
| 253 |
+
"""Check if a memory exists in the cache without affecting LRU order.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
memory_id: The memory ID to check.
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
True if the memory exists, False otherwise.
|
| 260 |
+
"""
|
| 261 |
+
with self._lock:
|
| 262 |
+
return memory_id in self._cache
|
| 263 |
+
|
| 264 |
+
def keys(self) -> list[str]:
|
| 265 |
+
"""Get all keys in the cache.
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
List of keys in LRU order (oldest first, newest last).
|
| 269 |
+
"""
|
| 270 |
+
with self._lock:
|
| 271 |
+
return list(self._cache.keys())
|
| 272 |
+
|
| 273 |
+
def stats(self) -> dict:
|
| 274 |
+
"""Get cache statistics.
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
Dict with size, max_size, and utilization percentage.
|
| 278 |
+
"""
|
| 279 |
+
with self._lock:
|
| 280 |
+
current_size = len(self._cache)
|
| 281 |
+
return {
|
| 282 |
+
"size": current_size,
|
| 283 |
+
"max_size": self._max_size,
|
| 284 |
+
"utilization": (current_size / self._max_size) * 100 if self._max_size > 0 else 0.0,
|
| 285 |
+
}
|
|
@@ -0,0 +1,716 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedder implementations for Headroom Memory.
|
| 2 |
+
|
| 3 |
+
Provides embedding generation via multiple backends:
|
| 4 |
+
- LocalEmbedder: sentence-transformers (local, no API needed)
|
| 5 |
+
- OpenAIEmbedder: OpenAI API (cloud, requires API key)
|
| 6 |
+
- OllamaEmbedder: Ollama API (local server)
|
| 7 |
+
|
| 8 |
+
All embedders return normalized float32 vectors for cosine similarity.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import logging
|
| 15 |
+
import os
|
| 16 |
+
from functools import cached_property
|
| 17 |
+
from typing import TYPE_CHECKING, Any
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from sentence_transformers import SentenceTransformer
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _normalize_embedding(embedding: np.ndarray) -> np.ndarray:
|
| 28 |
+
"""Normalize embedding to unit vector for cosine similarity.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
embedding: The embedding vector to normalize.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Normalized embedding with L2 norm of 1.0.
|
| 35 |
+
"""
|
| 36 |
+
norm = np.linalg.norm(embedding)
|
| 37 |
+
if norm > 0:
|
| 38 |
+
result: np.ndarray = (embedding / norm).astype(np.float32)
|
| 39 |
+
return result
|
| 40 |
+
result = embedding.astype(np.float32)
|
| 41 |
+
return result
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _normalize_embeddings_batch(embeddings: np.ndarray) -> np.ndarray:
|
| 45 |
+
"""Normalize a batch of embeddings to unit vectors.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
embeddings: 2D array of embeddings (batch_size, dimension).
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Normalized embeddings with L2 norm of 1.0 per row.
|
| 52 |
+
"""
|
| 53 |
+
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
| 54 |
+
# Avoid division by zero
|
| 55 |
+
norms = np.where(norms > 0, norms, 1.0)
|
| 56 |
+
return (embeddings / norms).astype(np.float32)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# =============================================================================
|
| 60 |
+
# LocalEmbedder - sentence-transformers
|
| 61 |
+
# =============================================================================
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class LocalEmbedder:
|
| 65 |
+
"""Local embedding using sentence-transformers.
|
| 66 |
+
|
| 67 |
+
Uses the sentence-transformers library for local embedding generation.
|
| 68 |
+
No API calls needed - runs entirely on local hardware.
|
| 69 |
+
|
| 70 |
+
Features:
|
| 71 |
+
- Lazy model loading (loads on first use)
|
| 72 |
+
- Automatic device selection (CUDA > MPS > CPU)
|
| 73 |
+
- Batch embedding support
|
| 74 |
+
- Returns normalized float32 vectors
|
| 75 |
+
|
| 76 |
+
Default model: all-MiniLM-L6-v2 (384 dimensions)
|
| 77 |
+
|
| 78 |
+
Usage:
|
| 79 |
+
embedder = LocalEmbedder()
|
| 80 |
+
embedding = await embedder.embed("Hello world")
|
| 81 |
+
embeddings = await embedder.embed_batch(["Hello", "World"])
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
DEFAULT_MODEL = "all-MiniLM-L6-v2"
|
| 85 |
+
DEFAULT_DIMENSION = 384
|
| 86 |
+
DEFAULT_MAX_TOKENS = 256
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
model_name: str | None = None,
|
| 91 |
+
device: str | None = None,
|
| 92 |
+
) -> None:
|
| 93 |
+
"""Initialize the local embedder.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
model_name: Name of the sentence-transformers model to use.
|
| 97 |
+
Defaults to "all-MiniLM-L6-v2".
|
| 98 |
+
device: Device to run on ("cuda", "mps", "cpu", or None for auto).
|
| 99 |
+
If None, automatically selects the best available device.
|
| 100 |
+
|
| 101 |
+
Raises:
|
| 102 |
+
ImportError: If sentence-transformers is not installed.
|
| 103 |
+
"""
|
| 104 |
+
self._model_name = model_name or self.DEFAULT_MODEL
|
| 105 |
+
self._requested_device = device
|
| 106 |
+
self._model: SentenceTransformer | None = None
|
| 107 |
+
self._device: str | None = None
|
| 108 |
+
self._dimension: int | None = None
|
| 109 |
+
self._lock = asyncio.Lock()
|
| 110 |
+
|
| 111 |
+
def _check_dependencies(self) -> None:
|
| 112 |
+
"""Check that required dependencies are installed."""
|
| 113 |
+
try:
|
| 114 |
+
import sentence_transformers # noqa: F401
|
| 115 |
+
except ImportError as e:
|
| 116 |
+
raise ImportError(
|
| 117 |
+
"sentence-transformers is required for LocalEmbedder. "
|
| 118 |
+
"Install it with: pip install sentence-transformers"
|
| 119 |
+
) from e
|
| 120 |
+
|
| 121 |
+
def _detect_device(self) -> str:
|
| 122 |
+
"""Auto-detect the best available device.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Device string: "cuda", "mps", or "cpu".
|
| 126 |
+
"""
|
| 127 |
+
import torch
|
| 128 |
+
|
| 129 |
+
if torch.cuda.is_available():
|
| 130 |
+
logger.info("CUDA device detected, using GPU")
|
| 131 |
+
return "cuda"
|
| 132 |
+
elif torch.backends.mps.is_available():
|
| 133 |
+
logger.info("MPS device detected, using Apple Silicon GPU")
|
| 134 |
+
return "mps"
|
| 135 |
+
else:
|
| 136 |
+
logger.info("No GPU detected, using CPU")
|
| 137 |
+
return "cpu"
|
| 138 |
+
|
| 139 |
+
def _load_model(self) -> None:
|
| 140 |
+
"""Load the sentence-transformers model lazily."""
|
| 141 |
+
if self._model is not None:
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
self._check_dependencies()
|
| 145 |
+
from sentence_transformers import SentenceTransformer
|
| 146 |
+
|
| 147 |
+
# Determine device
|
| 148 |
+
if self._requested_device:
|
| 149 |
+
self._device = self._requested_device
|
| 150 |
+
else:
|
| 151 |
+
self._device = self._detect_device()
|
| 152 |
+
|
| 153 |
+
logger.info(f"Loading model {self._model_name} on device {self._device}")
|
| 154 |
+
self._model = SentenceTransformer(self._model_name, device=self._device)
|
| 155 |
+
|
| 156 |
+
# Get actual dimension from loaded model
|
| 157 |
+
self._dimension = self._model.get_sentence_embedding_dimension()
|
| 158 |
+
logger.info(
|
| 159 |
+
f"Model loaded: {self._model_name}, dimension={self._dimension}, device={self._device}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
async def embed(self, text: str) -> np.ndarray:
|
| 163 |
+
"""Generate an embedding for a single text.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
text: The text to embed.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Normalized embedding vector as float32 numpy array.
|
| 170 |
+
"""
|
| 171 |
+
async with self._lock:
|
| 172 |
+
# Load model if not already loaded
|
| 173 |
+
if self._model is None:
|
| 174 |
+
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
| 175 |
+
|
| 176 |
+
# Handle empty string
|
| 177 |
+
if not text or not text.strip():
|
| 178 |
+
return np.zeros(self.dimension, dtype=np.float32)
|
| 179 |
+
|
| 180 |
+
# Run encoding in executor to avoid blocking
|
| 181 |
+
# Model is guaranteed to be loaded after the lock check above
|
| 182 |
+
assert self._model is not None
|
| 183 |
+
model = self._model # Local reference for lambda closure
|
| 184 |
+
loop = asyncio.get_event_loop()
|
| 185 |
+
embedding = await loop.run_in_executor(
|
| 186 |
+
None,
|
| 187 |
+
lambda: model.encode(text, convert_to_numpy=True, normalize_embeddings=False),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return _normalize_embedding(embedding)
|
| 191 |
+
|
| 192 |
+
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 193 |
+
"""Generate embeddings for multiple texts.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
texts: List of texts to embed.
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
List of normalized embedding vectors.
|
| 200 |
+
"""
|
| 201 |
+
if not texts:
|
| 202 |
+
return []
|
| 203 |
+
|
| 204 |
+
async with self._lock:
|
| 205 |
+
# Load model if not already loaded
|
| 206 |
+
if self._model is None:
|
| 207 |
+
await asyncio.get_event_loop().run_in_executor(None, self._load_model)
|
| 208 |
+
|
| 209 |
+
# Handle empty strings by tracking their indices
|
| 210 |
+
non_empty_indices = []
|
| 211 |
+
non_empty_texts = []
|
| 212 |
+
for i, text in enumerate(texts):
|
| 213 |
+
if text and text.strip():
|
| 214 |
+
non_empty_indices.append(i)
|
| 215 |
+
non_empty_texts.append(text)
|
| 216 |
+
|
| 217 |
+
# Initialize results with zeros for empty strings
|
| 218 |
+
results: list[np.ndarray] = [
|
| 219 |
+
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
if non_empty_texts:
|
| 223 |
+
# Run batch encoding in executor
|
| 224 |
+
# Model is guaranteed to be loaded after the lock check above
|
| 225 |
+
assert self._model is not None
|
| 226 |
+
model = self._model # Local reference for lambda closure
|
| 227 |
+
loop = asyncio.get_event_loop()
|
| 228 |
+
embeddings = await loop.run_in_executor(
|
| 229 |
+
None,
|
| 230 |
+
lambda: model.encode(
|
| 231 |
+
non_empty_texts, convert_to_numpy=True, normalize_embeddings=False
|
| 232 |
+
),
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Normalize batch
|
| 236 |
+
normalized = _normalize_embeddings_batch(embeddings)
|
| 237 |
+
|
| 238 |
+
# Place results at correct indices
|
| 239 |
+
for idx, emb in zip(non_empty_indices, normalized):
|
| 240 |
+
results[idx] = emb
|
| 241 |
+
|
| 242 |
+
return results
|
| 243 |
+
|
| 244 |
+
@property
|
| 245 |
+
def dimension(self) -> int:
|
| 246 |
+
"""Return the dimension of generated embeddings."""
|
| 247 |
+
if self._dimension is not None:
|
| 248 |
+
return self._dimension
|
| 249 |
+
# Return default dimension before model is loaded
|
| 250 |
+
return self.DEFAULT_DIMENSION
|
| 251 |
+
|
| 252 |
+
@property
|
| 253 |
+
def model_name(self) -> str:
|
| 254 |
+
"""Return the name of the embedding model."""
|
| 255 |
+
return self._model_name
|
| 256 |
+
|
| 257 |
+
@property
|
| 258 |
+
def max_tokens(self) -> int:
|
| 259 |
+
"""Return the maximum number of tokens the model can process."""
|
| 260 |
+
return self.DEFAULT_MAX_TOKENS
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# =============================================================================
|
| 264 |
+
# OpenAIEmbedder - OpenAI API
|
| 265 |
+
# =============================================================================
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class OpenAIEmbedder:
|
| 269 |
+
"""OpenAI API-based embedding generation.
|
| 270 |
+
|
| 271 |
+
Uses OpenAI's text-embedding-3-small model for high-quality embeddings.
|
| 272 |
+
Requires an API key (constructor parameter or OPENAI_API_KEY env var).
|
| 273 |
+
|
| 274 |
+
Features:
|
| 275 |
+
- Async API calls with retry logic
|
| 276 |
+
- Batch support with automatic rate limiting
|
| 277 |
+
- Returns normalized float32 vectors
|
| 278 |
+
|
| 279 |
+
Default model: text-embedding-3-small (1536 dimensions)
|
| 280 |
+
|
| 281 |
+
Usage:
|
| 282 |
+
embedder = OpenAIEmbedder(api_key="sk-...")
|
| 283 |
+
# Or use OPENAI_API_KEY environment variable
|
| 284 |
+
embedder = OpenAIEmbedder()
|
| 285 |
+
embedding = await embedder.embed("Hello world")
|
| 286 |
+
"""
|
| 287 |
+
|
| 288 |
+
DEFAULT_MODEL = "text-embedding-3-small"
|
| 289 |
+
DEFAULT_DIMENSION = 1536
|
| 290 |
+
DEFAULT_MAX_TOKENS = 8191
|
| 291 |
+
MAX_BATCH_SIZE = 2048 # OpenAI's limit
|
| 292 |
+
MAX_RETRIES = 3
|
| 293 |
+
RETRY_DELAY_BASE = 1.0 # Base delay in seconds for exponential backoff
|
| 294 |
+
|
| 295 |
+
def __init__(
|
| 296 |
+
self,
|
| 297 |
+
api_key: str | None = None,
|
| 298 |
+
model_name: str | None = None,
|
| 299 |
+
max_retries: int | None = None,
|
| 300 |
+
) -> None:
|
| 301 |
+
"""Initialize the OpenAI embedder.
|
| 302 |
+
|
| 303 |
+
Args:
|
| 304 |
+
api_key: OpenAI API key. If not provided, will use OPENAI_API_KEY
|
| 305 |
+
environment variable.
|
| 306 |
+
model_name: Model to use. Defaults to "text-embedding-3-small".
|
| 307 |
+
max_retries: Maximum number of retries for transient failures.
|
| 308 |
+
|
| 309 |
+
Raises:
|
| 310 |
+
ImportError: If openai library is not installed.
|
| 311 |
+
ValueError: If no API key is provided or found in environment.
|
| 312 |
+
"""
|
| 313 |
+
self._check_dependencies()
|
| 314 |
+
|
| 315 |
+
self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
| 316 |
+
if not self._api_key:
|
| 317 |
+
raise ValueError(
|
| 318 |
+
"OpenAI API key required. Provide api_key parameter or set "
|
| 319 |
+
"OPENAI_API_KEY environment variable."
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
self._model_name = model_name or self.DEFAULT_MODEL
|
| 323 |
+
self._max_retries = max_retries if max_retries is not None else self.MAX_RETRIES
|
| 324 |
+
self._client = None
|
| 325 |
+
|
| 326 |
+
def _check_dependencies(self) -> None:
|
| 327 |
+
"""Check that required dependencies are installed."""
|
| 328 |
+
try:
|
| 329 |
+
import openai # noqa: F401
|
| 330 |
+
except ImportError as e:
|
| 331 |
+
raise ImportError(
|
| 332 |
+
"openai is required for OpenAIEmbedder. Install it with: pip install openai"
|
| 333 |
+
) from e
|
| 334 |
+
|
| 335 |
+
@cached_property
|
| 336 |
+
def _async_client(self) -> Any:
|
| 337 |
+
"""Lazy initialization of async OpenAI client."""
|
| 338 |
+
from openai import AsyncOpenAI
|
| 339 |
+
|
| 340 |
+
return AsyncOpenAI(api_key=self._api_key)
|
| 341 |
+
|
| 342 |
+
async def _embed_with_retry(self, texts: list[str]) -> list[np.ndarray]:
|
| 343 |
+
"""Call OpenAI API with retry logic for transient failures.
|
| 344 |
+
|
| 345 |
+
Args:
|
| 346 |
+
texts: List of texts to embed.
|
| 347 |
+
|
| 348 |
+
Returns:
|
| 349 |
+
List of embedding vectors.
|
| 350 |
+
|
| 351 |
+
Raises:
|
| 352 |
+
ConnectionError: If all retries fail.
|
| 353 |
+
"""
|
| 354 |
+
from openai import APIConnectionError, APITimeoutError, RateLimitError
|
| 355 |
+
|
| 356 |
+
last_error = None
|
| 357 |
+
|
| 358 |
+
for attempt in range(self._max_retries):
|
| 359 |
+
try:
|
| 360 |
+
response = await self._async_client.embeddings.create(
|
| 361 |
+
model=self._model_name,
|
| 362 |
+
input=texts,
|
| 363 |
+
)
|
| 364 |
+
# Extract embeddings in order
|
| 365 |
+
embeddings = [np.array(item.embedding, dtype=np.float32) for item in response.data]
|
| 366 |
+
return embeddings
|
| 367 |
+
|
| 368 |
+
except (APIConnectionError, APITimeoutError, RateLimitError) as e:
|
| 369 |
+
last_error = e
|
| 370 |
+
delay = self.RETRY_DELAY_BASE * (2**attempt)
|
| 371 |
+
logger.warning(
|
| 372 |
+
f"OpenAI API error (attempt {attempt + 1}/{self._max_retries}): {e}. "
|
| 373 |
+
f"Retrying in {delay:.1f}s..."
|
| 374 |
+
)
|
| 375 |
+
await asyncio.sleep(delay)
|
| 376 |
+
|
| 377 |
+
except Exception as e:
|
| 378 |
+
# Non-retryable error
|
| 379 |
+
raise ConnectionError(f"OpenAI API error: {e}") from e
|
| 380 |
+
|
| 381 |
+
# All retries exhausted
|
| 382 |
+
raise ConnectionError(
|
| 383 |
+
f"OpenAI API failed after {self._max_retries} retries: {last_error}"
|
| 384 |
+
) from last_error
|
| 385 |
+
|
| 386 |
+
async def embed(self, text: str) -> np.ndarray:
|
| 387 |
+
"""Generate an embedding for a single text.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
text: The text to embed.
|
| 391 |
+
|
| 392 |
+
Returns:
|
| 393 |
+
Normalized embedding vector as float32 numpy array.
|
| 394 |
+
|
| 395 |
+
Raises:
|
| 396 |
+
ConnectionError: If API call fails after retries.
|
| 397 |
+
"""
|
| 398 |
+
# Handle empty string
|
| 399 |
+
if not text or not text.strip():
|
| 400 |
+
return np.zeros(self.dimension, dtype=np.float32)
|
| 401 |
+
|
| 402 |
+
embeddings = await self._embed_with_retry([text])
|
| 403 |
+
return _normalize_embedding(embeddings[0])
|
| 404 |
+
|
| 405 |
+
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 406 |
+
"""Generate embeddings for multiple texts.
|
| 407 |
+
|
| 408 |
+
Automatically handles batching for large inputs.
|
| 409 |
+
|
| 410 |
+
Args:
|
| 411 |
+
texts: List of texts to embed.
|
| 412 |
+
|
| 413 |
+
Returns:
|
| 414 |
+
List of normalized embedding vectors.
|
| 415 |
+
|
| 416 |
+
Raises:
|
| 417 |
+
ConnectionError: If API call fails after retries.
|
| 418 |
+
"""
|
| 419 |
+
if not texts:
|
| 420 |
+
return []
|
| 421 |
+
|
| 422 |
+
# Handle empty strings by tracking their indices
|
| 423 |
+
non_empty_indices = []
|
| 424 |
+
non_empty_texts = []
|
| 425 |
+
for i, text in enumerate(texts):
|
| 426 |
+
if text and text.strip():
|
| 427 |
+
non_empty_indices.append(i)
|
| 428 |
+
non_empty_texts.append(text)
|
| 429 |
+
|
| 430 |
+
# Initialize results with zeros for empty strings
|
| 431 |
+
results: list[np.ndarray] = [
|
| 432 |
+
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
| 433 |
+
]
|
| 434 |
+
|
| 435 |
+
if not non_empty_texts:
|
| 436 |
+
return results
|
| 437 |
+
|
| 438 |
+
# Process in batches
|
| 439 |
+
all_embeddings: list[np.ndarray] = []
|
| 440 |
+
for batch_start in range(0, len(non_empty_texts), self.MAX_BATCH_SIZE):
|
| 441 |
+
batch_end = min(batch_start + self.MAX_BATCH_SIZE, len(non_empty_texts))
|
| 442 |
+
batch = non_empty_texts[batch_start:batch_end]
|
| 443 |
+
|
| 444 |
+
batch_embeddings = await self._embed_with_retry(batch)
|
| 445 |
+
all_embeddings.extend(batch_embeddings)
|
| 446 |
+
|
| 447 |
+
# Normalize and place results at correct indices
|
| 448 |
+
for idx, emb in zip(non_empty_indices, all_embeddings):
|
| 449 |
+
results[idx] = _normalize_embedding(emb)
|
| 450 |
+
|
| 451 |
+
return results
|
| 452 |
+
|
| 453 |
+
@property
|
| 454 |
+
def dimension(self) -> int:
|
| 455 |
+
"""Return the dimension of generated embeddings."""
|
| 456 |
+
return self.DEFAULT_DIMENSION
|
| 457 |
+
|
| 458 |
+
@property
|
| 459 |
+
def model_name(self) -> str:
|
| 460 |
+
"""Return the name of the embedding model."""
|
| 461 |
+
return self._model_name
|
| 462 |
+
|
| 463 |
+
@property
|
| 464 |
+
def max_tokens(self) -> int:
|
| 465 |
+
"""Return the maximum number of tokens the model can process."""
|
| 466 |
+
return self.DEFAULT_MAX_TOKENS
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
# =============================================================================
|
| 470 |
+
# OllamaEmbedder - Ollama API
|
| 471 |
+
# =============================================================================
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
class OllamaEmbedder:
|
| 475 |
+
"""Ollama API-based embedding generation.
|
| 476 |
+
|
| 477 |
+
Uses a local Ollama server for embedding generation. No cloud API needed.
|
| 478 |
+
|
| 479 |
+
Features:
|
| 480 |
+
- Async HTTP calls via httpx
|
| 481 |
+
- Batch support
|
| 482 |
+
- Retry logic for transient failures
|
| 483 |
+
- Returns normalized float32 vectors
|
| 484 |
+
|
| 485 |
+
Default model: nomic-embed-text (768 dimensions)
|
| 486 |
+
|
| 487 |
+
Usage:
|
| 488 |
+
embedder = OllamaEmbedder() # Uses localhost:11434
|
| 489 |
+
embedder = OllamaEmbedder(base_url="http://remote:11434")
|
| 490 |
+
embedding = await embedder.embed("Hello world")
|
| 491 |
+
"""
|
| 492 |
+
|
| 493 |
+
DEFAULT_MODEL = "nomic-embed-text"
|
| 494 |
+
DEFAULT_DIMENSION = 768
|
| 495 |
+
DEFAULT_MAX_TOKENS = 8192
|
| 496 |
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
| 497 |
+
MAX_RETRIES = 3
|
| 498 |
+
RETRY_DELAY_BASE = 0.5 # Base delay in seconds for exponential backoff
|
| 499 |
+
REQUEST_TIMEOUT = 60.0 # Timeout for API requests
|
| 500 |
+
|
| 501 |
+
# Known model dimensions (for models that don't report their dimension)
|
| 502 |
+
KNOWN_DIMENSIONS = {
|
| 503 |
+
"nomic-embed-text": 768,
|
| 504 |
+
"all-minilm": 384,
|
| 505 |
+
"mxbai-embed-large": 1024,
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
def __init__(
|
| 509 |
+
self,
|
| 510 |
+
model_name: str | None = None,
|
| 511 |
+
base_url: str | None = None,
|
| 512 |
+
max_retries: int | None = None,
|
| 513 |
+
dimension: int | None = None,
|
| 514 |
+
) -> None:
|
| 515 |
+
"""Initialize the Ollama embedder.
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
model_name: Model to use. Defaults to "nomic-embed-text".
|
| 519 |
+
base_url: Ollama server URL. Defaults to "http://localhost:11434".
|
| 520 |
+
max_retries: Maximum number of retries for transient failures.
|
| 521 |
+
dimension: Override embedding dimension. If not provided, uses
|
| 522 |
+
known dimension for model or probes the API.
|
| 523 |
+
|
| 524 |
+
Raises:
|
| 525 |
+
ImportError: If httpx library is not installed.
|
| 526 |
+
"""
|
| 527 |
+
self._check_dependencies()
|
| 528 |
+
|
| 529 |
+
self._model_name = model_name or self.DEFAULT_MODEL
|
| 530 |
+
self._base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
| 531 |
+
self._max_retries = max_retries if max_retries is not None else self.MAX_RETRIES
|
| 532 |
+
self._explicit_dimension = dimension
|
| 533 |
+
self._detected_dimension: int | None = None
|
| 534 |
+
self._client: Any = None # httpx.AsyncClient when initialized
|
| 535 |
+
self._lock = asyncio.Lock()
|
| 536 |
+
|
| 537 |
+
def _check_dependencies(self) -> None:
|
| 538 |
+
"""Check that required dependencies are installed."""
|
| 539 |
+
try:
|
| 540 |
+
import httpx # noqa: F401
|
| 541 |
+
except ImportError as e:
|
| 542 |
+
raise ImportError(
|
| 543 |
+
"httpx is required for OllamaEmbedder. Install it with: pip install httpx"
|
| 544 |
+
) from e
|
| 545 |
+
|
| 546 |
+
async def _get_client(self) -> Any:
|
| 547 |
+
"""Get or create the httpx async client."""
|
| 548 |
+
if self._client is None:
|
| 549 |
+
import httpx
|
| 550 |
+
|
| 551 |
+
self._client = httpx.AsyncClient(
|
| 552 |
+
base_url=self._base_url,
|
| 553 |
+
timeout=self.REQUEST_TIMEOUT,
|
| 554 |
+
)
|
| 555 |
+
return self._client
|
| 556 |
+
|
| 557 |
+
async def _embed_single_with_retry(self, text: str) -> np.ndarray:
|
| 558 |
+
"""Call Ollama API with retry logic for a single text.
|
| 559 |
+
|
| 560 |
+
Args:
|
| 561 |
+
text: Text to embed.
|
| 562 |
+
|
| 563 |
+
Returns:
|
| 564 |
+
Embedding vector.
|
| 565 |
+
|
| 566 |
+
Raises:
|
| 567 |
+
ConnectionError: If all retries fail.
|
| 568 |
+
"""
|
| 569 |
+
import httpx
|
| 570 |
+
|
| 571 |
+
client = await self._get_client()
|
| 572 |
+
last_error = None
|
| 573 |
+
|
| 574 |
+
for attempt in range(self._max_retries):
|
| 575 |
+
try:
|
| 576 |
+
response = await client.post(
|
| 577 |
+
"/api/embeddings",
|
| 578 |
+
json={
|
| 579 |
+
"model": self._model_name,
|
| 580 |
+
"prompt": text,
|
| 581 |
+
},
|
| 582 |
+
)
|
| 583 |
+
response.raise_for_status()
|
| 584 |
+
|
| 585 |
+
data = response.json()
|
| 586 |
+
embedding = np.array(data["embedding"], dtype=np.float32)
|
| 587 |
+
|
| 588 |
+
# Detect dimension from first successful response
|
| 589 |
+
if self._detected_dimension is None:
|
| 590 |
+
self._detected_dimension = len(embedding)
|
| 591 |
+
|
| 592 |
+
return embedding
|
| 593 |
+
|
| 594 |
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
|
| 595 |
+
last_error = e
|
| 596 |
+
delay = self.RETRY_DELAY_BASE * (2**attempt)
|
| 597 |
+
logger.warning(
|
| 598 |
+
f"Ollama API error (attempt {attempt + 1}/{self._max_retries}): {e}. "
|
| 599 |
+
f"Retrying in {delay:.1f}s..."
|
| 600 |
+
)
|
| 601 |
+
await asyncio.sleep(delay)
|
| 602 |
+
|
| 603 |
+
except Exception as e:
|
| 604 |
+
# Non-retryable error
|
| 605 |
+
raise ConnectionError(f"Ollama API error: {e}") from e
|
| 606 |
+
|
| 607 |
+
# All retries exhausted
|
| 608 |
+
raise ConnectionError(
|
| 609 |
+
f"Ollama API failed after {self._max_retries} retries: {last_error}"
|
| 610 |
+
) from last_error
|
| 611 |
+
|
| 612 |
+
async def embed(self, text: str) -> np.ndarray:
|
| 613 |
+
"""Generate an embedding for a single text.
|
| 614 |
+
|
| 615 |
+
Args:
|
| 616 |
+
text: The text to embed.
|
| 617 |
+
|
| 618 |
+
Returns:
|
| 619 |
+
Normalized embedding vector as float32 numpy array.
|
| 620 |
+
|
| 621 |
+
Raises:
|
| 622 |
+
ConnectionError: If API call fails after retries.
|
| 623 |
+
"""
|
| 624 |
+
# Handle empty string
|
| 625 |
+
if not text or not text.strip():
|
| 626 |
+
return np.zeros(self.dimension, dtype=np.float32)
|
| 627 |
+
|
| 628 |
+
embedding = await self._embed_single_with_retry(text)
|
| 629 |
+
return _normalize_embedding(embedding)
|
| 630 |
+
|
| 631 |
+
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 632 |
+
"""Generate embeddings for multiple texts.
|
| 633 |
+
|
| 634 |
+
Ollama API doesn't support batch embedding natively,
|
| 635 |
+
so we make concurrent requests.
|
| 636 |
+
|
| 637 |
+
Args:
|
| 638 |
+
texts: List of texts to embed.
|
| 639 |
+
|
| 640 |
+
Returns:
|
| 641 |
+
List of normalized embedding vectors.
|
| 642 |
+
|
| 643 |
+
Raises:
|
| 644 |
+
ConnectionError: If API call fails after retries.
|
| 645 |
+
"""
|
| 646 |
+
if not texts:
|
| 647 |
+
return []
|
| 648 |
+
|
| 649 |
+
# Handle empty strings by tracking their indices
|
| 650 |
+
non_empty_indices = []
|
| 651 |
+
non_empty_texts = []
|
| 652 |
+
for i, text in enumerate(texts):
|
| 653 |
+
if text and text.strip():
|
| 654 |
+
non_empty_indices.append(i)
|
| 655 |
+
non_empty_texts.append(text)
|
| 656 |
+
|
| 657 |
+
# Initialize results with zeros for empty strings
|
| 658 |
+
results: list[np.ndarray] = [
|
| 659 |
+
np.zeros(self.dimension, dtype=np.float32) for _ in range(len(texts))
|
| 660 |
+
]
|
| 661 |
+
|
| 662 |
+
if not non_empty_texts:
|
| 663 |
+
return results
|
| 664 |
+
|
| 665 |
+
# Make concurrent requests for non-empty texts
|
| 666 |
+
# Use a semaphore to limit concurrency and avoid overwhelming the server
|
| 667 |
+
semaphore = asyncio.Semaphore(10)
|
| 668 |
+
|
| 669 |
+
async def embed_with_semaphore(text: str) -> np.ndarray:
|
| 670 |
+
async with semaphore:
|
| 671 |
+
return await self._embed_single_with_retry(text)
|
| 672 |
+
|
| 673 |
+
tasks = [embed_with_semaphore(text) for text in non_empty_texts]
|
| 674 |
+
embeddings = await asyncio.gather(*tasks)
|
| 675 |
+
|
| 676 |
+
# Normalize and place results at correct indices
|
| 677 |
+
for idx, emb in zip(non_empty_indices, embeddings):
|
| 678 |
+
results[idx] = _normalize_embedding(emb)
|
| 679 |
+
|
| 680 |
+
return results
|
| 681 |
+
|
| 682 |
+
@property
|
| 683 |
+
def dimension(self) -> int:
|
| 684 |
+
"""Return the dimension of generated embeddings."""
|
| 685 |
+
# Priority: explicit > detected > known > default
|
| 686 |
+
if self._explicit_dimension is not None:
|
| 687 |
+
return self._explicit_dimension
|
| 688 |
+
if self._detected_dimension is not None:
|
| 689 |
+
return self._detected_dimension
|
| 690 |
+
if self._model_name in self.KNOWN_DIMENSIONS:
|
| 691 |
+
return self.KNOWN_DIMENSIONS[self._model_name]
|
| 692 |
+
return self.DEFAULT_DIMENSION
|
| 693 |
+
|
| 694 |
+
@property
|
| 695 |
+
def model_name(self) -> str:
|
| 696 |
+
"""Return the name of the embedding model."""
|
| 697 |
+
return self._model_name
|
| 698 |
+
|
| 699 |
+
@property
|
| 700 |
+
def max_tokens(self) -> int:
|
| 701 |
+
"""Return the maximum number of tokens the model can process."""
|
| 702 |
+
return self.DEFAULT_MAX_TOKENS
|
| 703 |
+
|
| 704 |
+
async def close(self) -> None:
|
| 705 |
+
"""Close the HTTP client."""
|
| 706 |
+
if self._client is not None:
|
| 707 |
+
await self._client.aclose()
|
| 708 |
+
self._client = None
|
| 709 |
+
|
| 710 |
+
async def __aenter__(self) -> OllamaEmbedder:
|
| 711 |
+
"""Async context manager entry."""
|
| 712 |
+
return self
|
| 713 |
+
|
| 714 |
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
| 715 |
+
"""Async context manager exit."""
|
| 716 |
+
await self.close()
|
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite FTS5 full-text search index for Headroom Memory.
|
| 2 |
+
|
| 3 |
+
Provides fast, local full-text search with BM25 ranking.
|
| 4 |
+
Uses SQLite's built-in FTS5 extension with Porter stemming
|
| 5 |
+
and Unicode tokenization for high-quality search results.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import sqlite3
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import TYPE_CHECKING, Any
|
| 15 |
+
|
| 16 |
+
from ..models import Memory, MemoryCategory
|
| 17 |
+
from ..ports import TextFilter, TextSearchResult
|
| 18 |
+
|
| 19 |
+
if TYPE_CHECKING:
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class FTS5SearchResult:
|
| 25 |
+
"""Result from an FTS5 full-text search.
|
| 26 |
+
|
| 27 |
+
This is a lightweight result that contains just the indexed fields,
|
| 28 |
+
not the full Memory object. Use memory_id to fetch the full Memory
|
| 29 |
+
from the MemoryStore if needed.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
memory_id: str
|
| 33 |
+
content: str
|
| 34 |
+
score: float # BM25 relevance score (higher = more relevant)
|
| 35 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FTS5TextIndex:
|
| 39 |
+
"""SQLite FTS5 full-text search index.
|
| 40 |
+
|
| 41 |
+
Features:
|
| 42 |
+
- BM25 ranking for relevance scoring
|
| 43 |
+
- Porter stemming for morphological matching
|
| 44 |
+
- Unicode support for international text
|
| 45 |
+
- Filtering by user_id, session_id, and categories
|
| 46 |
+
- Batch indexing for efficiency
|
| 47 |
+
|
| 48 |
+
Usage:
|
| 49 |
+
index = FTS5TextIndex("./search.db")
|
| 50 |
+
index.index("mem-123", "User prefers Python", {"user_id": "alice"})
|
| 51 |
+
results = index.search("python programming", k=5)
|
| 52 |
+
|
| 53 |
+
The FTS5 table stores:
|
| 54 |
+
- memory_id: Unique identifier for the memory
|
| 55 |
+
- content: Searchable text content
|
| 56 |
+
- user_id: Optional user identifier for filtering
|
| 57 |
+
- session_id: Optional session identifier for filtering
|
| 58 |
+
- category: Memory category for filtering
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def __init__(self, db_path: str | Path = "headroom_memory.db") -> None:
|
| 62 |
+
"""Initialize the FTS5 text index.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
db_path: Path to SQLite database file. Created if it doesn't exist.
|
| 66 |
+
"""
|
| 67 |
+
self.db_path = Path(db_path)
|
| 68 |
+
self._init_db()
|
| 69 |
+
|
| 70 |
+
def _get_conn(self) -> sqlite3.Connection:
|
| 71 |
+
"""Get a new database connection (thread-safe pattern).
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
A new SQLite connection with row factory configured.
|
| 75 |
+
"""
|
| 76 |
+
conn = sqlite3.connect(str(self.db_path))
|
| 77 |
+
conn.row_factory = sqlite3.Row
|
| 78 |
+
return conn
|
| 79 |
+
|
| 80 |
+
def _init_db(self) -> None:
|
| 81 |
+
"""Initialize the FTS5 virtual table schema."""
|
| 82 |
+
with self._get_conn() as conn:
|
| 83 |
+
# Create FTS5 virtual table with Porter stemming and Unicode tokenization
|
| 84 |
+
conn.execute("""
|
| 85 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
| 86 |
+
memory_id,
|
| 87 |
+
content,
|
| 88 |
+
user_id,
|
| 89 |
+
session_id,
|
| 90 |
+
category,
|
| 91 |
+
tokenize='porter unicode61'
|
| 92 |
+
)
|
| 93 |
+
""")
|
| 94 |
+
conn.commit()
|
| 95 |
+
|
| 96 |
+
def index_raw(
|
| 97 |
+
self,
|
| 98 |
+
memory_id: str,
|
| 99 |
+
text: str,
|
| 100 |
+
metadata: dict | None = None,
|
| 101 |
+
) -> None:
|
| 102 |
+
"""Index a single memory for full-text search (low-level).
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
memory_id: Unique identifier for the memory.
|
| 106 |
+
text: Text content to index.
|
| 107 |
+
metadata: Optional metadata dict with user_id, session_id, category.
|
| 108 |
+
"""
|
| 109 |
+
metadata = metadata or {}
|
| 110 |
+
user_id = metadata.get("user_id", "")
|
| 111 |
+
session_id = metadata.get("session_id", "")
|
| 112 |
+
category = metadata.get("category", "")
|
| 113 |
+
|
| 114 |
+
# Handle MemoryCategory enum values
|
| 115 |
+
if isinstance(category, MemoryCategory):
|
| 116 |
+
category = category.value
|
| 117 |
+
|
| 118 |
+
with self._get_conn() as conn:
|
| 119 |
+
# Delete existing entry if present (upsert behavior)
|
| 120 |
+
conn.execute(
|
| 121 |
+
"DELETE FROM memory_fts WHERE memory_id = ?",
|
| 122 |
+
(memory_id,),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Insert new entry
|
| 126 |
+
conn.execute(
|
| 127 |
+
"""
|
| 128 |
+
INSERT INTO memory_fts (memory_id, content, user_id, session_id, category)
|
| 129 |
+
VALUES (?, ?, ?, ?, ?)
|
| 130 |
+
""",
|
| 131 |
+
(memory_id, text, user_id, session_id, category),
|
| 132 |
+
)
|
| 133 |
+
conn.commit()
|
| 134 |
+
|
| 135 |
+
# Alias for backwards compatibility
|
| 136 |
+
def index(
|
| 137 |
+
self,
|
| 138 |
+
memory_id: str,
|
| 139 |
+
text: str,
|
| 140 |
+
metadata: dict | None = None,
|
| 141 |
+
) -> None:
|
| 142 |
+
"""Index a single memory for full-text search.
|
| 143 |
+
|
| 144 |
+
Alias for index_raw for backwards compatibility.
|
| 145 |
+
For protocol-compliant async indexing, use index_memory().
|
| 146 |
+
"""
|
| 147 |
+
self.index_raw(memory_id, text, metadata)
|
| 148 |
+
|
| 149 |
+
def index_batch(
|
| 150 |
+
self,
|
| 151 |
+
memory_ids: list[str],
|
| 152 |
+
texts: list[str],
|
| 153 |
+
metadata: list[dict] | None = None,
|
| 154 |
+
) -> None:
|
| 155 |
+
"""Index multiple memories in a single transaction.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
memory_ids: List of unique identifiers.
|
| 159 |
+
texts: List of text contents to index.
|
| 160 |
+
metadata: Optional list of metadata dicts (one per memory).
|
| 161 |
+
|
| 162 |
+
Raises:
|
| 163 |
+
ValueError: If memory_ids and texts have different lengths.
|
| 164 |
+
"""
|
| 165 |
+
if len(memory_ids) != len(texts):
|
| 166 |
+
raise ValueError(
|
| 167 |
+
f"memory_ids ({len(memory_ids)}) and texts ({len(texts)}) must have same length"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
if metadata is not None and len(metadata) != len(memory_ids):
|
| 171 |
+
raise ValueError(
|
| 172 |
+
f"metadata ({len(metadata)}) must match memory_ids ({len(memory_ids)}) length"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
metadata = metadata or [{} for _ in memory_ids]
|
| 176 |
+
|
| 177 |
+
with self._get_conn() as conn:
|
| 178 |
+
# Delete existing entries
|
| 179 |
+
conn.executemany(
|
| 180 |
+
"DELETE FROM memory_fts WHERE memory_id = ?",
|
| 181 |
+
[(mid,) for mid in memory_ids],
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Prepare batch data
|
| 185 |
+
batch_data = []
|
| 186 |
+
for memory_id, text, meta in zip(memory_ids, texts, metadata):
|
| 187 |
+
user_id = meta.get("user_id", "")
|
| 188 |
+
session_id = meta.get("session_id", "")
|
| 189 |
+
category = meta.get("category", "")
|
| 190 |
+
|
| 191 |
+
if isinstance(category, MemoryCategory):
|
| 192 |
+
category = category.value
|
| 193 |
+
|
| 194 |
+
batch_data.append((memory_id, text, user_id, session_id, category))
|
| 195 |
+
|
| 196 |
+
# Insert all entries
|
| 197 |
+
conn.executemany(
|
| 198 |
+
"""
|
| 199 |
+
INSERT INTO memory_fts (memory_id, content, user_id, session_id, category)
|
| 200 |
+
VALUES (?, ?, ?, ?, ?)
|
| 201 |
+
""",
|
| 202 |
+
batch_data,
|
| 203 |
+
)
|
| 204 |
+
conn.commit()
|
| 205 |
+
|
| 206 |
+
def search(
|
| 207 |
+
self,
|
| 208 |
+
query: str,
|
| 209 |
+
k: int = 10,
|
| 210 |
+
filter: TextFilter | None = None,
|
| 211 |
+
) -> list[FTS5SearchResult]:
|
| 212 |
+
"""Search indexed memories using FTS5 with BM25 ranking.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
query: Search query string.
|
| 216 |
+
k: Maximum number of results to return.
|
| 217 |
+
filter: Optional filter for user_id, session_id, categories.
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
List of FTS5SearchResult ordered by BM25 relevance score.
|
| 221 |
+
"""
|
| 222 |
+
# Sanitize query for FTS5
|
| 223 |
+
fts_query = self._sanitize_fts_query(query)
|
| 224 |
+
if not fts_query.strip():
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
# Build WHERE clause with filters
|
| 228 |
+
where_clauses = ["memory_fts MATCH ?"]
|
| 229 |
+
params: list = [fts_query]
|
| 230 |
+
|
| 231 |
+
if filter is not None:
|
| 232 |
+
if filter.user_id is not None:
|
| 233 |
+
where_clauses.append("user_id = ?")
|
| 234 |
+
params.append(filter.user_id)
|
| 235 |
+
|
| 236 |
+
if filter.session_id is not None:
|
| 237 |
+
where_clauses.append("session_id = ?")
|
| 238 |
+
params.append(filter.session_id)
|
| 239 |
+
|
| 240 |
+
if filter.categories is not None and len(filter.categories) > 0:
|
| 241 |
+
# Handle enum values
|
| 242 |
+
category_values = []
|
| 243 |
+
for cat in filter.categories:
|
| 244 |
+
if isinstance(cat, MemoryCategory):
|
| 245 |
+
category_values.append(cat.value)
|
| 246 |
+
else:
|
| 247 |
+
category_values.append(cat)
|
| 248 |
+
|
| 249 |
+
placeholders = ", ".join("?" * len(category_values))
|
| 250 |
+
where_clauses.append(f"category IN ({placeholders})")
|
| 251 |
+
params.extend(category_values)
|
| 252 |
+
|
| 253 |
+
params.append(k)
|
| 254 |
+
where_sql = " AND ".join(where_clauses)
|
| 255 |
+
|
| 256 |
+
with self._get_conn() as conn:
|
| 257 |
+
# Query with BM25 ranking (lower is better, so we order ASC)
|
| 258 |
+
cursor = conn.execute(
|
| 259 |
+
f"""
|
| 260 |
+
SELECT memory_id, content, user_id, session_id, category,
|
| 261 |
+
bm25(memory_fts) as rank
|
| 262 |
+
FROM memory_fts
|
| 263 |
+
WHERE {where_sql}
|
| 264 |
+
ORDER BY rank
|
| 265 |
+
LIMIT ?
|
| 266 |
+
""",
|
| 267 |
+
params,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
results = []
|
| 271 |
+
for row in cursor:
|
| 272 |
+
# Convert BM25 score to a positive relevance score
|
| 273 |
+
# BM25 returns negative values where more negative = more relevant
|
| 274 |
+
# We negate and normalize to make higher = more relevant
|
| 275 |
+
bm25_score = row["rank"]
|
| 276 |
+
relevance_score = -bm25_score if bm25_score < 0 else 0.0
|
| 277 |
+
|
| 278 |
+
results.append(
|
| 279 |
+
FTS5SearchResult(
|
| 280 |
+
memory_id=row["memory_id"],
|
| 281 |
+
content=row["content"],
|
| 282 |
+
score=relevance_score,
|
| 283 |
+
metadata={
|
| 284 |
+
"user_id": row["user_id"],
|
| 285 |
+
"session_id": row["session_id"],
|
| 286 |
+
"category": row["category"],
|
| 287 |
+
},
|
| 288 |
+
)
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
return results
|
| 292 |
+
|
| 293 |
+
def delete(self, memory_id: str) -> bool:
|
| 294 |
+
"""Delete a memory from the index.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
memory_id: ID of the memory to delete.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
True if the memory was deleted, False if not found.
|
| 301 |
+
"""
|
| 302 |
+
with self._get_conn() as conn:
|
| 303 |
+
cursor = conn.execute(
|
| 304 |
+
"DELETE FROM memory_fts WHERE memory_id = ?",
|
| 305 |
+
(memory_id,),
|
| 306 |
+
)
|
| 307 |
+
conn.commit()
|
| 308 |
+
return cursor.rowcount > 0
|
| 309 |
+
|
| 310 |
+
def _sanitize_fts_query(self, query: str) -> str:
|
| 311 |
+
"""Sanitize a query string for FTS5.
|
| 312 |
+
|
| 313 |
+
Escapes special characters and handles edge cases for safe querying.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
query: Raw user query string.
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
FTS5-safe query string with OR between terms.
|
| 320 |
+
"""
|
| 321 |
+
# Extract alphanumeric words
|
| 322 |
+
words = re.findall(r"\w+", query)
|
| 323 |
+
|
| 324 |
+
if not words:
|
| 325 |
+
return ""
|
| 326 |
+
|
| 327 |
+
# Quote each word to handle special characters
|
| 328 |
+
# Use OR between words for flexible matching
|
| 329 |
+
escaped_words = [f'"{word}"' for word in words]
|
| 330 |
+
return " OR ".join(escaped_words)
|
| 331 |
+
|
| 332 |
+
def clear(self) -> None:
|
| 333 |
+
"""Clear all entries from the index."""
|
| 334 |
+
with self._get_conn() as conn:
|
| 335 |
+
conn.execute("DELETE FROM memory_fts")
|
| 336 |
+
conn.commit()
|
| 337 |
+
|
| 338 |
+
def count(self) -> int:
|
| 339 |
+
"""Get the total number of indexed entries.
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
Number of entries in the index.
|
| 343 |
+
"""
|
| 344 |
+
with self._get_conn() as conn:
|
| 345 |
+
cursor = conn.execute("SELECT COUNT(*) FROM memory_fts")
|
| 346 |
+
result = cursor.fetchone()[0]
|
| 347 |
+
return int(result)
|
| 348 |
+
|
| 349 |
+
# =========================================================================
|
| 350 |
+
# Protocol-compliant async methods (TextIndex protocol)
|
| 351 |
+
# =========================================================================
|
| 352 |
+
|
| 353 |
+
async def index_memory(self, memory: Memory) -> None:
|
| 354 |
+
"""Index a memory for full-text search (protocol-compliant).
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
memory: The memory to index.
|
| 358 |
+
"""
|
| 359 |
+
metadata = {
|
| 360 |
+
"user_id": memory.user_id,
|
| 361 |
+
"session_id": memory.session_id or "",
|
| 362 |
+
"category": memory.category,
|
| 363 |
+
}
|
| 364 |
+
self.index(memory.id, memory.content, metadata)
|
| 365 |
+
|
| 366 |
+
async def index_batch_memories(self, memories: list[Memory]) -> int:
|
| 367 |
+
"""Index multiple memories for full-text search (protocol-compliant).
|
| 368 |
+
|
| 369 |
+
Args:
|
| 370 |
+
memories: List of memories to index.
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Number of memories indexed.
|
| 374 |
+
"""
|
| 375 |
+
if not memories:
|
| 376 |
+
return 0
|
| 377 |
+
|
| 378 |
+
memory_ids = [m.id for m in memories]
|
| 379 |
+
texts = [m.content for m in memories]
|
| 380 |
+
metadata_list = [
|
| 381 |
+
{
|
| 382 |
+
"user_id": m.user_id,
|
| 383 |
+
"session_id": m.session_id or "",
|
| 384 |
+
"category": m.category,
|
| 385 |
+
}
|
| 386 |
+
for m in memories
|
| 387 |
+
]
|
| 388 |
+
self.index_batch(memory_ids, texts, metadata_list)
|
| 389 |
+
return len(memories)
|
| 390 |
+
|
| 391 |
+
async def remove(self, memory_id: str) -> bool:
|
| 392 |
+
"""Remove a memory from the text index (protocol-compliant).
|
| 393 |
+
|
| 394 |
+
Args:
|
| 395 |
+
memory_id: The unique identifier of the memory.
|
| 396 |
+
|
| 397 |
+
Returns:
|
| 398 |
+
True if removed, False if not found.
|
| 399 |
+
"""
|
| 400 |
+
return self.delete(memory_id)
|
| 401 |
+
|
| 402 |
+
async def remove_batch(self, memory_ids: list[str]) -> int:
|
| 403 |
+
"""Remove multiple memories from the text index (protocol-compliant).
|
| 404 |
+
|
| 405 |
+
Args:
|
| 406 |
+
memory_ids: List of memory IDs to remove.
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
Number of memories actually removed.
|
| 410 |
+
"""
|
| 411 |
+
count = 0
|
| 412 |
+
for memory_id in memory_ids:
|
| 413 |
+
if self.delete(memory_id):
|
| 414 |
+
count += 1
|
| 415 |
+
return count
|
| 416 |
+
|
| 417 |
+
async def search_memories(
|
| 418 |
+
self, filter: TextFilter, store: Any = None
|
| 419 |
+
) -> list[TextSearchResult]:
|
| 420 |
+
"""Search for memories using full-text search (protocol-compliant).
|
| 421 |
+
|
| 422 |
+
Args:
|
| 423 |
+
filter: Text search filter with query and constraints.
|
| 424 |
+
store: Optional MemoryStore to fetch full Memory objects.
|
| 425 |
+
|
| 426 |
+
Returns:
|
| 427 |
+
List of TextSearchResult sorted by relevance.
|
| 428 |
+
"""
|
| 429 |
+
# Use the existing synchronous search
|
| 430 |
+
fts_results = self.search(filter.query, k=filter.limit, filter=filter)
|
| 431 |
+
|
| 432 |
+
results: list[TextSearchResult] = []
|
| 433 |
+
for rank, fts_result in enumerate(fts_results, start=1):
|
| 434 |
+
# Create a minimal Memory object from FTS data
|
| 435 |
+
# If store is provided, we could fetch the full Memory
|
| 436 |
+
memory = Memory(
|
| 437 |
+
id=fts_result.memory_id,
|
| 438 |
+
content=fts_result.content,
|
| 439 |
+
user_id=fts_result.metadata.get("user_id", ""),
|
| 440 |
+
category=MemoryCategory(fts_result.metadata.get("category", "fact")),
|
| 441 |
+
)
|
| 442 |
+
results.append(
|
| 443 |
+
TextSearchResult(
|
| 444 |
+
memory=memory,
|
| 445 |
+
score=fts_result.score,
|
| 446 |
+
rank=rank,
|
| 447 |
+
)
|
| 448 |
+
)
|
| 449 |
+
return results
|
| 450 |
+
|
| 451 |
+
async def update_content(self, memory_id: str, content: str) -> bool:
|
| 452 |
+
"""Update the indexed content for a memory (protocol-compliant).
|
| 453 |
+
|
| 454 |
+
Args:
|
| 455 |
+
memory_id: The unique identifier of the memory.
|
| 456 |
+
content: The new content to index.
|
| 457 |
+
|
| 458 |
+
Returns:
|
| 459 |
+
True if updated, False if memory not found in index.
|
| 460 |
+
"""
|
| 461 |
+
# Check if exists first
|
| 462 |
+
with self._get_conn() as conn:
|
| 463 |
+
cursor = conn.execute(
|
| 464 |
+
"SELECT user_id, session_id, category FROM memory_fts WHERE memory_id = ?",
|
| 465 |
+
(memory_id,),
|
| 466 |
+
)
|
| 467 |
+
row = cursor.fetchone()
|
| 468 |
+
if row is None:
|
| 469 |
+
return False
|
| 470 |
+
|
| 471 |
+
# Re-index with new content
|
| 472 |
+
metadata = {
|
| 473 |
+
"user_id": row["user_id"],
|
| 474 |
+
"session_id": row["session_id"],
|
| 475 |
+
"category": row["category"],
|
| 476 |
+
}
|
| 477 |
+
self.index(memory_id, content, metadata)
|
| 478 |
+
return True
|
|
@@ -0,0 +1,803 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HNSW vector index for Headroom Memory using hnswlib.
|
| 2 |
+
|
| 3 |
+
Provides fast approximate nearest neighbor search with cosine similarity
|
| 4 |
+
for semantic memory retrieval. Supports filtering by user_id, session_id,
|
| 5 |
+
agent_id, category, and entity references.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from threading import Lock
|
| 15 |
+
from typing import TYPE_CHECKING, Any
|
| 16 |
+
|
| 17 |
+
import hnswlib
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from ..models import Memory, MemoryCategory, ScopeLevel
|
| 21 |
+
from ..ports import VectorFilter, VectorSearchResult
|
| 22 |
+
|
| 23 |
+
if TYPE_CHECKING:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class IndexedMemoryMetadata:
|
| 29 |
+
"""Metadata stored alongside vectors for post-filtering.
|
| 30 |
+
|
| 31 |
+
Stores all filterable fields from Memory to enable
|
| 32 |
+
post-retrieval filtering without accessing the main store.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
memory_id: str
|
| 36 |
+
user_id: str
|
| 37 |
+
session_id: str | None
|
| 38 |
+
agent_id: str | None
|
| 39 |
+
category: str # Stored as string value
|
| 40 |
+
valid_until: datetime | None
|
| 41 |
+
entity_refs: list[str]
|
| 42 |
+
content: str # For reconstructing Memory in search results
|
| 43 |
+
created_at: datetime
|
| 44 |
+
importance: float
|
| 45 |
+
|
| 46 |
+
def to_dict(self) -> dict[str, Any]:
|
| 47 |
+
"""Serialize to dictionary for persistence."""
|
| 48 |
+
return {
|
| 49 |
+
"memory_id": self.memory_id,
|
| 50 |
+
"user_id": self.user_id,
|
| 51 |
+
"session_id": self.session_id,
|
| 52 |
+
"agent_id": self.agent_id,
|
| 53 |
+
"category": self.category,
|
| 54 |
+
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
| 55 |
+
"entity_refs": self.entity_refs,
|
| 56 |
+
"content": self.content,
|
| 57 |
+
"created_at": self.created_at.isoformat(),
|
| 58 |
+
"importance": self.importance,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
@classmethod
|
| 62 |
+
def from_dict(cls, data: dict[str, Any]) -> IndexedMemoryMetadata:
|
| 63 |
+
"""Deserialize from dictionary."""
|
| 64 |
+
return cls(
|
| 65 |
+
memory_id=data["memory_id"],
|
| 66 |
+
user_id=data["user_id"],
|
| 67 |
+
session_id=data.get("session_id"),
|
| 68 |
+
agent_id=data.get("agent_id"),
|
| 69 |
+
category=data["category"],
|
| 70 |
+
valid_until=(
|
| 71 |
+
datetime.fromisoformat(data["valid_until"]) if data.get("valid_until") else None
|
| 72 |
+
),
|
| 73 |
+
entity_refs=data.get("entity_refs", []),
|
| 74 |
+
content=data["content"],
|
| 75 |
+
created_at=datetime.fromisoformat(data["created_at"]),
|
| 76 |
+
importance=data.get("importance", 0.5),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def from_memory(cls, memory: Memory) -> IndexedMemoryMetadata:
|
| 81 |
+
"""Create metadata from a Memory object."""
|
| 82 |
+
category_value = (
|
| 83 |
+
memory.category.value
|
| 84 |
+
if isinstance(memory.category, MemoryCategory)
|
| 85 |
+
else memory.category
|
| 86 |
+
)
|
| 87 |
+
return cls(
|
| 88 |
+
memory_id=memory.id,
|
| 89 |
+
user_id=memory.user_id,
|
| 90 |
+
session_id=memory.session_id,
|
| 91 |
+
agent_id=memory.agent_id,
|
| 92 |
+
category=category_value,
|
| 93 |
+
valid_until=memory.valid_until,
|
| 94 |
+
entity_refs=memory.entity_refs.copy(),
|
| 95 |
+
content=memory.content,
|
| 96 |
+
created_at=memory.created_at,
|
| 97 |
+
importance=memory.importance,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def to_memory(self, embedding: np.ndarray | None = None) -> Memory:
|
| 101 |
+
"""Reconstruct a basic Memory object from metadata.
|
| 102 |
+
|
| 103 |
+
Note: This creates a partial Memory with only indexed fields.
|
| 104 |
+
For full Memory objects, retrieve from the MemoryStore.
|
| 105 |
+
"""
|
| 106 |
+
return Memory(
|
| 107 |
+
id=self.memory_id,
|
| 108 |
+
content=self.content,
|
| 109 |
+
user_id=self.user_id,
|
| 110 |
+
session_id=self.session_id,
|
| 111 |
+
agent_id=self.agent_id,
|
| 112 |
+
category=MemoryCategory(self.category),
|
| 113 |
+
valid_until=self.valid_until,
|
| 114 |
+
entity_refs=self.entity_refs.copy(),
|
| 115 |
+
created_at=self.created_at,
|
| 116 |
+
importance=self.importance,
|
| 117 |
+
embedding=embedding,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class HNSWVectorIndex:
|
| 122 |
+
"""HNSW-based vector index using hnswlib.
|
| 123 |
+
|
| 124 |
+
Features:
|
| 125 |
+
- Fast approximate nearest neighbor search with cosine similarity
|
| 126 |
+
- Configurable HNSW parameters (ef_construction, M, ef_search)
|
| 127 |
+
- Post-filtering by user_id, session_id, agent_id, category, entity_refs
|
| 128 |
+
- Bidirectional ID mapping (string memory_id <-> integer hnsw_id)
|
| 129 |
+
- Persistence support with save_index/load_index
|
| 130 |
+
- Thread-safe operations with Lock
|
| 131 |
+
- Optional auto-save on index modifications
|
| 132 |
+
|
| 133 |
+
Usage:
|
| 134 |
+
index = HNSWVectorIndex(dimension=384)
|
| 135 |
+
await index.index(memory_with_embedding)
|
| 136 |
+
results = await index.search(VectorFilter(
|
| 137 |
+
query_vector=query_embedding,
|
| 138 |
+
top_k=10,
|
| 139 |
+
user_id="alice"
|
| 140 |
+
))
|
| 141 |
+
|
| 142 |
+
HNSW Parameters:
|
| 143 |
+
- ef_construction: Size of dynamic candidate list during index construction.
|
| 144 |
+
Higher values give better quality but slower construction. Default: 200
|
| 145 |
+
- M: Number of bi-directional links per element. Higher values give
|
| 146 |
+
better recall but use more memory. Default: 16
|
| 147 |
+
- ef_search: Size of dynamic candidate list during search. Higher values
|
| 148 |
+
give better recall but slower search. Default: 50
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
def __init__(
|
| 152 |
+
self,
|
| 153 |
+
dimension: int = 384,
|
| 154 |
+
max_elements: int = 100000,
|
| 155 |
+
ef_construction: int = 200,
|
| 156 |
+
m: int = 16,
|
| 157 |
+
ef_search: int = 50,
|
| 158 |
+
auto_save: bool = False,
|
| 159 |
+
save_path: str | Path | None = None,
|
| 160 |
+
) -> None:
|
| 161 |
+
"""Initialize the HNSW vector index.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
dimension: Embedding dimension. Default 384 for MiniLM.
|
| 165 |
+
max_elements: Maximum number of elements the index can hold.
|
| 166 |
+
Can be resized later with resize_index().
|
| 167 |
+
ef_construction: HNSW construction parameter. Higher = better quality,
|
| 168 |
+
slower construction. Default: 200
|
| 169 |
+
m: HNSW links per element. Higher = better recall, more memory.
|
| 170 |
+
Default: 16
|
| 171 |
+
ef_search: HNSW search parameter. Higher = better recall, slower
|
| 172 |
+
search. Default: 50
|
| 173 |
+
auto_save: If True and save_path is set, automatically save
|
| 174 |
+
index after modifications.
|
| 175 |
+
save_path: Path for auto-save operations. Required if auto_save=True.
|
| 176 |
+
|
| 177 |
+
Raises:
|
| 178 |
+
ValueError: If auto_save is True but save_path is not provided.
|
| 179 |
+
"""
|
| 180 |
+
if auto_save and save_path is None:
|
| 181 |
+
raise ValueError("save_path must be provided when auto_save is True")
|
| 182 |
+
|
| 183 |
+
self._dimension = dimension
|
| 184 |
+
self._max_elements = max_elements
|
| 185 |
+
self._ef_construction = ef_construction
|
| 186 |
+
self._m = m
|
| 187 |
+
self._ef_search = ef_search
|
| 188 |
+
self._auto_save = auto_save
|
| 189 |
+
self._save_path = Path(save_path) if save_path else None
|
| 190 |
+
|
| 191 |
+
# Initialize HNSW index with cosine similarity
|
| 192 |
+
# hnswlib uses 'cosine' space which internally normalizes vectors
|
| 193 |
+
self._index = hnswlib.Index(space="cosine", dim=dimension)
|
| 194 |
+
self._index.init_index(
|
| 195 |
+
max_elements=max_elements,
|
| 196 |
+
ef_construction=ef_construction,
|
| 197 |
+
M=m,
|
| 198 |
+
)
|
| 199 |
+
self._index.set_ef(ef_search)
|
| 200 |
+
|
| 201 |
+
# ID mappings: string memory_id <-> integer hnsw_id
|
| 202 |
+
self._memory_to_hnsw: dict[str, int] = {}
|
| 203 |
+
self._hnsw_to_memory: dict[int, str] = {}
|
| 204 |
+
self._next_hnsw_id: int = 0
|
| 205 |
+
|
| 206 |
+
# Metadata storage for filtering
|
| 207 |
+
self._metadata: dict[str, IndexedMemoryMetadata] = {}
|
| 208 |
+
|
| 209 |
+
# Embedding storage for retrieval
|
| 210 |
+
self._embeddings: dict[str, np.ndarray] = {}
|
| 211 |
+
|
| 212 |
+
# Thread safety
|
| 213 |
+
self._lock = Lock()
|
| 214 |
+
|
| 215 |
+
@property
|
| 216 |
+
def dimension(self) -> int:
|
| 217 |
+
"""Return the embedding dimension this index expects."""
|
| 218 |
+
return self._dimension
|
| 219 |
+
|
| 220 |
+
@property
|
| 221 |
+
def size(self) -> int:
|
| 222 |
+
"""Return the number of vectors currently indexed."""
|
| 223 |
+
with self._lock:
|
| 224 |
+
return len(self._memory_to_hnsw)
|
| 225 |
+
|
| 226 |
+
async def index(self, memory: Memory) -> None:
|
| 227 |
+
"""Index a memory's embedding for similarity search.
|
| 228 |
+
|
| 229 |
+
The memory must have an embedding set.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
memory: The memory to index.
|
| 233 |
+
|
| 234 |
+
Raises:
|
| 235 |
+
ValueError: If the memory has no embedding or wrong dimension.
|
| 236 |
+
"""
|
| 237 |
+
if memory.embedding is None:
|
| 238 |
+
raise ValueError(f"Memory {memory.id} has no embedding")
|
| 239 |
+
|
| 240 |
+
embedding = np.asarray(memory.embedding, dtype=np.float32)
|
| 241 |
+
if embedding.shape[0] != self._dimension:
|
| 242 |
+
raise ValueError(
|
| 243 |
+
f"Embedding dimension {embedding.shape[0]} does not match "
|
| 244 |
+
f"index dimension {self._dimension}"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
with self._lock:
|
| 248 |
+
# Check if already indexed - update if so
|
| 249 |
+
if memory.id in self._memory_to_hnsw:
|
| 250 |
+
await self._update_embedding_internal(memory.id, embedding)
|
| 251 |
+
# Update metadata
|
| 252 |
+
self._metadata[memory.id] = IndexedMemoryMetadata.from_memory(memory)
|
| 253 |
+
else:
|
| 254 |
+
# Resize if needed
|
| 255 |
+
if self._next_hnsw_id >= self._max_elements:
|
| 256 |
+
self._resize_index(self._max_elements * 2)
|
| 257 |
+
|
| 258 |
+
# Add to HNSW index
|
| 259 |
+
hnsw_id = self._next_hnsw_id
|
| 260 |
+
self._index.add_items(
|
| 261 |
+
embedding.reshape(1, -1),
|
| 262 |
+
np.array([hnsw_id]),
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Update mappings
|
| 266 |
+
self._memory_to_hnsw[memory.id] = hnsw_id
|
| 267 |
+
self._hnsw_to_memory[hnsw_id] = memory.id
|
| 268 |
+
self._next_hnsw_id += 1
|
| 269 |
+
|
| 270 |
+
# Store metadata and embedding
|
| 271 |
+
self._metadata[memory.id] = IndexedMemoryMetadata.from_memory(memory)
|
| 272 |
+
self._embeddings[memory.id] = embedding.copy()
|
| 273 |
+
|
| 274 |
+
if self._auto_save and self._save_path:
|
| 275 |
+
self.save_index(self._save_path)
|
| 276 |
+
|
| 277 |
+
async def index_batch(self, memories: list[Memory]) -> int:
|
| 278 |
+
"""Index multiple memories' embeddings.
|
| 279 |
+
|
| 280 |
+
Memories without embeddings are skipped.
|
| 281 |
+
|
| 282 |
+
Args:
|
| 283 |
+
memories: List of memories to index.
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Number of memories actually indexed.
|
| 287 |
+
"""
|
| 288 |
+
# Filter memories with valid embeddings
|
| 289 |
+
valid_memories: list[tuple[Memory, np.ndarray]] = []
|
| 290 |
+
for memory in memories:
|
| 291 |
+
if memory.embedding is not None:
|
| 292 |
+
embedding = np.asarray(memory.embedding, dtype=np.float32)
|
| 293 |
+
if embedding.shape[0] == self._dimension:
|
| 294 |
+
valid_memories.append((memory, embedding))
|
| 295 |
+
|
| 296 |
+
if not valid_memories:
|
| 297 |
+
return 0
|
| 298 |
+
|
| 299 |
+
with self._lock:
|
| 300 |
+
# Separate new memories from updates
|
| 301 |
+
new_memories: list[tuple[Memory, np.ndarray, int]] = []
|
| 302 |
+
update_memories: list[tuple[Memory, np.ndarray]] = []
|
| 303 |
+
|
| 304 |
+
for memory, embedding in valid_memories:
|
| 305 |
+
if memory.id in self._memory_to_hnsw:
|
| 306 |
+
update_memories.append((memory, embedding))
|
| 307 |
+
else:
|
| 308 |
+
hnsw_id = self._next_hnsw_id
|
| 309 |
+
new_memories.append((memory, embedding, hnsw_id))
|
| 310 |
+
self._next_hnsw_id += 1
|
| 311 |
+
|
| 312 |
+
# Resize if needed
|
| 313 |
+
required_capacity = len(self._memory_to_hnsw) + len(new_memories)
|
| 314 |
+
if required_capacity > self._max_elements:
|
| 315 |
+
new_max = max(self._max_elements * 2, required_capacity + 1000)
|
| 316 |
+
self._resize_index(new_max)
|
| 317 |
+
|
| 318 |
+
# Batch add new memories
|
| 319 |
+
if new_memories:
|
| 320 |
+
embeddings_array = np.vstack([emb for _, emb, _ in new_memories]).astype(np.float32)
|
| 321 |
+
ids_array = np.array([hid for _, _, hid in new_memories])
|
| 322 |
+
|
| 323 |
+
self._index.add_items(embeddings_array, ids_array)
|
| 324 |
+
|
| 325 |
+
# Update mappings and metadata
|
| 326 |
+
for memory, embedding, hnsw_id in new_memories:
|
| 327 |
+
self._memory_to_hnsw[memory.id] = hnsw_id
|
| 328 |
+
self._hnsw_to_memory[hnsw_id] = memory.id
|
| 329 |
+
self._metadata[memory.id] = IndexedMemoryMetadata.from_memory(memory)
|
| 330 |
+
self._embeddings[memory.id] = embedding.copy()
|
| 331 |
+
|
| 332 |
+
# Handle updates (hnswlib doesn't support true updates, so we track separately)
|
| 333 |
+
for memory, embedding in update_memories:
|
| 334 |
+
self._metadata[memory.id] = IndexedMemoryMetadata.from_memory(memory)
|
| 335 |
+
self._embeddings[memory.id] = embedding.copy()
|
| 336 |
+
# Note: HNSW embedding stays unchanged unless we remove and re-add
|
| 337 |
+
|
| 338 |
+
if self._auto_save and self._save_path:
|
| 339 |
+
self.save_index(self._save_path)
|
| 340 |
+
|
| 341 |
+
return len(valid_memories)
|
| 342 |
+
|
| 343 |
+
async def remove(self, memory_id: str) -> bool:
|
| 344 |
+
"""Remove a memory from the vector index.
|
| 345 |
+
|
| 346 |
+
Note: hnswlib doesn't support true deletion. We mark the item as deleted
|
| 347 |
+
and exclude it from results. The space is reclaimed on next save/load.
|
| 348 |
+
|
| 349 |
+
Args:
|
| 350 |
+
memory_id: The unique identifier of the memory.
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
True if removed, False if not found.
|
| 354 |
+
"""
|
| 355 |
+
with self._lock:
|
| 356 |
+
if memory_id not in self._memory_to_hnsw:
|
| 357 |
+
return False
|
| 358 |
+
|
| 359 |
+
hnsw_id = self._memory_to_hnsw[memory_id]
|
| 360 |
+
|
| 361 |
+
# Mark as deleted in HNSW index
|
| 362 |
+
self._index.mark_deleted(hnsw_id)
|
| 363 |
+
|
| 364 |
+
# Remove from our mappings
|
| 365 |
+
del self._memory_to_hnsw[memory_id]
|
| 366 |
+
del self._hnsw_to_memory[hnsw_id]
|
| 367 |
+
|
| 368 |
+
# Remove metadata and embedding
|
| 369 |
+
if memory_id in self._metadata:
|
| 370 |
+
del self._metadata[memory_id]
|
| 371 |
+
if memory_id in self._embeddings:
|
| 372 |
+
del self._embeddings[memory_id]
|
| 373 |
+
|
| 374 |
+
if self._auto_save and self._save_path:
|
| 375 |
+
self.save_index(self._save_path)
|
| 376 |
+
|
| 377 |
+
return True
|
| 378 |
+
|
| 379 |
+
async def remove_batch(self, memory_ids: list[str]) -> int:
|
| 380 |
+
"""Remove multiple memories from the vector index.
|
| 381 |
+
|
| 382 |
+
Args:
|
| 383 |
+
memory_ids: List of memory IDs to remove.
|
| 384 |
+
|
| 385 |
+
Returns:
|
| 386 |
+
Number of memories actually removed.
|
| 387 |
+
"""
|
| 388 |
+
removed_count = 0
|
| 389 |
+
|
| 390 |
+
with self._lock:
|
| 391 |
+
for memory_id in memory_ids:
|
| 392 |
+
if memory_id not in self._memory_to_hnsw:
|
| 393 |
+
continue
|
| 394 |
+
|
| 395 |
+
hnsw_id = self._memory_to_hnsw[memory_id]
|
| 396 |
+
|
| 397 |
+
# Mark as deleted in HNSW index
|
| 398 |
+
self._index.mark_deleted(hnsw_id)
|
| 399 |
+
|
| 400 |
+
# Remove from our mappings
|
| 401 |
+
del self._memory_to_hnsw[memory_id]
|
| 402 |
+
del self._hnsw_to_memory[hnsw_id]
|
| 403 |
+
|
| 404 |
+
# Remove metadata and embedding
|
| 405 |
+
if memory_id in self._metadata:
|
| 406 |
+
del self._metadata[memory_id]
|
| 407 |
+
if memory_id in self._embeddings:
|
| 408 |
+
del self._embeddings[memory_id]
|
| 409 |
+
|
| 410 |
+
removed_count += 1
|
| 411 |
+
|
| 412 |
+
if removed_count > 0 and self._auto_save and self._save_path:
|
| 413 |
+
self.save_index(self._save_path)
|
| 414 |
+
|
| 415 |
+
return removed_count
|
| 416 |
+
|
| 417 |
+
async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
|
| 418 |
+
"""Search for similar memories using vector similarity.
|
| 419 |
+
|
| 420 |
+
Args:
|
| 421 |
+
filter: Vector search filter with query and constraints.
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
List of search results sorted by similarity (descending).
|
| 425 |
+
|
| 426 |
+
Raises:
|
| 427 |
+
ValueError: If neither query_vector nor query_text is provided,
|
| 428 |
+
or if query_text is provided (embedding must be done externally).
|
| 429 |
+
"""
|
| 430 |
+
if filter.query_vector is None:
|
| 431 |
+
if filter.query_text is not None:
|
| 432 |
+
raise ValueError(
|
| 433 |
+
"query_text provided but HNSWVectorIndex does not embed text. "
|
| 434 |
+
"Provide query_vector directly or use an Embedder first."
|
| 435 |
+
)
|
| 436 |
+
raise ValueError("Either query_vector or query_text must be provided")
|
| 437 |
+
|
| 438 |
+
query_vector = np.asarray(filter.query_vector, dtype=np.float32)
|
| 439 |
+
if query_vector.shape[0] != self._dimension:
|
| 440 |
+
raise ValueError(
|
| 441 |
+
f"Query vector dimension {query_vector.shape[0]} does not match "
|
| 442 |
+
f"index dimension {self._dimension}"
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
with self._lock:
|
| 446 |
+
# NOTE: Use len() directly, not self.size - Lock is not reentrant!
|
| 447 |
+
current_size = len(self._memory_to_hnsw)
|
| 448 |
+
if current_size == 0:
|
| 449 |
+
return []
|
| 450 |
+
|
| 451 |
+
# Search with more results than needed to account for filtering
|
| 452 |
+
# Retrieve extra candidates to improve recall after filtering
|
| 453 |
+
k_with_buffer = min(
|
| 454 |
+
filter.top_k * 10, # Get 10x candidates for filtering
|
| 455 |
+
current_size, # But not more than we have
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# Query HNSW index
|
| 459 |
+
# Returns (labels, distances) where labels are hnsw_ids
|
| 460 |
+
labels, distances = self._index.knn_query(
|
| 461 |
+
query_vector.reshape(1, -1),
|
| 462 |
+
k=k_with_buffer,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
# Convert distances to similarities
|
| 466 |
+
# hnswlib with 'cosine' space returns 1 - cosine_similarity
|
| 467 |
+
# So similarity = 1 - distance
|
| 468 |
+
similarities = 1.0 - distances[0]
|
| 469 |
+
|
| 470 |
+
# Build results with post-filtering
|
| 471 |
+
results: list[VectorSearchResult] = []
|
| 472 |
+
|
| 473 |
+
for hnsw_id, similarity in zip(labels[0], similarities):
|
| 474 |
+
# Skip if not in our mapping (deleted)
|
| 475 |
+
if hnsw_id not in self._hnsw_to_memory:
|
| 476 |
+
continue
|
| 477 |
+
|
| 478 |
+
memory_id = self._hnsw_to_memory[hnsw_id]
|
| 479 |
+
|
| 480 |
+
# Skip if below minimum similarity
|
| 481 |
+
if similarity < filter.min_similarity:
|
| 482 |
+
continue
|
| 483 |
+
|
| 484 |
+
# Get metadata for filtering
|
| 485 |
+
metadata = self._metadata.get(memory_id)
|
| 486 |
+
if metadata is None:
|
| 487 |
+
continue
|
| 488 |
+
|
| 489 |
+
# Apply filters
|
| 490 |
+
if not self._passes_filter(metadata, filter):
|
| 491 |
+
continue
|
| 492 |
+
|
| 493 |
+
# Get stored embedding
|
| 494 |
+
embedding = self._embeddings.get(memory_id)
|
| 495 |
+
|
| 496 |
+
# Create Memory from metadata
|
| 497 |
+
memory = metadata.to_memory(embedding=embedding)
|
| 498 |
+
|
| 499 |
+
results.append(
|
| 500 |
+
VectorSearchResult(
|
| 501 |
+
memory=memory,
|
| 502 |
+
similarity=float(similarity),
|
| 503 |
+
rank=0, # Will be set after sorting
|
| 504 |
+
)
|
| 505 |
+
)
|
| 506 |
+
|
| 507 |
+
# Stop if we have enough results
|
| 508 |
+
if len(results) >= filter.top_k:
|
| 509 |
+
break
|
| 510 |
+
|
| 511 |
+
# Sort by similarity (descending) and assign ranks
|
| 512 |
+
results.sort(key=lambda r: r.similarity, reverse=True)
|
| 513 |
+
for i, result in enumerate(results):
|
| 514 |
+
result.rank = i + 1
|
| 515 |
+
|
| 516 |
+
return results[: filter.top_k]
|
| 517 |
+
|
| 518 |
+
def _passes_filter(
|
| 519 |
+
self,
|
| 520 |
+
metadata: IndexedMemoryMetadata,
|
| 521 |
+
filter: VectorFilter,
|
| 522 |
+
) -> bool:
|
| 523 |
+
"""Check if metadata passes all filter constraints.
|
| 524 |
+
|
| 525 |
+
Args:
|
| 526 |
+
metadata: The indexed memory metadata.
|
| 527 |
+
filter: The vector filter with constraints.
|
| 528 |
+
|
| 529 |
+
Returns:
|
| 530 |
+
True if all filter constraints pass, False otherwise.
|
| 531 |
+
"""
|
| 532 |
+
# User ID filter
|
| 533 |
+
if filter.user_id is not None and metadata.user_id != filter.user_id:
|
| 534 |
+
return False
|
| 535 |
+
|
| 536 |
+
# Session ID filter
|
| 537 |
+
if filter.session_id is not None and metadata.session_id != filter.session_id:
|
| 538 |
+
return False
|
| 539 |
+
|
| 540 |
+
# Agent ID filter
|
| 541 |
+
if filter.agent_id is not None and metadata.agent_id != filter.agent_id:
|
| 542 |
+
return False
|
| 543 |
+
|
| 544 |
+
# Scope level filter
|
| 545 |
+
if filter.scope_levels is not None:
|
| 546 |
+
# Determine the memory's scope level
|
| 547 |
+
if metadata.agent_id is not None:
|
| 548 |
+
memory_scope = ScopeLevel.AGENT
|
| 549 |
+
elif metadata.session_id is not None:
|
| 550 |
+
memory_scope = ScopeLevel.SESSION
|
| 551 |
+
else:
|
| 552 |
+
memory_scope = ScopeLevel.USER
|
| 553 |
+
|
| 554 |
+
if memory_scope not in filter.scope_levels:
|
| 555 |
+
return False
|
| 556 |
+
|
| 557 |
+
# Category filter
|
| 558 |
+
if filter.categories is not None:
|
| 559 |
+
category_values = []
|
| 560 |
+
for cat in filter.categories:
|
| 561 |
+
if isinstance(cat, MemoryCategory):
|
| 562 |
+
category_values.append(cat.value)
|
| 563 |
+
else:
|
| 564 |
+
category_values.append(cat)
|
| 565 |
+
|
| 566 |
+
if metadata.category not in category_values:
|
| 567 |
+
return False
|
| 568 |
+
|
| 569 |
+
# Temporal filter - valid_at
|
| 570 |
+
if filter.valid_at is not None:
|
| 571 |
+
# Memory must be valid at the specified time
|
| 572 |
+
# If valid_until is None, memory is current (always valid after creation)
|
| 573 |
+
# If valid_until is set, memory must have been valid at that time
|
| 574 |
+
if metadata.valid_until is not None:
|
| 575 |
+
if filter.valid_at > metadata.valid_until:
|
| 576 |
+
return False
|
| 577 |
+
|
| 578 |
+
# Superseded filter
|
| 579 |
+
if not filter.include_superseded:
|
| 580 |
+
# Exclude superseded memories (those with valid_until set)
|
| 581 |
+
if metadata.valid_until is not None:
|
| 582 |
+
return False
|
| 583 |
+
|
| 584 |
+
# Entity refs filter - any match
|
| 585 |
+
if filter.entity_refs is not None and len(filter.entity_refs) > 0:
|
| 586 |
+
if not any(ref in metadata.entity_refs for ref in filter.entity_refs):
|
| 587 |
+
return False
|
| 588 |
+
|
| 589 |
+
# Metadata filters (custom key-value pairs)
|
| 590 |
+
# Note: We don't store custom metadata in IndexedMemoryMetadata
|
| 591 |
+
# This would require extending the metadata storage
|
| 592 |
+
|
| 593 |
+
return True
|
| 594 |
+
|
| 595 |
+
async def update_embedding(self, memory_id: str, embedding: np.ndarray) -> bool:
|
| 596 |
+
"""Update the embedding for an indexed memory.
|
| 597 |
+
|
| 598 |
+
Note: hnswlib doesn't support in-place updates. This stores the new
|
| 599 |
+
embedding but the HNSW graph uses the original. For full update,
|
| 600 |
+
remove and re-index the memory.
|
| 601 |
+
|
| 602 |
+
Args:
|
| 603 |
+
memory_id: The unique identifier of the memory.
|
| 604 |
+
embedding: The new embedding vector.
|
| 605 |
+
|
| 606 |
+
Returns:
|
| 607 |
+
True if updated, False if memory not found in index.
|
| 608 |
+
"""
|
| 609 |
+
embedding = np.asarray(embedding, dtype=np.float32)
|
| 610 |
+
if embedding.shape[0] != self._dimension:
|
| 611 |
+
raise ValueError(
|
| 612 |
+
f"Embedding dimension {embedding.shape[0]} does not match "
|
| 613 |
+
f"index dimension {self._dimension}"
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
with self._lock:
|
| 617 |
+
result = await self._update_embedding_internal(memory_id, embedding)
|
| 618 |
+
|
| 619 |
+
if result and self._auto_save and self._save_path:
|
| 620 |
+
self.save_index(self._save_path)
|
| 621 |
+
|
| 622 |
+
return result
|
| 623 |
+
|
| 624 |
+
async def _update_embedding_internal(self, memory_id: str, embedding: np.ndarray) -> bool:
|
| 625 |
+
"""Internal embedding update without lock (caller must hold lock).
|
| 626 |
+
|
| 627 |
+
hnswlib doesn't support true in-place updates, so we:
|
| 628 |
+
1. Store the new embedding in our local cache
|
| 629 |
+
2. The HNSW index continues to use the old embedding for search
|
| 630 |
+
|
| 631 |
+
For a true update, the caller should remove and re-index.
|
| 632 |
+
"""
|
| 633 |
+
if memory_id not in self._memory_to_hnsw:
|
| 634 |
+
return False
|
| 635 |
+
|
| 636 |
+
self._embeddings[memory_id] = embedding.copy()
|
| 637 |
+
return True
|
| 638 |
+
|
| 639 |
+
def _resize_index(self, new_max_elements: int) -> None:
|
| 640 |
+
"""Resize the HNSW index to accommodate more elements.
|
| 641 |
+
|
| 642 |
+
Must be called with lock held.
|
| 643 |
+
|
| 644 |
+
Args:
|
| 645 |
+
new_max_elements: New maximum capacity.
|
| 646 |
+
"""
|
| 647 |
+
self._index.resize_index(new_max_elements)
|
| 648 |
+
self._max_elements = new_max_elements
|
| 649 |
+
|
| 650 |
+
def save_index(self, path: str | Path) -> None:
|
| 651 |
+
"""Save the index to disk.
|
| 652 |
+
|
| 653 |
+
Saves both the HNSW index and all metadata/mappings.
|
| 654 |
+
|
| 655 |
+
Args:
|
| 656 |
+
path: Base path for the saved files. Will create:
|
| 657 |
+
- {path}.hnsw - The HNSW index
|
| 658 |
+
- {path}.meta - Metadata and mappings (pickled)
|
| 659 |
+
"""
|
| 660 |
+
path = Path(path)
|
| 661 |
+
|
| 662 |
+
with self._lock:
|
| 663 |
+
# Save HNSW index
|
| 664 |
+
hnsw_path = path.with_suffix(".hnsw")
|
| 665 |
+
self._index.save_index(str(hnsw_path))
|
| 666 |
+
|
| 667 |
+
# Save metadata, mappings, and embeddings
|
| 668 |
+
meta_path = path.with_suffix(".meta")
|
| 669 |
+
meta_data = {
|
| 670 |
+
"dimension": self._dimension,
|
| 671 |
+
"max_elements": self._max_elements,
|
| 672 |
+
"ef_construction": self._ef_construction,
|
| 673 |
+
"m": self._m,
|
| 674 |
+
"ef_search": self._ef_search,
|
| 675 |
+
"memory_to_hnsw": self._memory_to_hnsw,
|
| 676 |
+
"hnsw_to_memory": self._hnsw_to_memory,
|
| 677 |
+
"next_hnsw_id": self._next_hnsw_id,
|
| 678 |
+
"metadata": {mid: meta.to_dict() for mid, meta in self._metadata.items()},
|
| 679 |
+
"embeddings": {mid: emb.tolist() for mid, emb in self._embeddings.items()},
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
with open(meta_path, "w") as f:
|
| 683 |
+
json.dump(meta_data, f)
|
| 684 |
+
|
| 685 |
+
def load_index(self, path: str | Path) -> None:
|
| 686 |
+
"""Load the index from disk.
|
| 687 |
+
|
| 688 |
+
Loads both the HNSW index and all metadata/mappings.
|
| 689 |
+
|
| 690 |
+
Args:
|
| 691 |
+
path: Base path for the saved files.
|
| 692 |
+
|
| 693 |
+
Raises:
|
| 694 |
+
FileNotFoundError: If the index files don't exist.
|
| 695 |
+
ValueError: If the saved dimension doesn't match.
|
| 696 |
+
"""
|
| 697 |
+
path = Path(path)
|
| 698 |
+
hnsw_path = path.with_suffix(".hnsw")
|
| 699 |
+
meta_path = path.with_suffix(".meta")
|
| 700 |
+
|
| 701 |
+
if not hnsw_path.exists():
|
| 702 |
+
raise FileNotFoundError(f"HNSW index not found: {hnsw_path}")
|
| 703 |
+
if not meta_path.exists():
|
| 704 |
+
raise FileNotFoundError(f"Metadata file not found: {meta_path}")
|
| 705 |
+
|
| 706 |
+
# Load metadata first to get parameters
|
| 707 |
+
with open(meta_path) as f:
|
| 708 |
+
meta_data = json.load(f)
|
| 709 |
+
|
| 710 |
+
# Verify dimension matches
|
| 711 |
+
saved_dimension = meta_data["dimension"]
|
| 712 |
+
if saved_dimension != self._dimension:
|
| 713 |
+
raise ValueError(
|
| 714 |
+
f"Saved index dimension {saved_dimension} does not match "
|
| 715 |
+
f"current dimension {self._dimension}"
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
with self._lock:
|
| 719 |
+
# Update parameters
|
| 720 |
+
self._max_elements = meta_data["max_elements"]
|
| 721 |
+
self._ef_construction = meta_data["ef_construction"]
|
| 722 |
+
self._m = meta_data["m"]
|
| 723 |
+
self._ef_search = meta_data["ef_search"]
|
| 724 |
+
|
| 725 |
+
# Create new index and load from file
|
| 726 |
+
self._index = hnswlib.Index(space="cosine", dim=self._dimension)
|
| 727 |
+
self._index.load_index(
|
| 728 |
+
str(hnsw_path),
|
| 729 |
+
max_elements=self._max_elements,
|
| 730 |
+
)
|
| 731 |
+
self._index.set_ef(self._ef_search)
|
| 732 |
+
|
| 733 |
+
# Restore mappings
|
| 734 |
+
# JSON converts int keys to strings, so we need to convert back
|
| 735 |
+
self._memory_to_hnsw = meta_data["memory_to_hnsw"]
|
| 736 |
+
self._hnsw_to_memory = {int(k): v for k, v in meta_data["hnsw_to_memory"].items()}
|
| 737 |
+
self._next_hnsw_id = meta_data["next_hnsw_id"]
|
| 738 |
+
|
| 739 |
+
# Restore metadata
|
| 740 |
+
self._metadata = {
|
| 741 |
+
mid: IndexedMemoryMetadata.from_dict(meta_dict)
|
| 742 |
+
for mid, meta_dict in meta_data["metadata"].items()
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
# Restore embeddings
|
| 746 |
+
self._embeddings = {
|
| 747 |
+
mid: np.array(emb, dtype=np.float32) for mid, emb in meta_data["embeddings"].items()
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
def clear(self) -> None:
|
| 751 |
+
"""Clear all entries from the index."""
|
| 752 |
+
with self._lock:
|
| 753 |
+
# Reinitialize the index
|
| 754 |
+
self._index = hnswlib.Index(space="cosine", dim=self._dimension)
|
| 755 |
+
self._index.init_index(
|
| 756 |
+
max_elements=self._max_elements,
|
| 757 |
+
ef_construction=self._ef_construction,
|
| 758 |
+
M=self._m,
|
| 759 |
+
)
|
| 760 |
+
self._index.set_ef(self._ef_search)
|
| 761 |
+
|
| 762 |
+
# Clear all mappings and metadata
|
| 763 |
+
self._memory_to_hnsw.clear()
|
| 764 |
+
self._hnsw_to_memory.clear()
|
| 765 |
+
self._next_hnsw_id = 0
|
| 766 |
+
self._metadata.clear()
|
| 767 |
+
self._embeddings.clear()
|
| 768 |
+
|
| 769 |
+
if self._auto_save and self._save_path:
|
| 770 |
+
self.save_index(self._save_path)
|
| 771 |
+
|
| 772 |
+
def stats(self) -> dict[str, Any]:
|
| 773 |
+
"""Get index statistics.
|
| 774 |
+
|
| 775 |
+
Returns:
|
| 776 |
+
Dictionary with index metrics.
|
| 777 |
+
"""
|
| 778 |
+
with self._lock:
|
| 779 |
+
return {
|
| 780 |
+
"size": len(self._memory_to_hnsw),
|
| 781 |
+
"dimension": self._dimension,
|
| 782 |
+
"max_elements": self._max_elements,
|
| 783 |
+
"ef_construction": self._ef_construction,
|
| 784 |
+
"m": self._m,
|
| 785 |
+
"ef_search": self._ef_search,
|
| 786 |
+
"utilization": (
|
| 787 |
+
(len(self._memory_to_hnsw) / self._max_elements) * 100
|
| 788 |
+
if self._max_elements > 0
|
| 789 |
+
else 0.0
|
| 790 |
+
),
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
def set_ef_search(self, ef_search: int) -> None:
|
| 794 |
+
"""Update the ef_search parameter for query time.
|
| 795 |
+
|
| 796 |
+
Higher values give better recall but slower search.
|
| 797 |
+
|
| 798 |
+
Args:
|
| 799 |
+
ef_search: New ef_search value.
|
| 800 |
+
"""
|
| 801 |
+
with self._lock:
|
| 802 |
+
self._ef_search = ef_search
|
| 803 |
+
self._index.set_ef(ef_search)
|
|
@@ -0,0 +1,752 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite memory store for Headroom's hierarchical memory system.
|
| 2 |
+
|
| 3 |
+
Provides persistent storage for Memory objects with full support for:
|
| 4 |
+
- Hierarchical scope filtering (user/session/agent/turn)
|
| 5 |
+
- Temporal versioning with supersession chains
|
| 6 |
+
- Point-in-time queries
|
| 7 |
+
- Efficient batch operations
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sqlite3
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from ..models import Memory, MemoryCategory, ScopeLevel
|
| 21 |
+
from ..ports import MemoryFilter
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SQLiteMemoryStore:
|
| 25 |
+
"""SQLite-based memory store implementing the MemoryStore protocol.
|
| 26 |
+
|
| 27 |
+
Features:
|
| 28 |
+
- Full CRUD operations with batch support
|
| 29 |
+
- Hierarchical scope filtering (user -> session -> agent -> turn)
|
| 30 |
+
- Temporal versioning with supersession chains
|
| 31 |
+
- Point-in-time queries via valid_at filter
|
| 32 |
+
- Thread-safe: connection-per-request pattern
|
| 33 |
+
|
| 34 |
+
Usage:
|
| 35 |
+
store = SQLiteMemoryStore("./memories.db")
|
| 36 |
+
await store.save(memory)
|
| 37 |
+
memories = await store.query(MemoryFilter(user_id="alice"))
|
| 38 |
+
|
| 39 |
+
Schema:
|
| 40 |
+
The memories table stores all Memory fields with appropriate indexes
|
| 41 |
+
for efficient querying by scope, category, importance, and time.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, db_path: str | Path = "headroom_memory.db") -> None:
|
| 45 |
+
"""Initialize the SQLite memory store.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
db_path: Path to SQLite database file. Created if it doesn't exist.
|
| 49 |
+
"""
|
| 50 |
+
self.db_path = Path(db_path)
|
| 51 |
+
self._init_db()
|
| 52 |
+
|
| 53 |
+
def _get_conn(self) -> sqlite3.Connection:
|
| 54 |
+
"""Get a new database connection (thread-safe pattern).
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
A new SQLite connection with row factory configured.
|
| 58 |
+
"""
|
| 59 |
+
conn = sqlite3.connect(str(self.db_path))
|
| 60 |
+
conn.row_factory = sqlite3.Row
|
| 61 |
+
return conn
|
| 62 |
+
|
| 63 |
+
def _init_db(self) -> None:
|
| 64 |
+
"""Initialize the database schema with indexes."""
|
| 65 |
+
with self._get_conn() as conn:
|
| 66 |
+
# Create memories table
|
| 67 |
+
conn.execute("""
|
| 68 |
+
CREATE TABLE IF NOT EXISTS memories (
|
| 69 |
+
id TEXT PRIMARY KEY,
|
| 70 |
+
content TEXT NOT NULL,
|
| 71 |
+
|
| 72 |
+
-- Hierarchical scoping
|
| 73 |
+
user_id TEXT NOT NULL,
|
| 74 |
+
session_id TEXT,
|
| 75 |
+
agent_id TEXT,
|
| 76 |
+
turn_id TEXT,
|
| 77 |
+
|
| 78 |
+
-- Temporal
|
| 79 |
+
created_at TEXT NOT NULL,
|
| 80 |
+
valid_from TEXT NOT NULL,
|
| 81 |
+
valid_until TEXT,
|
| 82 |
+
|
| 83 |
+
-- Classification
|
| 84 |
+
category TEXT NOT NULL,
|
| 85 |
+
importance REAL NOT NULL DEFAULT 0.5,
|
| 86 |
+
|
| 87 |
+
-- Lineage
|
| 88 |
+
supersedes TEXT,
|
| 89 |
+
superseded_by TEXT,
|
| 90 |
+
promoted_from TEXT,
|
| 91 |
+
promotion_chain TEXT NOT NULL DEFAULT '[]',
|
| 92 |
+
|
| 93 |
+
-- Access tracking
|
| 94 |
+
access_count INTEGER NOT NULL DEFAULT 0,
|
| 95 |
+
last_accessed TEXT,
|
| 96 |
+
|
| 97 |
+
-- Entity references (JSON array)
|
| 98 |
+
entity_refs TEXT NOT NULL DEFAULT '[]',
|
| 99 |
+
|
| 100 |
+
-- Embedding (BLOB for numpy array)
|
| 101 |
+
embedding BLOB,
|
| 102 |
+
|
| 103 |
+
-- Metadata (JSON object)
|
| 104 |
+
metadata TEXT NOT NULL DEFAULT '{}'
|
| 105 |
+
)
|
| 106 |
+
""")
|
| 107 |
+
|
| 108 |
+
# Create indexes for efficient querying
|
| 109 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)")
|
| 110 |
+
conn.execute(
|
| 111 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_session_id ON memories(session_id)"
|
| 112 |
+
)
|
| 113 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id)")
|
| 114 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_turn_id ON memories(turn_id)")
|
| 115 |
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)")
|
| 116 |
+
conn.execute(
|
| 117 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance)"
|
| 118 |
+
)
|
| 119 |
+
conn.execute(
|
| 120 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at)"
|
| 121 |
+
)
|
| 122 |
+
conn.execute(
|
| 123 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_valid_until ON memories(valid_until)"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Composite index for common scope queries
|
| 127 |
+
conn.execute("""
|
| 128 |
+
CREATE INDEX IF NOT EXISTS idx_memories_scope
|
| 129 |
+
ON memories(user_id, session_id, agent_id, turn_id)
|
| 130 |
+
""")
|
| 131 |
+
|
| 132 |
+
# Index for supersession chain traversal
|
| 133 |
+
conn.execute(
|
| 134 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_supersedes ON memories(supersedes)"
|
| 135 |
+
)
|
| 136 |
+
conn.execute(
|
| 137 |
+
"CREATE INDEX IF NOT EXISTS idx_memories_superseded_by ON memories(superseded_by)"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
conn.commit()
|
| 141 |
+
|
| 142 |
+
def _serialize_embedding(self, embedding: np.ndarray | None) -> bytes | None:
|
| 143 |
+
"""Serialize numpy array to bytes for BLOB storage."""
|
| 144 |
+
if embedding is None:
|
| 145 |
+
return None
|
| 146 |
+
return embedding.astype(np.float32).tobytes()
|
| 147 |
+
|
| 148 |
+
def _deserialize_embedding(
|
| 149 |
+
self, data: bytes | None, dim: int | None = None
|
| 150 |
+
) -> np.ndarray | None:
|
| 151 |
+
"""Deserialize bytes back to numpy array."""
|
| 152 |
+
if data is None:
|
| 153 |
+
return None
|
| 154 |
+
arr = np.frombuffer(data, dtype=np.float32)
|
| 155 |
+
return arr
|
| 156 |
+
|
| 157 |
+
def _memory_to_row(self, memory: Memory) -> dict[str, Any]:
|
| 158 |
+
"""Convert Memory object to row dict for insertion."""
|
| 159 |
+
return {
|
| 160 |
+
"id": memory.id,
|
| 161 |
+
"content": memory.content,
|
| 162 |
+
"user_id": memory.user_id,
|
| 163 |
+
"session_id": memory.session_id,
|
| 164 |
+
"agent_id": memory.agent_id,
|
| 165 |
+
"turn_id": memory.turn_id,
|
| 166 |
+
"created_at": memory.created_at.isoformat(),
|
| 167 |
+
"valid_from": memory.valid_from.isoformat(),
|
| 168 |
+
"valid_until": memory.valid_until.isoformat() if memory.valid_until else None,
|
| 169 |
+
"category": memory.category.value,
|
| 170 |
+
"importance": memory.importance,
|
| 171 |
+
"supersedes": memory.supersedes,
|
| 172 |
+
"superseded_by": memory.superseded_by,
|
| 173 |
+
"promoted_from": memory.promoted_from,
|
| 174 |
+
"promotion_chain": json.dumps(memory.promotion_chain),
|
| 175 |
+
"access_count": memory.access_count,
|
| 176 |
+
"last_accessed": memory.last_accessed.isoformat() if memory.last_accessed else None,
|
| 177 |
+
"entity_refs": json.dumps(memory.entity_refs),
|
| 178 |
+
"embedding": self._serialize_embedding(memory.embedding),
|
| 179 |
+
"metadata": json.dumps(memory.metadata),
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
def _row_to_memory(self, row: sqlite3.Row) -> Memory:
|
| 183 |
+
"""Convert database row to Memory object."""
|
| 184 |
+
return Memory(
|
| 185 |
+
id=row["id"],
|
| 186 |
+
content=row["content"],
|
| 187 |
+
user_id=row["user_id"],
|
| 188 |
+
session_id=row["session_id"],
|
| 189 |
+
agent_id=row["agent_id"],
|
| 190 |
+
turn_id=row["turn_id"],
|
| 191 |
+
created_at=datetime.fromisoformat(row["created_at"]),
|
| 192 |
+
valid_from=datetime.fromisoformat(row["valid_from"]),
|
| 193 |
+
valid_until=datetime.fromisoformat(row["valid_until"]) if row["valid_until"] else None,
|
| 194 |
+
category=MemoryCategory(row["category"]),
|
| 195 |
+
importance=row["importance"],
|
| 196 |
+
supersedes=row["supersedes"],
|
| 197 |
+
superseded_by=row["superseded_by"],
|
| 198 |
+
promoted_from=row["promoted_from"],
|
| 199 |
+
promotion_chain=json.loads(row["promotion_chain"]),
|
| 200 |
+
access_count=row["access_count"],
|
| 201 |
+
last_accessed=datetime.fromisoformat(row["last_accessed"])
|
| 202 |
+
if row["last_accessed"]
|
| 203 |
+
else None,
|
| 204 |
+
entity_refs=json.loads(row["entity_refs"]),
|
| 205 |
+
embedding=self._deserialize_embedding(row["embedding"]),
|
| 206 |
+
metadata=json.loads(row["metadata"]),
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
async def save(self, memory: Memory) -> None:
|
| 210 |
+
"""Save or update a memory.
|
| 211 |
+
|
| 212 |
+
If a memory with the same ID exists, it will be updated.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
memory: The memory to save.
|
| 216 |
+
"""
|
| 217 |
+
row = self._memory_to_row(memory)
|
| 218 |
+
|
| 219 |
+
with self._get_conn() as conn:
|
| 220 |
+
conn.execute(
|
| 221 |
+
"""
|
| 222 |
+
INSERT OR REPLACE INTO memories (
|
| 223 |
+
id, content, user_id, session_id, agent_id, turn_id,
|
| 224 |
+
created_at, valid_from, valid_until,
|
| 225 |
+
category, importance,
|
| 226 |
+
supersedes, superseded_by, promoted_from, promotion_chain,
|
| 227 |
+
access_count, last_accessed,
|
| 228 |
+
entity_refs, embedding, metadata
|
| 229 |
+
) VALUES (
|
| 230 |
+
:id, :content, :user_id, :session_id, :agent_id, :turn_id,
|
| 231 |
+
:created_at, :valid_from, :valid_until,
|
| 232 |
+
:category, :importance,
|
| 233 |
+
:supersedes, :superseded_by, :promoted_from, :promotion_chain,
|
| 234 |
+
:access_count, :last_accessed,
|
| 235 |
+
:entity_refs, :embedding, :metadata
|
| 236 |
+
)
|
| 237 |
+
""",
|
| 238 |
+
row,
|
| 239 |
+
)
|
| 240 |
+
conn.commit()
|
| 241 |
+
|
| 242 |
+
async def save_batch(self, memories: list[Memory]) -> None:
|
| 243 |
+
"""Save multiple memories in a single transaction.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
memories: List of memories to save.
|
| 247 |
+
"""
|
| 248 |
+
if not memories:
|
| 249 |
+
return
|
| 250 |
+
|
| 251 |
+
rows = [self._memory_to_row(m) for m in memories]
|
| 252 |
+
|
| 253 |
+
with self._get_conn() as conn:
|
| 254 |
+
conn.executemany(
|
| 255 |
+
"""
|
| 256 |
+
INSERT OR REPLACE INTO memories (
|
| 257 |
+
id, content, user_id, session_id, agent_id, turn_id,
|
| 258 |
+
created_at, valid_from, valid_until,
|
| 259 |
+
category, importance,
|
| 260 |
+
supersedes, superseded_by, promoted_from, promotion_chain,
|
| 261 |
+
access_count, last_accessed,
|
| 262 |
+
entity_refs, embedding, metadata
|
| 263 |
+
) VALUES (
|
| 264 |
+
:id, :content, :user_id, :session_id, :agent_id, :turn_id,
|
| 265 |
+
:created_at, :valid_from, :valid_until,
|
| 266 |
+
:category, :importance,
|
| 267 |
+
:supersedes, :superseded_by, :promoted_from, :promotion_chain,
|
| 268 |
+
:access_count, :last_accessed,
|
| 269 |
+
:entity_refs, :embedding, :metadata
|
| 270 |
+
)
|
| 271 |
+
""",
|
| 272 |
+
rows,
|
| 273 |
+
)
|
| 274 |
+
conn.commit()
|
| 275 |
+
|
| 276 |
+
async def get(self, memory_id: str) -> Memory | None:
|
| 277 |
+
"""Retrieve a memory by ID.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
memory_id: The unique identifier of the memory.
|
| 281 |
+
|
| 282 |
+
Returns:
|
| 283 |
+
The memory if found, None otherwise.
|
| 284 |
+
"""
|
| 285 |
+
with self._get_conn() as conn:
|
| 286 |
+
cursor = conn.execute(
|
| 287 |
+
"SELECT * FROM memories WHERE id = ?",
|
| 288 |
+
(memory_id,),
|
| 289 |
+
)
|
| 290 |
+
row = cursor.fetchone()
|
| 291 |
+
|
| 292 |
+
if row is None:
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
return self._row_to_memory(row)
|
| 296 |
+
|
| 297 |
+
async def get_batch(self, memory_ids: list[str]) -> list[Memory]:
|
| 298 |
+
"""Retrieve multiple memories by ID.
|
| 299 |
+
|
| 300 |
+
Args:
|
| 301 |
+
memory_ids: List of memory IDs to retrieve.
|
| 302 |
+
|
| 303 |
+
Returns:
|
| 304 |
+
List of found memories (may be shorter than input if some not found).
|
| 305 |
+
"""
|
| 306 |
+
if not memory_ids:
|
| 307 |
+
return []
|
| 308 |
+
|
| 309 |
+
placeholders = ", ".join("?" * len(memory_ids))
|
| 310 |
+
|
| 311 |
+
with self._get_conn() as conn:
|
| 312 |
+
cursor = conn.execute(
|
| 313 |
+
f"SELECT * FROM memories WHERE id IN ({placeholders})",
|
| 314 |
+
memory_ids,
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
return [self._row_to_memory(row) for row in cursor]
|
| 318 |
+
|
| 319 |
+
async def delete(self, memory_id: str) -> bool:
|
| 320 |
+
"""Delete a memory by ID.
|
| 321 |
+
|
| 322 |
+
Args:
|
| 323 |
+
memory_id: The unique identifier of the memory.
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
True if the memory was deleted, False if not found.
|
| 327 |
+
"""
|
| 328 |
+
with self._get_conn() as conn:
|
| 329 |
+
cursor = conn.execute(
|
| 330 |
+
"DELETE FROM memories WHERE id = ?",
|
| 331 |
+
(memory_id,),
|
| 332 |
+
)
|
| 333 |
+
conn.commit()
|
| 334 |
+
return cursor.rowcount > 0
|
| 335 |
+
|
| 336 |
+
async def delete_batch(self, memory_ids: list[str]) -> int:
|
| 337 |
+
"""Delete multiple memories by ID.
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
memory_ids: List of memory IDs to delete.
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Number of memories actually deleted.
|
| 344 |
+
"""
|
| 345 |
+
if not memory_ids:
|
| 346 |
+
return 0
|
| 347 |
+
|
| 348 |
+
placeholders = ", ".join("?" * len(memory_ids))
|
| 349 |
+
|
| 350 |
+
with self._get_conn() as conn:
|
| 351 |
+
cursor = conn.execute(
|
| 352 |
+
f"DELETE FROM memories WHERE id IN ({placeholders})",
|
| 353 |
+
memory_ids,
|
| 354 |
+
)
|
| 355 |
+
conn.commit()
|
| 356 |
+
return cursor.rowcount
|
| 357 |
+
|
| 358 |
+
def _build_query_conditions(self, filter: MemoryFilter) -> tuple[list[str], list[Any]]:
|
| 359 |
+
"""Build WHERE clause conditions from a MemoryFilter.
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
Tuple of (conditions list, params list).
|
| 363 |
+
"""
|
| 364 |
+
conditions: list[str] = []
|
| 365 |
+
params: list[Any] = []
|
| 366 |
+
|
| 367 |
+
# Hierarchical scope filtering
|
| 368 |
+
if filter.user_id is not None:
|
| 369 |
+
conditions.append("user_id = ?")
|
| 370 |
+
params.append(filter.user_id)
|
| 371 |
+
|
| 372 |
+
# Hierarchical filtering: when filtering by user_id only,
|
| 373 |
+
# return USER-level and below (all that user's memories)
|
| 374 |
+
# This is implicit - we just filter by user_id
|
| 375 |
+
|
| 376 |
+
if filter.session_id is not None:
|
| 377 |
+
conditions.append("session_id = ?")
|
| 378 |
+
params.append(filter.session_id)
|
| 379 |
+
|
| 380 |
+
if filter.agent_id is not None:
|
| 381 |
+
conditions.append("agent_id = ?")
|
| 382 |
+
params.append(filter.agent_id)
|
| 383 |
+
|
| 384 |
+
if filter.turn_id is not None:
|
| 385 |
+
conditions.append("turn_id = ?")
|
| 386 |
+
params.append(filter.turn_id)
|
| 387 |
+
elif filter.agent_id is not None:
|
| 388 |
+
# Agent without session - unusual but supported
|
| 389 |
+
conditions.append("agent_id = ?")
|
| 390 |
+
params.append(filter.agent_id)
|
| 391 |
+
elif filter.turn_id is not None:
|
| 392 |
+
# Turn without session/agent - unusual but supported
|
| 393 |
+
conditions.append("turn_id = ?")
|
| 394 |
+
params.append(filter.turn_id)
|
| 395 |
+
elif filter.session_id is not None:
|
| 396 |
+
# Session without user - filter by session only
|
| 397 |
+
conditions.append("session_id = ?")
|
| 398 |
+
params.append(filter.session_id)
|
| 399 |
+
elif filter.agent_id is not None:
|
| 400 |
+
# Agent without user/session
|
| 401 |
+
conditions.append("agent_id = ?")
|
| 402 |
+
params.append(filter.agent_id)
|
| 403 |
+
elif filter.turn_id is not None:
|
| 404 |
+
# Turn only
|
| 405 |
+
conditions.append("turn_id = ?")
|
| 406 |
+
params.append(filter.turn_id)
|
| 407 |
+
|
| 408 |
+
# Explicit scope level filtering
|
| 409 |
+
if filter.scope_levels is not None and len(filter.scope_levels) > 0:
|
| 410 |
+
scope_conditions = []
|
| 411 |
+
for level in filter.scope_levels:
|
| 412 |
+
if level == ScopeLevel.USER:
|
| 413 |
+
# USER level: no session/agent/turn
|
| 414 |
+
scope_conditions.append(
|
| 415 |
+
"(session_id IS NULL AND agent_id IS NULL AND turn_id IS NULL)"
|
| 416 |
+
)
|
| 417 |
+
elif level == ScopeLevel.SESSION:
|
| 418 |
+
# SESSION level: has session, no agent/turn
|
| 419 |
+
scope_conditions.append(
|
| 420 |
+
"(session_id IS NOT NULL AND agent_id IS NULL AND turn_id IS NULL)"
|
| 421 |
+
)
|
| 422 |
+
elif level == ScopeLevel.AGENT:
|
| 423 |
+
# AGENT level: has agent, no turn
|
| 424 |
+
scope_conditions.append("(agent_id IS NOT NULL AND turn_id IS NULL)")
|
| 425 |
+
elif level == ScopeLevel.TURN:
|
| 426 |
+
# TURN level: has turn
|
| 427 |
+
scope_conditions.append("(turn_id IS NOT NULL)")
|
| 428 |
+
|
| 429 |
+
if scope_conditions:
|
| 430 |
+
conditions.append(f"({' OR '.join(scope_conditions)})")
|
| 431 |
+
|
| 432 |
+
# Category filtering
|
| 433 |
+
if filter.categories is not None and len(filter.categories) > 0:
|
| 434 |
+
category_values = [
|
| 435 |
+
c.value if isinstance(c, MemoryCategory) else c for c in filter.categories
|
| 436 |
+
]
|
| 437 |
+
placeholders = ", ".join("?" * len(category_values))
|
| 438 |
+
conditions.append(f"category IN ({placeholders})")
|
| 439 |
+
params.extend(category_values)
|
| 440 |
+
|
| 441 |
+
# Temporal filtering
|
| 442 |
+
if filter.created_after is not None:
|
| 443 |
+
conditions.append("created_at >= ?")
|
| 444 |
+
params.append(filter.created_after.isoformat())
|
| 445 |
+
|
| 446 |
+
if filter.created_before is not None:
|
| 447 |
+
conditions.append("created_at <= ?")
|
| 448 |
+
params.append(filter.created_before.isoformat())
|
| 449 |
+
|
| 450 |
+
# Point-in-time query
|
| 451 |
+
if filter.valid_at is not None:
|
| 452 |
+
valid_at_str = filter.valid_at.isoformat()
|
| 453 |
+
conditions.append("valid_from <= ?")
|
| 454 |
+
params.append(valid_at_str)
|
| 455 |
+
conditions.append("(valid_until IS NULL OR valid_until > ?)")
|
| 456 |
+
params.append(valid_at_str)
|
| 457 |
+
|
| 458 |
+
# Superseded filtering
|
| 459 |
+
if not filter.include_superseded:
|
| 460 |
+
# Default: only return current memories (not superseded)
|
| 461 |
+
conditions.append("valid_until IS NULL")
|
| 462 |
+
|
| 463 |
+
# Importance filtering
|
| 464 |
+
if filter.min_importance is not None:
|
| 465 |
+
conditions.append("importance >= ?")
|
| 466 |
+
params.append(filter.min_importance)
|
| 467 |
+
|
| 468 |
+
if filter.max_importance is not None:
|
| 469 |
+
conditions.append("importance <= ?")
|
| 470 |
+
params.append(filter.max_importance)
|
| 471 |
+
|
| 472 |
+
# Entity reference filtering (any of the specified entities)
|
| 473 |
+
if filter.entity_refs is not None and len(filter.entity_refs) > 0:
|
| 474 |
+
entity_conditions = []
|
| 475 |
+
for entity_ref in filter.entity_refs:
|
| 476 |
+
# Use JSON contains check
|
| 477 |
+
entity_conditions.append("entity_refs LIKE ?")
|
| 478 |
+
params.append(f'%"{entity_ref}"%')
|
| 479 |
+
conditions.append(f"({' OR '.join(entity_conditions)})")
|
| 480 |
+
|
| 481 |
+
# Lineage filtering
|
| 482 |
+
if filter.has_supersedes is not None:
|
| 483 |
+
if filter.has_supersedes:
|
| 484 |
+
conditions.append("supersedes IS NOT NULL")
|
| 485 |
+
else:
|
| 486 |
+
conditions.append("supersedes IS NULL")
|
| 487 |
+
|
| 488 |
+
if filter.has_promoted_from is not None:
|
| 489 |
+
if filter.has_promoted_from:
|
| 490 |
+
conditions.append("promoted_from IS NOT NULL")
|
| 491 |
+
else:
|
| 492 |
+
conditions.append("promoted_from IS NULL")
|
| 493 |
+
|
| 494 |
+
# Metadata filtering
|
| 495 |
+
if filter.metadata_filters:
|
| 496 |
+
for key, value in filter.metadata_filters.items():
|
| 497 |
+
# Use JSON extraction for metadata filtering
|
| 498 |
+
conditions.append(f"json_extract(metadata, '$.{key}') = ?")
|
| 499 |
+
params.append(json.dumps(value) if not isinstance(value, str) else value)
|
| 500 |
+
|
| 501 |
+
return conditions, params
|
| 502 |
+
|
| 503 |
+
async def query(self, filter: MemoryFilter) -> list[Memory]:
|
| 504 |
+
"""Query memories matching the given filter.
|
| 505 |
+
|
| 506 |
+
Args:
|
| 507 |
+
filter: Filter criteria for the query.
|
| 508 |
+
|
| 509 |
+
Returns:
|
| 510 |
+
List of matching memories.
|
| 511 |
+
"""
|
| 512 |
+
conditions, params = self._build_query_conditions(filter)
|
| 513 |
+
|
| 514 |
+
# Build WHERE clause
|
| 515 |
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
| 516 |
+
|
| 517 |
+
# Build ORDER BY clause
|
| 518 |
+
order_column = filter.order_by
|
| 519 |
+
if order_column not in (
|
| 520 |
+
"created_at",
|
| 521 |
+
"importance",
|
| 522 |
+
"access_count",
|
| 523 |
+
"last_accessed",
|
| 524 |
+
):
|
| 525 |
+
order_column = "created_at"
|
| 526 |
+
|
| 527 |
+
order_direction = "DESC" if filter.order_desc else "ASC"
|
| 528 |
+
|
| 529 |
+
# Build full query
|
| 530 |
+
query = f"""
|
| 531 |
+
SELECT * FROM memories
|
| 532 |
+
WHERE {where_clause}
|
| 533 |
+
ORDER BY {order_column} {order_direction}
|
| 534 |
+
"""
|
| 535 |
+
|
| 536 |
+
# Add pagination
|
| 537 |
+
if filter.limit is not None:
|
| 538 |
+
query += f" LIMIT {filter.limit}"
|
| 539 |
+
|
| 540 |
+
if filter.offset > 0:
|
| 541 |
+
query += f" OFFSET {filter.offset}"
|
| 542 |
+
|
| 543 |
+
with self._get_conn() as conn:
|
| 544 |
+
cursor = conn.execute(query, params)
|
| 545 |
+
return [self._row_to_memory(row) for row in cursor]
|
| 546 |
+
|
| 547 |
+
async def count(self, filter: MemoryFilter) -> int:
|
| 548 |
+
"""Count memories matching the given filter.
|
| 549 |
+
|
| 550 |
+
Args:
|
| 551 |
+
filter: Filter criteria for the count.
|
| 552 |
+
|
| 553 |
+
Returns:
|
| 554 |
+
Number of matching memories.
|
| 555 |
+
"""
|
| 556 |
+
conditions, params = self._build_query_conditions(filter)
|
| 557 |
+
|
| 558 |
+
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
| 559 |
+
|
| 560 |
+
query = f"SELECT COUNT(*) FROM memories WHERE {where_clause}"
|
| 561 |
+
|
| 562 |
+
with self._get_conn() as conn:
|
| 563 |
+
cursor = conn.execute(query, params)
|
| 564 |
+
result = cursor.fetchone()[0]
|
| 565 |
+
return int(result)
|
| 566 |
+
|
| 567 |
+
async def supersede(
|
| 568 |
+
self,
|
| 569 |
+
old_memory_id: str,
|
| 570 |
+
new_memory: Memory,
|
| 571 |
+
supersede_time: datetime | None = None,
|
| 572 |
+
) -> Memory:
|
| 573 |
+
"""Supersede an existing memory with a new version.
|
| 574 |
+
|
| 575 |
+
This creates a temporal chain: the old memory's valid_until is set,
|
| 576 |
+
and the new memory's supersedes field points to the old one.
|
| 577 |
+
|
| 578 |
+
Args:
|
| 579 |
+
old_memory_id: ID of the memory to supersede.
|
| 580 |
+
new_memory: The new memory that replaces it.
|
| 581 |
+
supersede_time: When the supersession occurred (defaults to now).
|
| 582 |
+
|
| 583 |
+
Returns:
|
| 584 |
+
The saved new memory with lineage fields populated.
|
| 585 |
+
|
| 586 |
+
Raises:
|
| 587 |
+
ValueError: If the old memory is not found.
|
| 588 |
+
"""
|
| 589 |
+
if supersede_time is None:
|
| 590 |
+
supersede_time = datetime.utcnow()
|
| 591 |
+
|
| 592 |
+
# Get the old memory
|
| 593 |
+
old_memory = await self.get(old_memory_id)
|
| 594 |
+
if old_memory is None:
|
| 595 |
+
raise ValueError(f"Memory with ID {old_memory_id} not found")
|
| 596 |
+
|
| 597 |
+
# Update old memory's valid_until and superseded_by
|
| 598 |
+
old_memory.valid_until = supersede_time
|
| 599 |
+
old_memory.superseded_by = new_memory.id
|
| 600 |
+
|
| 601 |
+
# Set up new memory's lineage
|
| 602 |
+
new_memory.supersedes = old_memory_id
|
| 603 |
+
new_memory.valid_from = supersede_time
|
| 604 |
+
|
| 605 |
+
# Save both in a transaction
|
| 606 |
+
with self._get_conn() as conn:
|
| 607 |
+
# Update old memory
|
| 608 |
+
conn.execute(
|
| 609 |
+
"""
|
| 610 |
+
UPDATE memories
|
| 611 |
+
SET valid_until = ?, superseded_by = ?
|
| 612 |
+
WHERE id = ?
|
| 613 |
+
""",
|
| 614 |
+
(supersede_time.isoformat(), new_memory.id, old_memory_id),
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
# Insert new memory
|
| 618 |
+
row = self._memory_to_row(new_memory)
|
| 619 |
+
conn.execute(
|
| 620 |
+
"""
|
| 621 |
+
INSERT OR REPLACE INTO memories (
|
| 622 |
+
id, content, user_id, session_id, agent_id, turn_id,
|
| 623 |
+
created_at, valid_from, valid_until,
|
| 624 |
+
category, importance,
|
| 625 |
+
supersedes, superseded_by, promoted_from, promotion_chain,
|
| 626 |
+
access_count, last_accessed,
|
| 627 |
+
entity_refs, embedding, metadata
|
| 628 |
+
) VALUES (
|
| 629 |
+
:id, :content, :user_id, :session_id, :agent_id, :turn_id,
|
| 630 |
+
:created_at, :valid_from, :valid_until,
|
| 631 |
+
:category, :importance,
|
| 632 |
+
:supersedes, :superseded_by, :promoted_from, :promotion_chain,
|
| 633 |
+
:access_count, :last_accessed,
|
| 634 |
+
:entity_refs, :embedding, :metadata
|
| 635 |
+
)
|
| 636 |
+
""",
|
| 637 |
+
row,
|
| 638 |
+
)
|
| 639 |
+
conn.commit()
|
| 640 |
+
|
| 641 |
+
return new_memory
|
| 642 |
+
|
| 643 |
+
async def get_history(
|
| 644 |
+
self,
|
| 645 |
+
memory_id: str,
|
| 646 |
+
include_future: bool = False,
|
| 647 |
+
) -> list[Memory]:
|
| 648 |
+
"""Get the full history chain for a memory.
|
| 649 |
+
|
| 650 |
+
Follows the supersedes/superseded_by chain to return all versions.
|
| 651 |
+
|
| 652 |
+
Args:
|
| 653 |
+
memory_id: ID of any memory in the chain.
|
| 654 |
+
include_future: Whether to include memories that superseded this one.
|
| 655 |
+
|
| 656 |
+
Returns:
|
| 657 |
+
List of memories in temporal order (oldest first).
|
| 658 |
+
"""
|
| 659 |
+
# Start with the given memory
|
| 660 |
+
current = await self.get(memory_id)
|
| 661 |
+
if current is None:
|
| 662 |
+
return []
|
| 663 |
+
|
| 664 |
+
history: list[Memory] = [current]
|
| 665 |
+
|
| 666 |
+
# Follow chain backwards (supersedes)
|
| 667 |
+
back_id = current.supersedes
|
| 668 |
+
while back_id is not None:
|
| 669 |
+
prev = await self.get(back_id)
|
| 670 |
+
if prev is None:
|
| 671 |
+
break
|
| 672 |
+
history.insert(0, prev) # Add to beginning
|
| 673 |
+
back_id = prev.supersedes
|
| 674 |
+
|
| 675 |
+
# Follow chain forwards (superseded_by) if requested
|
| 676 |
+
if include_future:
|
| 677 |
+
forward_id = current.superseded_by
|
| 678 |
+
while forward_id is not None:
|
| 679 |
+
next_mem = await self.get(forward_id)
|
| 680 |
+
if next_mem is None:
|
| 681 |
+
break
|
| 682 |
+
history.append(next_mem)
|
| 683 |
+
forward_id = next_mem.superseded_by
|
| 684 |
+
|
| 685 |
+
return history
|
| 686 |
+
|
| 687 |
+
async def clear_scope(
|
| 688 |
+
self,
|
| 689 |
+
user_id: str,
|
| 690 |
+
session_id: str | None = None,
|
| 691 |
+
agent_id: str | None = None,
|
| 692 |
+
turn_id: str | None = None,
|
| 693 |
+
) -> int:
|
| 694 |
+
"""Clear all memories at or below a scope level.
|
| 695 |
+
|
| 696 |
+
Args:
|
| 697 |
+
user_id: Required user scope.
|
| 698 |
+
session_id: If provided, clear session and below.
|
| 699 |
+
agent_id: If provided, clear agent and below.
|
| 700 |
+
turn_id: If provided, clear only that turn.
|
| 701 |
+
|
| 702 |
+
Returns:
|
| 703 |
+
Number of memories deleted.
|
| 704 |
+
"""
|
| 705 |
+
conditions = ["user_id = ?"]
|
| 706 |
+
params: list[Any] = [user_id]
|
| 707 |
+
|
| 708 |
+
if turn_id is not None:
|
| 709 |
+
# Clear only specific turn
|
| 710 |
+
conditions.append("turn_id = ?")
|
| 711 |
+
params.append(turn_id)
|
| 712 |
+
elif agent_id is not None:
|
| 713 |
+
# Clear agent and its turns
|
| 714 |
+
conditions.append("agent_id = ?")
|
| 715 |
+
params.append(agent_id)
|
| 716 |
+
elif session_id is not None:
|
| 717 |
+
# Clear session and its agents/turns
|
| 718 |
+
conditions.append("session_id = ?")
|
| 719 |
+
params.append(session_id)
|
| 720 |
+
# If only user_id, clear all user's memories
|
| 721 |
+
|
| 722 |
+
where_clause = " AND ".join(conditions)
|
| 723 |
+
|
| 724 |
+
with self._get_conn() as conn:
|
| 725 |
+
cursor = conn.execute(
|
| 726 |
+
f"DELETE FROM memories WHERE {where_clause}",
|
| 727 |
+
params,
|
| 728 |
+
)
|
| 729 |
+
conn.commit()
|
| 730 |
+
return cursor.rowcount
|
| 731 |
+
|
| 732 |
+
async def clear_all(self) -> int:
|
| 733 |
+
"""Clear all memories from the store.
|
| 734 |
+
|
| 735 |
+
Returns:
|
| 736 |
+
Number of memories deleted.
|
| 737 |
+
"""
|
| 738 |
+
with self._get_conn() as conn:
|
| 739 |
+
cursor = conn.execute("DELETE FROM memories")
|
| 740 |
+
conn.commit()
|
| 741 |
+
return cursor.rowcount
|
| 742 |
+
|
| 743 |
+
def count_sync(self) -> int:
|
| 744 |
+
"""Synchronous count of all memories (for diagnostics).
|
| 745 |
+
|
| 746 |
+
Returns:
|
| 747 |
+
Total number of memories in the store.
|
| 748 |
+
"""
|
| 749 |
+
with self._get_conn() as conn:
|
| 750 |
+
cursor = conn.execute("SELECT COUNT(*) FROM memories")
|
| 751 |
+
result = cursor.fetchone()[0]
|
| 752 |
+
return int(result)
|
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration dataclasses for Headroom's hierarchical memory system.
|
| 2 |
+
|
| 3 |
+
Provides configuration options for all pluggable components:
|
| 4 |
+
- Storage backends (SQLite, future: PostgreSQL, DynamoDB)
|
| 5 |
+
- Vector index backends (HNSW, future: FAISS, Pinecone)
|
| 6 |
+
- Text index backends (FTS5, future: Elasticsearch)
|
| 7 |
+
- Embedder backends (local sentence-transformers, OpenAI, Ollama)
|
| 8 |
+
- Caching options
|
| 9 |
+
- Bubbling behavior defaults
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from enum import Enum
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from headroom.memory.models import ScopeLevel
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StoreBackend(Enum):
|
| 22 |
+
"""Supported memory store backends."""
|
| 23 |
+
|
| 24 |
+
SQLITE = "sqlite"
|
| 25 |
+
# Future: POSTGRES = "postgres", DYNAMODB = "dynamodb"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class VectorBackend(Enum):
|
| 29 |
+
"""Supported vector index backends."""
|
| 30 |
+
|
| 31 |
+
HNSW = "hnsw"
|
| 32 |
+
# Future: FAISS = "faiss", PINECONE = "pinecone"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TextBackend(Enum):
|
| 36 |
+
"""Supported text index backends."""
|
| 37 |
+
|
| 38 |
+
FTS5 = "fts5"
|
| 39 |
+
# Future: ELASTICSEARCH = "elasticsearch"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class EmbedderBackend(Enum):
|
| 43 |
+
"""Supported embedder backends."""
|
| 44 |
+
|
| 45 |
+
LOCAL = "local" # sentence-transformers
|
| 46 |
+
OPENAI = "openai"
|
| 47 |
+
OLLAMA = "ollama"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class MemoryConfig:
|
| 52 |
+
"""Complete configuration for the memory system.
|
| 53 |
+
|
| 54 |
+
This dataclass holds all configuration options needed to initialize
|
| 55 |
+
the memory system components. Each component can be configured
|
| 56 |
+
independently, allowing for flexible deployment scenarios.
|
| 57 |
+
|
| 58 |
+
Attributes:
|
| 59 |
+
store_backend: Which storage backend to use for memory persistence.
|
| 60 |
+
db_path: Path to the database file (for file-based backends like SQLite).
|
| 61 |
+
|
| 62 |
+
vector_backend: Which vector index backend to use for similarity search.
|
| 63 |
+
vector_dimension: Dimension of embedding vectors.
|
| 64 |
+
hnsw_ef_construction: HNSW index build-time accuracy parameter.
|
| 65 |
+
hnsw_m: HNSW maximum number of connections per node.
|
| 66 |
+
hnsw_ef_search: HNSW search-time accuracy parameter.
|
| 67 |
+
|
| 68 |
+
text_backend: Which text index backend to use for full-text search.
|
| 69 |
+
|
| 70 |
+
embedder_backend: Which embedder to use for generating embeddings.
|
| 71 |
+
embedder_model: Model name/identifier for the embedder.
|
| 72 |
+
openai_api_key: API key for OpenAI embeddings (if using OpenAI backend).
|
| 73 |
+
ollama_base_url: Base URL for Ollama server (if using Ollama backend).
|
| 74 |
+
|
| 75 |
+
cache_enabled: Whether to enable the memory cache layer.
|
| 76 |
+
cache_max_size: Maximum number of entries in the cache.
|
| 77 |
+
|
| 78 |
+
auto_bubble: Whether to automatically bubble memories up the hierarchy.
|
| 79 |
+
preference_bubble_to: Default scope level for bubbling preferences.
|
| 80 |
+
decision_bubble_to: Default scope level for bubbling decisions.
|
| 81 |
+
|
| 82 |
+
Example:
|
| 83 |
+
config = MemoryConfig(
|
| 84 |
+
db_path=Path("./my_memory.db"),
|
| 85 |
+
embedder_backend=EmbedderBackend.OPENAI,
|
| 86 |
+
openai_api_key="sk-...",
|
| 87 |
+
cache_max_size=2000,
|
| 88 |
+
)
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
# Storage
|
| 92 |
+
store_backend: StoreBackend = StoreBackend.SQLITE
|
| 93 |
+
db_path: Path = field(default_factory=lambda: Path("headroom_memory.db"))
|
| 94 |
+
|
| 95 |
+
# Vector index
|
| 96 |
+
vector_backend: VectorBackend = VectorBackend.HNSW
|
| 97 |
+
vector_dimension: int = 384
|
| 98 |
+
hnsw_ef_construction: int = 200
|
| 99 |
+
hnsw_m: int = 16
|
| 100 |
+
hnsw_ef_search: int = 50
|
| 101 |
+
|
| 102 |
+
# Text index
|
| 103 |
+
text_backend: TextBackend = TextBackend.FTS5
|
| 104 |
+
|
| 105 |
+
# Embedder
|
| 106 |
+
embedder_backend: EmbedderBackend = EmbedderBackend.LOCAL
|
| 107 |
+
embedder_model: str = "all-MiniLM-L6-v2"
|
| 108 |
+
openai_api_key: str | None = None
|
| 109 |
+
ollama_base_url: str = "http://localhost:11434"
|
| 110 |
+
|
| 111 |
+
# Cache
|
| 112 |
+
cache_enabled: bool = True
|
| 113 |
+
cache_max_size: int = 1000
|
| 114 |
+
|
| 115 |
+
# Bubbling defaults
|
| 116 |
+
auto_bubble: bool = True
|
| 117 |
+
preference_bubble_to: ScopeLevel = ScopeLevel.USER
|
| 118 |
+
decision_bubble_to: ScopeLevel = ScopeLevel.SESSION
|
| 119 |
+
|
| 120 |
+
def __post_init__(self) -> None:
|
| 121 |
+
"""Validate configuration after initialization."""
|
| 122 |
+
if self.vector_dimension < 1:
|
| 123 |
+
raise ValueError(f"vector_dimension must be positive, got {self.vector_dimension}")
|
| 124 |
+
|
| 125 |
+
if self.hnsw_ef_construction < 1:
|
| 126 |
+
raise ValueError(
|
| 127 |
+
f"hnsw_ef_construction must be positive, got {self.hnsw_ef_construction}"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if self.hnsw_m < 1:
|
| 131 |
+
raise ValueError(f"hnsw_m must be positive, got {self.hnsw_m}")
|
| 132 |
+
|
| 133 |
+
if self.hnsw_ef_search < 1:
|
| 134 |
+
raise ValueError(f"hnsw_ef_search must be positive, got {self.hnsw_ef_search}")
|
| 135 |
+
|
| 136 |
+
if self.cache_max_size < 1:
|
| 137 |
+
raise ValueError(f"cache_max_size must be positive, got {self.cache_max_size}")
|
| 138 |
+
|
| 139 |
+
if self.embedder_backend == EmbedderBackend.OPENAI and not self.openai_api_key:
|
| 140 |
+
raise ValueError("openai_api_key is required when using OpenAI embedder backend")
|
| 141 |
+
|
| 142 |
+
# Ensure db_path is a Path object
|
| 143 |
+
if isinstance(self.db_path, str):
|
| 144 |
+
self.db_path = Path(self.db_path)
|
|
@@ -0,0 +1,893 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core HierarchicalMemory orchestrator for Headroom.
|
| 2 |
+
|
| 3 |
+
This module provides the main HierarchicalMemory class that coordinates
|
| 4 |
+
all memory system components: store, vector index, text index, embedder,
|
| 5 |
+
and cache. It implements the high-level memory operations with automatic
|
| 6 |
+
embedding, indexing, caching, and memory bubbling.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from typing import TYPE_CHECKING, Any
|
| 14 |
+
|
| 15 |
+
from headroom.memory.config import MemoryConfig
|
| 16 |
+
from headroom.memory.factory import create_memory_system
|
| 17 |
+
from headroom.memory.models import Memory, MemoryCategory, ScopeLevel
|
| 18 |
+
from headroom.memory.ports import MemoryFilter, TextFilter, VectorFilter
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
from headroom.memory.ports import (
|
| 22 |
+
Embedder,
|
| 23 |
+
MemoryCache,
|
| 24 |
+
MemoryStore,
|
| 25 |
+
TextIndex,
|
| 26 |
+
TextSearchResult,
|
| 27 |
+
VectorIndex,
|
| 28 |
+
VectorSearchResult,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class HierarchicalMemory:
|
| 35 |
+
"""Main orchestrator for the hierarchical memory system.
|
| 36 |
+
|
| 37 |
+
HierarchicalMemory coordinates all memory system components to provide
|
| 38 |
+
a unified API for memory operations. It handles:
|
| 39 |
+
- Automatic embedding generation
|
| 40 |
+
- Multi-index updates (store, vector, text)
|
| 41 |
+
- Cache management
|
| 42 |
+
- Memory bubbling (promoting important memories up the hierarchy)
|
| 43 |
+
- Hierarchical scoping (user -> session -> agent -> turn)
|
| 44 |
+
- Temporal queries (point-in-time, supersession)
|
| 45 |
+
|
| 46 |
+
Usage:
|
| 47 |
+
# Create with default configuration
|
| 48 |
+
memory = await HierarchicalMemory.create()
|
| 49 |
+
|
| 50 |
+
# Or with custom configuration
|
| 51 |
+
config = MemoryConfig(embedder_backend=EmbedderBackend.OPENAI)
|
| 52 |
+
memory = await HierarchicalMemory.create(config)
|
| 53 |
+
|
| 54 |
+
# Add a memory
|
| 55 |
+
await memory.add(
|
| 56 |
+
content="User prefers Python over JavaScript",
|
| 57 |
+
user_id="alice",
|
| 58 |
+
category=MemoryCategory.PREFERENCE,
|
| 59 |
+
importance=0.9,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Search semantically
|
| 63 |
+
results = await memory.search("programming language preferences", user_id="alice")
|
| 64 |
+
|
| 65 |
+
# Full-text search
|
| 66 |
+
results = await memory.text_search("Python", user_id="alice")
|
| 67 |
+
|
| 68 |
+
# Query with filters
|
| 69 |
+
memories = await memory.query(MemoryFilter(
|
| 70 |
+
user_id="alice",
|
| 71 |
+
categories=[MemoryCategory.PREFERENCE],
|
| 72 |
+
min_importance=0.8,
|
| 73 |
+
))
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __init__(
|
| 77 |
+
self,
|
| 78 |
+
store: MemoryStore,
|
| 79 |
+
vector_index: VectorIndex,
|
| 80 |
+
text_index: TextIndex,
|
| 81 |
+
embedder: Embedder,
|
| 82 |
+
cache: MemoryCache | None = None,
|
| 83 |
+
config: MemoryConfig | None = None,
|
| 84 |
+
) -> None:
|
| 85 |
+
"""Initialize HierarchicalMemory with components.
|
| 86 |
+
|
| 87 |
+
Prefer using the create() factory method instead of direct initialization.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
store: Memory persistence backend.
|
| 91 |
+
vector_index: Vector similarity search index.
|
| 92 |
+
text_index: Full-text search index.
|
| 93 |
+
embedder: Text embedding generator.
|
| 94 |
+
cache: Optional memory cache.
|
| 95 |
+
config: Configuration (for bubbling settings, etc.).
|
| 96 |
+
"""
|
| 97 |
+
self._store = store
|
| 98 |
+
self._vector_index = vector_index
|
| 99 |
+
self._text_index = text_index
|
| 100 |
+
self._embedder = embedder
|
| 101 |
+
self._cache = cache
|
| 102 |
+
self._config = config or MemoryConfig()
|
| 103 |
+
|
| 104 |
+
@classmethod
|
| 105 |
+
async def create(cls, config: MemoryConfig | None = None) -> HierarchicalMemory:
|
| 106 |
+
"""Create a HierarchicalMemory instance from configuration.
|
| 107 |
+
|
| 108 |
+
This is the recommended way to create a HierarchicalMemory instance.
|
| 109 |
+
It creates all necessary components based on the configuration.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
config: Memory system configuration. Uses defaults if not provided.
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Fully initialized HierarchicalMemory instance.
|
| 116 |
+
|
| 117 |
+
Example:
|
| 118 |
+
memory = await HierarchicalMemory.create()
|
| 119 |
+
# Or with config
|
| 120 |
+
memory = await HierarchicalMemory.create(MemoryConfig(
|
| 121 |
+
embedder_backend=EmbedderBackend.OPENAI,
|
| 122 |
+
openai_api_key="sk-...",
|
| 123 |
+
))
|
| 124 |
+
"""
|
| 125 |
+
config = config or MemoryConfig()
|
| 126 |
+
store, vector_index, text_index, embedder, cache = await create_memory_system(config)
|
| 127 |
+
return cls(store, vector_index, text_index, embedder, cache, config)
|
| 128 |
+
|
| 129 |
+
# =========================================================================
|
| 130 |
+
# Memory Creation
|
| 131 |
+
# =========================================================================
|
| 132 |
+
|
| 133 |
+
async def add(
|
| 134 |
+
self,
|
| 135 |
+
content: str,
|
| 136 |
+
user_id: str,
|
| 137 |
+
session_id: str | None = None,
|
| 138 |
+
agent_id: str | None = None,
|
| 139 |
+
turn_id: str | None = None,
|
| 140 |
+
category: MemoryCategory = MemoryCategory.FACT,
|
| 141 |
+
importance: float = 0.5,
|
| 142 |
+
entity_refs: list[str] | None = None,
|
| 143 |
+
metadata: dict[str, Any] | None = None,
|
| 144 |
+
auto_embed: bool = True,
|
| 145 |
+
auto_bubble: bool | None = None,
|
| 146 |
+
) -> Memory:
|
| 147 |
+
"""Add a new memory to the system.
|
| 148 |
+
|
| 149 |
+
Creates a memory with the specified content and scope, generates
|
| 150 |
+
embeddings, and indexes it for search. Optionally bubbles important
|
| 151 |
+
memories up the hierarchy.
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
content: The memory content/text.
|
| 155 |
+
user_id: User identifier (required - top of hierarchy).
|
| 156 |
+
session_id: Session identifier (optional).
|
| 157 |
+
agent_id: Agent identifier (optional).
|
| 158 |
+
turn_id: Turn identifier (optional).
|
| 159 |
+
category: Memory classification.
|
| 160 |
+
importance: Importance score (0.0 - 1.0).
|
| 161 |
+
entity_refs: List of entity references.
|
| 162 |
+
metadata: Additional metadata.
|
| 163 |
+
auto_embed: Whether to generate embedding automatically.
|
| 164 |
+
auto_bubble: Whether to bubble up (uses config default if None).
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
The created and stored Memory object.
|
| 168 |
+
|
| 169 |
+
Example:
|
| 170 |
+
memory = await system.add(
|
| 171 |
+
content="User prefers dark mode",
|
| 172 |
+
user_id="alice",
|
| 173 |
+
session_id="sess-123",
|
| 174 |
+
category=MemoryCategory.PREFERENCE,
|
| 175 |
+
importance=0.8,
|
| 176 |
+
)
|
| 177 |
+
"""
|
| 178 |
+
# Create memory object
|
| 179 |
+
memory = Memory(
|
| 180 |
+
content=content,
|
| 181 |
+
user_id=user_id,
|
| 182 |
+
session_id=session_id,
|
| 183 |
+
agent_id=agent_id,
|
| 184 |
+
turn_id=turn_id,
|
| 185 |
+
category=category,
|
| 186 |
+
importance=importance,
|
| 187 |
+
entity_refs=entity_refs or [],
|
| 188 |
+
metadata=metadata or {},
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Generate embedding if requested
|
| 192 |
+
if auto_embed:
|
| 193 |
+
embedding = await self._embedder.embed(content)
|
| 194 |
+
memory.embedding = embedding
|
| 195 |
+
|
| 196 |
+
# Save to store
|
| 197 |
+
await self._store.save(memory)
|
| 198 |
+
|
| 199 |
+
# Index for vector search
|
| 200 |
+
if memory.embedding is not None:
|
| 201 |
+
await self._vector_index.index(memory)
|
| 202 |
+
|
| 203 |
+
# Index for text search
|
| 204 |
+
await self._index_for_text_search(memory)
|
| 205 |
+
|
| 206 |
+
# Update cache
|
| 207 |
+
if self._cache is not None:
|
| 208 |
+
await self._cache.put(memory)
|
| 209 |
+
|
| 210 |
+
# Handle bubbling
|
| 211 |
+
should_bubble = auto_bubble if auto_bubble is not None else self._config.auto_bubble
|
| 212 |
+
if should_bubble:
|
| 213 |
+
await self._maybe_bubble(memory)
|
| 214 |
+
|
| 215 |
+
logger.debug(f"Added memory {memory.id} at scope {memory.scope_level.value}")
|
| 216 |
+
return memory
|
| 217 |
+
|
| 218 |
+
async def add_batch(
|
| 219 |
+
self,
|
| 220 |
+
memories_data: list[dict[str, Any]],
|
| 221 |
+
auto_embed: bool = True,
|
| 222 |
+
) -> list[Memory]:
|
| 223 |
+
"""Add multiple memories in a batch operation.
|
| 224 |
+
|
| 225 |
+
More efficient than calling add() multiple times due to batch
|
| 226 |
+
embedding and batch database operations.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
memories_data: List of dicts with memory parameters
|
| 230 |
+
(content, user_id, etc.).
|
| 231 |
+
auto_embed: Whether to generate embeddings automatically.
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
List of created Memory objects.
|
| 235 |
+
|
| 236 |
+
Example:
|
| 237 |
+
memories = await system.add_batch([
|
| 238 |
+
{"content": "Fact 1", "user_id": "alice"},
|
| 239 |
+
{"content": "Fact 2", "user_id": "alice"},
|
| 240 |
+
])
|
| 241 |
+
"""
|
| 242 |
+
# Create Memory objects
|
| 243 |
+
memories: list[Memory] = []
|
| 244 |
+
for data in memories_data:
|
| 245 |
+
memory = Memory(
|
| 246 |
+
content=data["content"],
|
| 247 |
+
user_id=data["user_id"],
|
| 248 |
+
session_id=data.get("session_id"),
|
| 249 |
+
agent_id=data.get("agent_id"),
|
| 250 |
+
turn_id=data.get("turn_id"),
|
| 251 |
+
category=data.get("category", MemoryCategory.FACT),
|
| 252 |
+
importance=data.get("importance", 0.5),
|
| 253 |
+
entity_refs=data.get("entity_refs", []),
|
| 254 |
+
metadata=data.get("metadata", {}),
|
| 255 |
+
)
|
| 256 |
+
memories.append(memory)
|
| 257 |
+
|
| 258 |
+
# Batch embed
|
| 259 |
+
if auto_embed:
|
| 260 |
+
texts = [m.content for m in memories]
|
| 261 |
+
embeddings = await self._embedder.embed_batch(texts)
|
| 262 |
+
for memory, embedding in zip(memories, embeddings):
|
| 263 |
+
memory.embedding = embedding
|
| 264 |
+
|
| 265 |
+
# Batch save
|
| 266 |
+
await self._store.save_batch(memories)
|
| 267 |
+
|
| 268 |
+
# Batch index for vector search
|
| 269 |
+
memories_with_embeddings = [m for m in memories if m.embedding is not None]
|
| 270 |
+
if memories_with_embeddings:
|
| 271 |
+
await self._vector_index.index_batch(memories_with_embeddings)
|
| 272 |
+
|
| 273 |
+
# Index for text search
|
| 274 |
+
for memory in memories:
|
| 275 |
+
await self._index_for_text_search(memory)
|
| 276 |
+
|
| 277 |
+
# Update cache
|
| 278 |
+
if self._cache is not None:
|
| 279 |
+
await self._cache.put_batch(memories)
|
| 280 |
+
|
| 281 |
+
logger.debug(f"Added batch of {len(memories)} memories")
|
| 282 |
+
return memories
|
| 283 |
+
|
| 284 |
+
# =========================================================================
|
| 285 |
+
# Memory Retrieval
|
| 286 |
+
# =========================================================================
|
| 287 |
+
|
| 288 |
+
async def get(self, memory_id: str) -> Memory | None:
|
| 289 |
+
"""Get a memory by ID.
|
| 290 |
+
|
| 291 |
+
Checks cache first, then falls back to store.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
memory_id: The unique memory identifier.
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
The Memory if found, None otherwise.
|
| 298 |
+
"""
|
| 299 |
+
# Check cache first
|
| 300 |
+
if self._cache is not None:
|
| 301 |
+
cached = await self._cache.get(memory_id)
|
| 302 |
+
if cached is not None:
|
| 303 |
+
return cached
|
| 304 |
+
|
| 305 |
+
# Fall back to store
|
| 306 |
+
memory = await self._store.get(memory_id)
|
| 307 |
+
|
| 308 |
+
# Update cache on hit
|
| 309 |
+
if memory is not None and self._cache is not None:
|
| 310 |
+
await self._cache.put(memory)
|
| 311 |
+
|
| 312 |
+
return memory
|
| 313 |
+
|
| 314 |
+
async def query(self, filter: MemoryFilter) -> list[Memory]:
|
| 315 |
+
"""Query memories with filtering.
|
| 316 |
+
|
| 317 |
+
Args:
|
| 318 |
+
filter: Filter criteria for the query.
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
List of matching memories.
|
| 322 |
+
|
| 323 |
+
Example:
|
| 324 |
+
memories = await system.query(MemoryFilter(
|
| 325 |
+
user_id="alice",
|
| 326 |
+
categories=[MemoryCategory.PREFERENCE],
|
| 327 |
+
min_importance=0.7,
|
| 328 |
+
limit=10,
|
| 329 |
+
))
|
| 330 |
+
"""
|
| 331 |
+
return await self._store.query(filter)
|
| 332 |
+
|
| 333 |
+
async def count(self, filter: MemoryFilter) -> int:
|
| 334 |
+
"""Count memories matching filter criteria.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
filter: Filter criteria.
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
Number of matching memories.
|
| 341 |
+
"""
|
| 342 |
+
return await self._store.count(filter)
|
| 343 |
+
|
| 344 |
+
# =========================================================================
|
| 345 |
+
# Search Operations
|
| 346 |
+
# =========================================================================
|
| 347 |
+
|
| 348 |
+
async def search(
|
| 349 |
+
self,
|
| 350 |
+
query: str,
|
| 351 |
+
user_id: str | None = None,
|
| 352 |
+
session_id: str | None = None,
|
| 353 |
+
agent_id: str | None = None,
|
| 354 |
+
top_k: int = 10,
|
| 355 |
+
min_similarity: float = 0.0,
|
| 356 |
+
categories: list[MemoryCategory] | None = None,
|
| 357 |
+
scope_levels: list[ScopeLevel] | None = None,
|
| 358 |
+
include_superseded: bool = False,
|
| 359 |
+
) -> list[VectorSearchResult]:
|
| 360 |
+
"""Semantic search for similar memories.
|
| 361 |
+
|
| 362 |
+
Uses vector similarity to find memories semantically similar
|
| 363 |
+
to the query text.
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
query: Search query text.
|
| 367 |
+
user_id: Filter by user.
|
| 368 |
+
session_id: Filter by session.
|
| 369 |
+
agent_id: Filter by agent.
|
| 370 |
+
top_k: Maximum number of results.
|
| 371 |
+
min_similarity: Minimum cosine similarity threshold.
|
| 372 |
+
categories: Filter by categories.
|
| 373 |
+
scope_levels: Filter by scope levels.
|
| 374 |
+
include_superseded: Include superseded memories.
|
| 375 |
+
|
| 376 |
+
Returns:
|
| 377 |
+
List of VectorSearchResult sorted by similarity.
|
| 378 |
+
|
| 379 |
+
Example:
|
| 380 |
+
results = await system.search(
|
| 381 |
+
"programming preferences",
|
| 382 |
+
user_id="alice",
|
| 383 |
+
top_k=5,
|
| 384 |
+
)
|
| 385 |
+
for result in results:
|
| 386 |
+
print(f"{result.similarity:.2f}: {result.memory.content}")
|
| 387 |
+
"""
|
| 388 |
+
# Embed query
|
| 389 |
+
query_vector = await self._embedder.embed(query)
|
| 390 |
+
|
| 391 |
+
# Build filter
|
| 392 |
+
vector_filter = VectorFilter(
|
| 393 |
+
query_vector=query_vector,
|
| 394 |
+
top_k=top_k,
|
| 395 |
+
min_similarity=min_similarity,
|
| 396 |
+
user_id=user_id,
|
| 397 |
+
session_id=session_id,
|
| 398 |
+
agent_id=agent_id,
|
| 399 |
+
categories=categories,
|
| 400 |
+
scope_levels=scope_levels,
|
| 401 |
+
include_superseded=include_superseded,
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
return await self._vector_index.search(vector_filter)
|
| 405 |
+
|
| 406 |
+
async def text_search(
|
| 407 |
+
self,
|
| 408 |
+
query: str,
|
| 409 |
+
user_id: str | None = None,
|
| 410 |
+
session_id: str | None = None,
|
| 411 |
+
limit: int = 100,
|
| 412 |
+
categories: list[MemoryCategory] | None = None,
|
| 413 |
+
) -> list[TextSearchResult]:
|
| 414 |
+
"""Full-text search for memories.
|
| 415 |
+
|
| 416 |
+
Uses keyword matching with BM25 ranking to find memories
|
| 417 |
+
containing the search terms.
|
| 418 |
+
|
| 419 |
+
Args:
|
| 420 |
+
query: Search query text.
|
| 421 |
+
user_id: Filter by user.
|
| 422 |
+
session_id: Filter by session.
|
| 423 |
+
limit: Maximum number of results.
|
| 424 |
+
categories: Filter by categories.
|
| 425 |
+
|
| 426 |
+
Returns:
|
| 427 |
+
List of TextSearchResult sorted by relevance.
|
| 428 |
+
|
| 429 |
+
Example:
|
| 430 |
+
results = await system.text_search("Python", user_id="alice")
|
| 431 |
+
"""
|
| 432 |
+
text_filter = TextFilter(
|
| 433 |
+
query=query,
|
| 434 |
+
user_id=user_id,
|
| 435 |
+
session_id=session_id,
|
| 436 |
+
limit=limit,
|
| 437 |
+
categories=categories,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
return await self._text_index.search(text_filter)
|
| 441 |
+
|
| 442 |
+
# =========================================================================
|
| 443 |
+
# Memory Updates
|
| 444 |
+
# =========================================================================
|
| 445 |
+
|
| 446 |
+
async def update(
|
| 447 |
+
self,
|
| 448 |
+
memory_id: str,
|
| 449 |
+
content: str | None = None,
|
| 450 |
+
importance: float | None = None,
|
| 451 |
+
category: MemoryCategory | None = None,
|
| 452 |
+
entity_refs: list[str] | None = None,
|
| 453 |
+
metadata: dict[str, Any] | None = None,
|
| 454 |
+
re_embed: bool = True,
|
| 455 |
+
) -> Memory | None:
|
| 456 |
+
"""Update an existing memory.
|
| 457 |
+
|
| 458 |
+
Updates the specified fields and re-indexes if content changes.
|
| 459 |
+
|
| 460 |
+
Args:
|
| 461 |
+
memory_id: ID of memory to update.
|
| 462 |
+
content: New content (triggers re-embedding if re_embed=True).
|
| 463 |
+
importance: New importance score.
|
| 464 |
+
category: New category.
|
| 465 |
+
entity_refs: New entity references.
|
| 466 |
+
metadata: New or updated metadata (merged with existing).
|
| 467 |
+
re_embed: Whether to regenerate embedding on content change.
|
| 468 |
+
|
| 469 |
+
Returns:
|
| 470 |
+
Updated Memory, or None if not found.
|
| 471 |
+
"""
|
| 472 |
+
memory = await self._store.get(memory_id)
|
| 473 |
+
if memory is None:
|
| 474 |
+
return None
|
| 475 |
+
|
| 476 |
+
content_changed = False
|
| 477 |
+
|
| 478 |
+
if content is not None and content != memory.content:
|
| 479 |
+
memory.content = content
|
| 480 |
+
content_changed = True
|
| 481 |
+
|
| 482 |
+
if importance is not None:
|
| 483 |
+
memory.importance = importance
|
| 484 |
+
|
| 485 |
+
if category is not None:
|
| 486 |
+
memory.category = category
|
| 487 |
+
|
| 488 |
+
if entity_refs is not None:
|
| 489 |
+
memory.entity_refs = entity_refs
|
| 490 |
+
|
| 491 |
+
if metadata is not None:
|
| 492 |
+
memory.metadata.update(metadata)
|
| 493 |
+
|
| 494 |
+
# Re-embed if content changed
|
| 495 |
+
if content_changed and re_embed:
|
| 496 |
+
memory.embedding = await self._embedder.embed(memory.content)
|
| 497 |
+
|
| 498 |
+
# Save updates
|
| 499 |
+
await self._store.save(memory)
|
| 500 |
+
|
| 501 |
+
# Update indexes
|
| 502 |
+
if content_changed:
|
| 503 |
+
if memory.embedding is not None:
|
| 504 |
+
await self._vector_index.index(memory)
|
| 505 |
+
await self._index_for_text_search(memory)
|
| 506 |
+
|
| 507 |
+
# Invalidate and re-cache
|
| 508 |
+
if self._cache is not None:
|
| 509 |
+
await self._cache.invalidate(memory_id)
|
| 510 |
+
await self._cache.put(memory)
|
| 511 |
+
|
| 512 |
+
return memory
|
| 513 |
+
|
| 514 |
+
async def supersede(
|
| 515 |
+
self,
|
| 516 |
+
old_memory_id: str,
|
| 517 |
+
new_content: str,
|
| 518 |
+
supersede_time: datetime | None = None,
|
| 519 |
+
auto_embed: bool = True,
|
| 520 |
+
) -> Memory:
|
| 521 |
+
"""Supersede an existing memory with a new version.
|
| 522 |
+
|
| 523 |
+
Creates a temporal chain where the old memory's validity ends
|
| 524 |
+
and the new memory begins. Both are kept for historical queries.
|
| 525 |
+
|
| 526 |
+
Args:
|
| 527 |
+
old_memory_id: ID of memory to supersede.
|
| 528 |
+
new_content: Content for the new memory.
|
| 529 |
+
supersede_time: When the supersession occurred (default: now).
|
| 530 |
+
auto_embed: Whether to embed the new content.
|
| 531 |
+
|
| 532 |
+
Returns:
|
| 533 |
+
The new Memory that supersedes the old one.
|
| 534 |
+
|
| 535 |
+
Raises:
|
| 536 |
+
ValueError: If old memory not found.
|
| 537 |
+
|
| 538 |
+
Example:
|
| 539 |
+
# User's preference changed
|
| 540 |
+
new_mem = await system.supersede(
|
| 541 |
+
old_memory.id,
|
| 542 |
+
"User now prefers JavaScript over Python",
|
| 543 |
+
)
|
| 544 |
+
"""
|
| 545 |
+
# Get old memory
|
| 546 |
+
old_memory = await self._store.get(old_memory_id)
|
| 547 |
+
if old_memory is None:
|
| 548 |
+
raise ValueError(f"Memory {old_memory_id} not found")
|
| 549 |
+
|
| 550 |
+
# Create new memory with same scope
|
| 551 |
+
new_memory = Memory(
|
| 552 |
+
content=new_content,
|
| 553 |
+
user_id=old_memory.user_id,
|
| 554 |
+
session_id=old_memory.session_id,
|
| 555 |
+
agent_id=old_memory.agent_id,
|
| 556 |
+
turn_id=old_memory.turn_id,
|
| 557 |
+
category=old_memory.category,
|
| 558 |
+
importance=old_memory.importance,
|
| 559 |
+
entity_refs=old_memory.entity_refs.copy(),
|
| 560 |
+
metadata=old_memory.metadata.copy(),
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
# Embed new content
|
| 564 |
+
if auto_embed:
|
| 565 |
+
new_memory.embedding = await self._embedder.embed(new_content)
|
| 566 |
+
|
| 567 |
+
# Perform supersession in store
|
| 568 |
+
new_memory = await self._store.supersede(old_memory_id, new_memory, supersede_time)
|
| 569 |
+
|
| 570 |
+
# Update indexes
|
| 571 |
+
if new_memory.embedding is not None:
|
| 572 |
+
await self._vector_index.index(new_memory)
|
| 573 |
+
await self._index_for_text_search(new_memory)
|
| 574 |
+
|
| 575 |
+
# Update cache
|
| 576 |
+
if self._cache is not None:
|
| 577 |
+
await self._cache.invalidate(old_memory_id)
|
| 578 |
+
await self._cache.put(new_memory)
|
| 579 |
+
|
| 580 |
+
logger.debug(f"Superseded memory {old_memory_id} with {new_memory.id}")
|
| 581 |
+
return new_memory
|
| 582 |
+
|
| 583 |
+
async def get_history(
|
| 584 |
+
self,
|
| 585 |
+
memory_id: str,
|
| 586 |
+
include_future: bool = False,
|
| 587 |
+
) -> list[Memory]:
|
| 588 |
+
"""Get the full history chain for a memory.
|
| 589 |
+
|
| 590 |
+
Follows supersession links to return all versions of a memory.
|
| 591 |
+
|
| 592 |
+
Args:
|
| 593 |
+
memory_id: ID of any memory in the chain.
|
| 594 |
+
include_future: Whether to include memories that superseded this one.
|
| 595 |
+
|
| 596 |
+
Returns:
|
| 597 |
+
List of memories in temporal order (oldest first).
|
| 598 |
+
"""
|
| 599 |
+
return await self._store.get_history(memory_id, include_future)
|
| 600 |
+
|
| 601 |
+
# =========================================================================
|
| 602 |
+
# Memory Deletion
|
| 603 |
+
# =========================================================================
|
| 604 |
+
|
| 605 |
+
async def delete(self, memory_id: str) -> bool:
|
| 606 |
+
"""Delete a memory from all indexes.
|
| 607 |
+
|
| 608 |
+
Args:
|
| 609 |
+
memory_id: ID of memory to delete.
|
| 610 |
+
|
| 611 |
+
Returns:
|
| 612 |
+
True if deleted, False if not found.
|
| 613 |
+
"""
|
| 614 |
+
# Delete from store
|
| 615 |
+
deleted = await self._store.delete(memory_id)
|
| 616 |
+
|
| 617 |
+
if deleted:
|
| 618 |
+
# Remove from indexes
|
| 619 |
+
await self._vector_index.remove(memory_id)
|
| 620 |
+
await self._text_index.remove(memory_id)
|
| 621 |
+
|
| 622 |
+
# Invalidate cache
|
| 623 |
+
if self._cache is not None:
|
| 624 |
+
await self._cache.invalidate(memory_id)
|
| 625 |
+
|
| 626 |
+
return deleted
|
| 627 |
+
|
| 628 |
+
async def clear_scope(
|
| 629 |
+
self,
|
| 630 |
+
user_id: str,
|
| 631 |
+
session_id: str | None = None,
|
| 632 |
+
agent_id: str | None = None,
|
| 633 |
+
turn_id: str | None = None,
|
| 634 |
+
) -> int:
|
| 635 |
+
"""Clear all memories at or below a scope level.
|
| 636 |
+
|
| 637 |
+
Args:
|
| 638 |
+
user_id: Required user scope.
|
| 639 |
+
session_id: If provided, clear session and below.
|
| 640 |
+
agent_id: If provided, clear agent and below.
|
| 641 |
+
turn_id: If provided, clear only that turn.
|
| 642 |
+
|
| 643 |
+
Returns:
|
| 644 |
+
Number of memories deleted.
|
| 645 |
+
"""
|
| 646 |
+
# Get IDs of memories to clear
|
| 647 |
+
filter = MemoryFilter(
|
| 648 |
+
user_id=user_id,
|
| 649 |
+
session_id=session_id,
|
| 650 |
+
agent_id=agent_id,
|
| 651 |
+
turn_id=turn_id,
|
| 652 |
+
include_superseded=True, # Clear all versions
|
| 653 |
+
)
|
| 654 |
+
memories = await self._store.query(filter)
|
| 655 |
+
memory_ids = [m.id for m in memories]
|
| 656 |
+
|
| 657 |
+
if not memory_ids:
|
| 658 |
+
return 0
|
| 659 |
+
|
| 660 |
+
# Clear from store
|
| 661 |
+
count = await self._store.clear_scope(user_id, session_id, agent_id, turn_id)
|
| 662 |
+
|
| 663 |
+
# Clear from indexes
|
| 664 |
+
await self._vector_index.remove_batch(memory_ids)
|
| 665 |
+
for mid in memory_ids:
|
| 666 |
+
await self._text_index.remove(mid)
|
| 667 |
+
|
| 668 |
+
# Clear from cache
|
| 669 |
+
if self._cache is not None:
|
| 670 |
+
await self._cache.invalidate_scope(user_id, session_id, agent_id)
|
| 671 |
+
|
| 672 |
+
logger.debug(f"Cleared {count} memories at scope user={user_id}, session={session_id}")
|
| 673 |
+
return count
|
| 674 |
+
|
| 675 |
+
# =========================================================================
|
| 676 |
+
# Convenience Methods
|
| 677 |
+
# =========================================================================
|
| 678 |
+
|
| 679 |
+
async def remember(
|
| 680 |
+
self,
|
| 681 |
+
content: str,
|
| 682 |
+
user_id: str,
|
| 683 |
+
session_id: str | None = None,
|
| 684 |
+
category: MemoryCategory = MemoryCategory.FACT,
|
| 685 |
+
importance: float = 0.5,
|
| 686 |
+
) -> Memory:
|
| 687 |
+
"""Convenience method to quickly add a memory.
|
| 688 |
+
|
| 689 |
+
Shorthand for add() with common parameters.
|
| 690 |
+
|
| 691 |
+
Args:
|
| 692 |
+
content: What to remember.
|
| 693 |
+
user_id: Who it's for.
|
| 694 |
+
session_id: Optional session context.
|
| 695 |
+
category: Memory category.
|
| 696 |
+
importance: How important (0.0 - 1.0).
|
| 697 |
+
|
| 698 |
+
Returns:
|
| 699 |
+
The created Memory.
|
| 700 |
+
|
| 701 |
+
Example:
|
| 702 |
+
await system.remember("Likes coffee", user_id="alice", importance=0.7)
|
| 703 |
+
"""
|
| 704 |
+
return await self.add(
|
| 705 |
+
content=content,
|
| 706 |
+
user_id=user_id,
|
| 707 |
+
session_id=session_id,
|
| 708 |
+
category=category,
|
| 709 |
+
importance=importance,
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
async def recall(
|
| 713 |
+
self,
|
| 714 |
+
query: str,
|
| 715 |
+
user_id: str,
|
| 716 |
+
top_k: int = 5,
|
| 717 |
+
) -> list[Memory]:
|
| 718 |
+
"""Convenience method to recall relevant memories.
|
| 719 |
+
|
| 720 |
+
Performs semantic search and returns just the Memory objects.
|
| 721 |
+
|
| 722 |
+
Args:
|
| 723 |
+
query: What to recall.
|
| 724 |
+
user_id: Whose memories to search.
|
| 725 |
+
top_k: Maximum memories to return.
|
| 726 |
+
|
| 727 |
+
Returns:
|
| 728 |
+
List of relevant Memory objects.
|
| 729 |
+
|
| 730 |
+
Example:
|
| 731 |
+
memories = await system.recall("coffee preferences", user_id="alice")
|
| 732 |
+
"""
|
| 733 |
+
results = await self.search(query, user_id=user_id, top_k=top_k)
|
| 734 |
+
return [r.memory for r in results]
|
| 735 |
+
|
| 736 |
+
async def get_user_memories(
|
| 737 |
+
self,
|
| 738 |
+
user_id: str,
|
| 739 |
+
limit: int = 100,
|
| 740 |
+
include_sessions: bool = True,
|
| 741 |
+
) -> list[Memory]:
|
| 742 |
+
"""Get all memories for a user.
|
| 743 |
+
|
| 744 |
+
Args:
|
| 745 |
+
user_id: User identifier.
|
| 746 |
+
limit: Maximum memories to return.
|
| 747 |
+
include_sessions: If True, include session-level memories.
|
| 748 |
+
If False, only return user-level memories.
|
| 749 |
+
|
| 750 |
+
Returns:
|
| 751 |
+
List of memories for the user.
|
| 752 |
+
"""
|
| 753 |
+
filter = MemoryFilter(
|
| 754 |
+
user_id=user_id,
|
| 755 |
+
limit=limit,
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
if not include_sessions:
|
| 759 |
+
filter.scope_levels = [ScopeLevel.USER]
|
| 760 |
+
|
| 761 |
+
return await self._store.query(filter)
|
| 762 |
+
|
| 763 |
+
async def get_session_memories(
|
| 764 |
+
self,
|
| 765 |
+
user_id: str,
|
| 766 |
+
session_id: str,
|
| 767 |
+
limit: int = 100,
|
| 768 |
+
) -> list[Memory]:
|
| 769 |
+
"""Get all memories for a session.
|
| 770 |
+
|
| 771 |
+
Args:
|
| 772 |
+
user_id: User identifier.
|
| 773 |
+
session_id: Session identifier.
|
| 774 |
+
limit: Maximum memories to return.
|
| 775 |
+
|
| 776 |
+
Returns:
|
| 777 |
+
List of memories for the session.
|
| 778 |
+
"""
|
| 779 |
+
filter = MemoryFilter(
|
| 780 |
+
user_id=user_id,
|
| 781 |
+
session_id=session_id,
|
| 782 |
+
limit=limit,
|
| 783 |
+
)
|
| 784 |
+
return await self._store.query(filter)
|
| 785 |
+
|
| 786 |
+
# =========================================================================
|
| 787 |
+
# Internal Methods
|
| 788 |
+
# =========================================================================
|
| 789 |
+
|
| 790 |
+
async def _index_for_text_search(self, memory: Memory) -> None:
|
| 791 |
+
"""Index a memory for full-text search.
|
| 792 |
+
|
| 793 |
+
Uses the protocol-compliant async method on the text index.
|
| 794 |
+
"""
|
| 795 |
+
# Use the async index_memory method which is protocol-compliant
|
| 796 |
+
await self._text_index.index_memory(memory) # type: ignore[attr-defined]
|
| 797 |
+
|
| 798 |
+
async def _maybe_bubble(self, memory: Memory) -> None:
|
| 799 |
+
"""Maybe bubble a memory up the hierarchy based on category and importance.
|
| 800 |
+
|
| 801 |
+
Bubbling creates a copy of the memory at a higher scope level.
|
| 802 |
+
Only happens if the memory meets bubbling criteria.
|
| 803 |
+
"""
|
| 804 |
+
# Determine target scope based on category
|
| 805 |
+
target_scope: ScopeLevel | None = None
|
| 806 |
+
|
| 807 |
+
if memory.category == MemoryCategory.PREFERENCE:
|
| 808 |
+
target_scope = self._config.preference_bubble_to
|
| 809 |
+
elif memory.category == MemoryCategory.DECISION:
|
| 810 |
+
target_scope = self._config.decision_bubble_to
|
| 811 |
+
|
| 812 |
+
if target_scope is None:
|
| 813 |
+
return
|
| 814 |
+
|
| 815 |
+
# Check if already at or above target scope
|
| 816 |
+
current_scope = memory.scope_level
|
| 817 |
+
if self._scope_level_value(current_scope) <= self._scope_level_value(target_scope):
|
| 818 |
+
return
|
| 819 |
+
|
| 820 |
+
# Only bubble if importance is high enough (> 0.7)
|
| 821 |
+
if memory.importance < 0.7:
|
| 822 |
+
return
|
| 823 |
+
|
| 824 |
+
# Create bubbled memory at target scope
|
| 825 |
+
bubbled = Memory(
|
| 826 |
+
content=memory.content,
|
| 827 |
+
user_id=memory.user_id,
|
| 828 |
+
session_id=memory.session_id if target_scope != ScopeLevel.USER else None,
|
| 829 |
+
agent_id=None, # Don't bubble to agent level
|
| 830 |
+
turn_id=None,
|
| 831 |
+
category=memory.category,
|
| 832 |
+
importance=memory.importance,
|
| 833 |
+
entity_refs=memory.entity_refs.copy(),
|
| 834 |
+
metadata=memory.metadata.copy(),
|
| 835 |
+
embedding=memory.embedding.copy() if memory.embedding is not None else None,
|
| 836 |
+
promoted_from=memory.id,
|
| 837 |
+
promotion_chain=memory.promotion_chain + [memory.id],
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
# Save bubbled memory
|
| 841 |
+
await self._store.save(bubbled)
|
| 842 |
+
|
| 843 |
+
# Index bubbled memory
|
| 844 |
+
if bubbled.embedding is not None:
|
| 845 |
+
await self._vector_index.index(bubbled)
|
| 846 |
+
await self._index_for_text_search(bubbled)
|
| 847 |
+
|
| 848 |
+
logger.debug(
|
| 849 |
+
f"Bubbled memory {memory.id} from {current_scope.value} to {target_scope.value}"
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
def _scope_level_value(self, level: ScopeLevel) -> int:
|
| 853 |
+
"""Get numeric value for scope level (lower = broader scope)."""
|
| 854 |
+
return {
|
| 855 |
+
ScopeLevel.USER: 0,
|
| 856 |
+
ScopeLevel.SESSION: 1,
|
| 857 |
+
ScopeLevel.AGENT: 2,
|
| 858 |
+
ScopeLevel.TURN: 3,
|
| 859 |
+
}[level]
|
| 860 |
+
|
| 861 |
+
# =========================================================================
|
| 862 |
+
# Properties
|
| 863 |
+
# =========================================================================
|
| 864 |
+
|
| 865 |
+
@property
|
| 866 |
+
def store(self) -> MemoryStore:
|
| 867 |
+
"""Access the underlying memory store."""
|
| 868 |
+
return self._store
|
| 869 |
+
|
| 870 |
+
@property
|
| 871 |
+
def vector_index(self) -> VectorIndex:
|
| 872 |
+
"""Access the underlying vector index."""
|
| 873 |
+
return self._vector_index
|
| 874 |
+
|
| 875 |
+
@property
|
| 876 |
+
def text_index(self) -> TextIndex:
|
| 877 |
+
"""Access the underlying text index."""
|
| 878 |
+
return self._text_index
|
| 879 |
+
|
| 880 |
+
@property
|
| 881 |
+
def embedder(self) -> Embedder:
|
| 882 |
+
"""Access the underlying embedder."""
|
| 883 |
+
return self._embedder
|
| 884 |
+
|
| 885 |
+
@property
|
| 886 |
+
def cache(self) -> MemoryCache | None:
|
| 887 |
+
"""Access the underlying cache (may be None)."""
|
| 888 |
+
return self._cache
|
| 889 |
+
|
| 890 |
+
@property
|
| 891 |
+
def config(self) -> MemoryConfig:
|
| 892 |
+
"""Access the configuration."""
|
| 893 |
+
return self._config
|
|
@@ -1,390 +0,0 @@
|
|
| 1 |
-
"""Memory extraction using LLMs.
|
| 2 |
-
|
| 3 |
-
Supports multiple providers by reusing the wrapped client with a cheap model.
|
| 4 |
-
Auto-detects provider from client class and selects appropriate cheap model.
|
| 5 |
-
Uses structured JSON output where available for reliable parsing.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import json
|
| 11 |
-
import logging
|
| 12 |
-
import re
|
| 13 |
-
from typing import Any, Protocol
|
| 14 |
-
|
| 15 |
-
from headroom.memory.store import Memory
|
| 16 |
-
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
# Provider → Cheap Model mapping (verified January 2026)
|
| 21 |
-
# These are the most cost-effective models for simple extraction tasks
|
| 22 |
-
CHEAP_MODELS: dict[str, str] = {
|
| 23 |
-
"openai": "gpt-4o-mini", # $0.15/1M input, $0.60/1M output
|
| 24 |
-
"anthropic": "claude-3-5-haiku-latest", # $0.80/1M input, $4/1M output
|
| 25 |
-
"mistralai": "mistral-small-latest", # $0.10/1M input, $0.30/1M output
|
| 26 |
-
"groq": "llama-3.3-70b-versatile", # Free tier available
|
| 27 |
-
"together": "meta-llama/Llama-3.3-70B-Instruct-Turbo", # $0.88/1M
|
| 28 |
-
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct", # $0.20/1M
|
| 29 |
-
"google": "gemini-2.0-flash-lite", # $0.075/1M input, $0.30/1M output
|
| 30 |
-
"cohere": "command-r7b-12-2024", # $0.0375/1M input, $0.15/1M output
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
# Providers that support structured JSON output via response_format
|
| 34 |
-
SUPPORTS_JSON_MODE: set[str] = {"openai", "mistralai", "groq", "together", "fireworks"}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
# Entity-agnostic prompt - works for users, agents, or any conversational entity
|
| 38 |
-
EXTRACTION_PROMPT = """Analyze this conversation and extract any facts worth remembering.
|
| 39 |
-
|
| 40 |
-
Focus on:
|
| 41 |
-
- Preferences (language, tools, frameworks, style, configuration)
|
| 42 |
-
- Facts (identity, role, capabilities, constraints, environment)
|
| 43 |
-
- Context (goals, ongoing tasks, relationships, history)
|
| 44 |
-
|
| 45 |
-
Conversation:
|
| 46 |
-
Speaker A: {query}
|
| 47 |
-
Speaker B: {response}
|
| 48 |
-
|
| 49 |
-
Return a JSON object with this structure:
|
| 50 |
-
{{
|
| 51 |
-
"memories": [
|
| 52 |
-
{{"content": "Prefers Python for backend development", "category": "preference", "importance": 0.8}},
|
| 53 |
-
{{"content": "Works on distributed systems", "category": "fact", "importance": 0.7}}
|
| 54 |
-
],
|
| 55 |
-
"should_remember": true
|
| 56 |
-
}}
|
| 57 |
-
|
| 58 |
-
Categories: "preference", "fact", "context"
|
| 59 |
-
Importance: 0.0-1.0 (higher = more important to remember long-term)
|
| 60 |
-
|
| 61 |
-
If there's nothing worth remembering (greetings, generic questions, transient info), return:
|
| 62 |
-
{{"memories": [], "should_remember": false}}
|
| 63 |
-
|
| 64 |
-
Return ONLY valid JSON."""
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
class ChatClient(Protocol):
|
| 68 |
-
"""Protocol for chat clients (OpenAI, Anthropic, etc.)."""
|
| 69 |
-
|
| 70 |
-
class Chat:
|
| 71 |
-
class Completions:
|
| 72 |
-
def create(self, **kwargs: Any) -> Any: ...
|
| 73 |
-
|
| 74 |
-
completions: Completions
|
| 75 |
-
|
| 76 |
-
chat: Chat
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def detect_provider(client: Any) -> str | None:
|
| 80 |
-
"""Detect the provider from client class path.
|
| 81 |
-
|
| 82 |
-
Args:
|
| 83 |
-
client: The LLM client instance
|
| 84 |
-
|
| 85 |
-
Returns:
|
| 86 |
-
Provider name or None if unknown
|
| 87 |
-
"""
|
| 88 |
-
module = type(client).__module__.lower()
|
| 89 |
-
|
| 90 |
-
# Check for known providers
|
| 91 |
-
providers = [
|
| 92 |
-
"openai",
|
| 93 |
-
"anthropic",
|
| 94 |
-
"mistralai",
|
| 95 |
-
"groq",
|
| 96 |
-
"together",
|
| 97 |
-
"fireworks",
|
| 98 |
-
"google",
|
| 99 |
-
"cohere",
|
| 100 |
-
]
|
| 101 |
-
|
| 102 |
-
for provider in providers:
|
| 103 |
-
if provider in module:
|
| 104 |
-
return provider
|
| 105 |
-
|
| 106 |
-
return None
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def get_cheap_model(provider: str) -> str | None:
|
| 110 |
-
"""Get the cheap model for a provider.
|
| 111 |
-
|
| 112 |
-
Args:
|
| 113 |
-
provider: Provider name
|
| 114 |
-
|
| 115 |
-
Returns:
|
| 116 |
-
Cheap model ID or None if unknown
|
| 117 |
-
"""
|
| 118 |
-
return CHEAP_MODELS.get(provider)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
class MemoryExtractor:
|
| 122 |
-
"""Extracts memories from conversations using LLMs.
|
| 123 |
-
|
| 124 |
-
Supports multiple providers by reusing the wrapped client.
|
| 125 |
-
Auto-detects provider and selects appropriate cheap model.
|
| 126 |
-
|
| 127 |
-
Usage:
|
| 128 |
-
extractor = MemoryExtractor(openai_client)
|
| 129 |
-
memories = extractor.extract("I prefer Python", "Great choice!")
|
| 130 |
-
"""
|
| 131 |
-
|
| 132 |
-
def __init__(
|
| 133 |
-
self,
|
| 134 |
-
client: Any,
|
| 135 |
-
model: str | None = None,
|
| 136 |
-
):
|
| 137 |
-
"""Initialize the extractor.
|
| 138 |
-
|
| 139 |
-
Args:
|
| 140 |
-
client: LLM client (OpenAI, Anthropic, etc.)
|
| 141 |
-
model: Override the extraction model (auto-detects if None)
|
| 142 |
-
"""
|
| 143 |
-
self.client = client
|
| 144 |
-
self._provider = detect_provider(client)
|
| 145 |
-
self._model: str | None = None
|
| 146 |
-
|
| 147 |
-
if model:
|
| 148 |
-
self._model = model
|
| 149 |
-
elif self._provider:
|
| 150 |
-
self._model = get_cheap_model(self._provider)
|
| 151 |
-
|
| 152 |
-
if not self._model:
|
| 153 |
-
logger.warning(
|
| 154 |
-
f"Could not detect cheap model for provider. "
|
| 155 |
-
f"Client type: {type(client).__module__}.{type(client).__name__}. "
|
| 156 |
-
f"Memory extraction may fail."
|
| 157 |
-
)
|
| 158 |
-
|
| 159 |
-
@property
|
| 160 |
-
def provider(self) -> str | None:
|
| 161 |
-
"""Get the detected provider."""
|
| 162 |
-
return self._provider
|
| 163 |
-
|
| 164 |
-
@property
|
| 165 |
-
def model(self) -> str | None:
|
| 166 |
-
"""Get the extraction model."""
|
| 167 |
-
return self._model
|
| 168 |
-
|
| 169 |
-
def extract(self, query: str, response: str) -> list[Memory]:
|
| 170 |
-
"""Extract memories from a conversation turn.
|
| 171 |
-
|
| 172 |
-
Args:
|
| 173 |
-
query: User's message
|
| 174 |
-
response: Assistant's response
|
| 175 |
-
|
| 176 |
-
Returns:
|
| 177 |
-
List of extracted memories (may be empty)
|
| 178 |
-
"""
|
| 179 |
-
if not self._model:
|
| 180 |
-
logger.warning("No extraction model configured, skipping extraction")
|
| 181 |
-
return []
|
| 182 |
-
|
| 183 |
-
prompt = EXTRACTION_PROMPT.format(query=query, response=response)
|
| 184 |
-
|
| 185 |
-
try:
|
| 186 |
-
result = self._call_llm(prompt)
|
| 187 |
-
return self._parse_response(result)
|
| 188 |
-
except Exception as e:
|
| 189 |
-
logger.error(f"Extraction failed: {e}")
|
| 190 |
-
return []
|
| 191 |
-
|
| 192 |
-
def extract_batch(self, conversations: list[tuple[str, str, str]]) -> dict[str, list[Memory]]:
|
| 193 |
-
"""Extract memories from multiple conversations.
|
| 194 |
-
|
| 195 |
-
Args:
|
| 196 |
-
conversations: List of (user_id, query, response) tuples
|
| 197 |
-
|
| 198 |
-
Returns:
|
| 199 |
-
Dict mapping user_id to list of memories
|
| 200 |
-
"""
|
| 201 |
-
if not conversations:
|
| 202 |
-
return {}
|
| 203 |
-
|
| 204 |
-
# Build batch prompt
|
| 205 |
-
batch_prompt = self._build_batch_prompt(conversations)
|
| 206 |
-
|
| 207 |
-
try:
|
| 208 |
-
result = self._call_llm(batch_prompt)
|
| 209 |
-
return self._parse_batch_response(result, conversations)
|
| 210 |
-
except Exception as e:
|
| 211 |
-
logger.error(f"Batch extraction failed: {e}")
|
| 212 |
-
return {}
|
| 213 |
-
|
| 214 |
-
def _call_llm(self, prompt: str) -> str:
|
| 215 |
-
"""Call the LLM with the given prompt.
|
| 216 |
-
|
| 217 |
-
Uses structured JSON output (response_format) where available
|
| 218 |
-
to ensure reliable JSON parsing.
|
| 219 |
-
|
| 220 |
-
Args:
|
| 221 |
-
prompt: The prompt to send
|
| 222 |
-
|
| 223 |
-
Returns:
|
| 224 |
-
The LLM's response text
|
| 225 |
-
"""
|
| 226 |
-
if self._provider == "anthropic":
|
| 227 |
-
# Anthropic uses different API - no native JSON mode yet
|
| 228 |
-
response = self.client.messages.create(
|
| 229 |
-
model=self._model,
|
| 230 |
-
max_tokens=1024,
|
| 231 |
-
messages=[{"role": "user", "content": prompt}],
|
| 232 |
-
)
|
| 233 |
-
return str(response.content[0].text)
|
| 234 |
-
elif self._provider == "cohere":
|
| 235 |
-
# Cohere uses different API
|
| 236 |
-
response = self.client.chat(
|
| 237 |
-
model=self._model,
|
| 238 |
-
message=prompt,
|
| 239 |
-
)
|
| 240 |
-
return str(response.text)
|
| 241 |
-
elif self._provider == "google":
|
| 242 |
-
# Google Gemini - use JSON response mime type
|
| 243 |
-
model = self.client.GenerativeModel(
|
| 244 |
-
self._model,
|
| 245 |
-
generation_config={"response_mime_type": "application/json"},
|
| 246 |
-
)
|
| 247 |
-
response = model.generate_content(prompt)
|
| 248 |
-
return str(response.text)
|
| 249 |
-
else:
|
| 250 |
-
# OpenAI-compatible API (OpenAI, Groq, Together, Fireworks, Mistral)
|
| 251 |
-
# Use JSON mode for structured output
|
| 252 |
-
kwargs: dict[str, Any] = {
|
| 253 |
-
"model": self._model,
|
| 254 |
-
"messages": [{"role": "user", "content": prompt}],
|
| 255 |
-
"temperature": 0.0, # Deterministic for extraction
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
# Add response_format for providers that support it
|
| 259 |
-
if self._provider in SUPPORTS_JSON_MODE:
|
| 260 |
-
kwargs["response_format"] = {"type": "json_object"}
|
| 261 |
-
|
| 262 |
-
response = self.client.chat.completions.create(**kwargs)
|
| 263 |
-
return str(response.choices[0].message.content)
|
| 264 |
-
|
| 265 |
-
def _parse_response(self, text: str) -> list[Memory]:
|
| 266 |
-
"""Parse LLM response into memories.
|
| 267 |
-
|
| 268 |
-
Args:
|
| 269 |
-
text: Raw LLM response
|
| 270 |
-
|
| 271 |
-
Returns:
|
| 272 |
-
List of Memory objects
|
| 273 |
-
"""
|
| 274 |
-
try:
|
| 275 |
-
# Extract JSON from response (handle markdown code blocks)
|
| 276 |
-
json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
|
| 277 |
-
if json_match:
|
| 278 |
-
text = json_match.group(1)
|
| 279 |
-
|
| 280 |
-
data = json.loads(text.strip())
|
| 281 |
-
|
| 282 |
-
if not data.get("should_remember", False):
|
| 283 |
-
return []
|
| 284 |
-
|
| 285 |
-
memories = []
|
| 286 |
-
for item in data.get("memories", []):
|
| 287 |
-
memories.append(
|
| 288 |
-
Memory(
|
| 289 |
-
content=item["content"],
|
| 290 |
-
category=item.get("category", "fact"),
|
| 291 |
-
importance=item.get("importance", 0.5),
|
| 292 |
-
)
|
| 293 |
-
)
|
| 294 |
-
|
| 295 |
-
return memories
|
| 296 |
-
|
| 297 |
-
except (json.JSONDecodeError, KeyError) as e:
|
| 298 |
-
logger.warning(f"Failed to parse extraction response: {e}")
|
| 299 |
-
return []
|
| 300 |
-
|
| 301 |
-
def _build_batch_prompt(self, conversations: list[tuple[str, str, str]]) -> str:
|
| 302 |
-
"""Build a batch extraction prompt.
|
| 303 |
-
|
| 304 |
-
Args:
|
| 305 |
-
conversations: List of (entity_id, query, response) tuples
|
| 306 |
-
|
| 307 |
-
Returns:
|
| 308 |
-
Batch prompt string
|
| 309 |
-
"""
|
| 310 |
-
lines = [
|
| 311 |
-
"Analyze these conversations and extract facts worth remembering about each entity.",
|
| 312 |
-
"",
|
| 313 |
-
"Focus on: preferences, facts, context that helps future interactions.",
|
| 314 |
-
"",
|
| 315 |
-
]
|
| 316 |
-
|
| 317 |
-
for i, (entity_id, query, response) in enumerate(conversations):
|
| 318 |
-
lines.extend(
|
| 319 |
-
[
|
| 320 |
-
f"--- Conversation {i + 1} (Entity: {entity_id}) ---",
|
| 321 |
-
f"Speaker A: {query}",
|
| 322 |
-
f"Speaker B: {response}",
|
| 323 |
-
"",
|
| 324 |
-
]
|
| 325 |
-
)
|
| 326 |
-
|
| 327 |
-
lines.extend(
|
| 328 |
-
[
|
| 329 |
-
"Return a JSON object mapping entity_id to their memories:",
|
| 330 |
-
"{",
|
| 331 |
-
' "entity_123": {',
|
| 332 |
-
' "memories": [{"content": "...", "category": "preference", "importance": 0.8}],',
|
| 333 |
-
' "should_remember": true',
|
| 334 |
-
" }",
|
| 335 |
-
"}",
|
| 336 |
-
"",
|
| 337 |
-
"Categories: preference, fact, context",
|
| 338 |
-
"Importance: 0.0-1.0",
|
| 339 |
-
"",
|
| 340 |
-
"Return ONLY valid JSON.",
|
| 341 |
-
]
|
| 342 |
-
)
|
| 343 |
-
|
| 344 |
-
return "\n".join(lines)
|
| 345 |
-
|
| 346 |
-
def _parse_batch_response(
|
| 347 |
-
self,
|
| 348 |
-
text: str,
|
| 349 |
-
conversations: list[tuple[str, str, str]],
|
| 350 |
-
) -> dict[str, list[Memory]]:
|
| 351 |
-
"""Parse batch extraction response.
|
| 352 |
-
|
| 353 |
-
Args:
|
| 354 |
-
text: Raw LLM response
|
| 355 |
-
conversations: Original conversations for fallback
|
| 356 |
-
|
| 357 |
-
Returns:
|
| 358 |
-
Dict mapping user_id to list of memories
|
| 359 |
-
"""
|
| 360 |
-
try:
|
| 361 |
-
# Extract JSON from response
|
| 362 |
-
json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL)
|
| 363 |
-
if json_match:
|
| 364 |
-
text = json_match.group(1)
|
| 365 |
-
|
| 366 |
-
data = json.loads(text.strip())
|
| 367 |
-
result: dict[str, list[Memory]] = {}
|
| 368 |
-
|
| 369 |
-
for user_id, user_data in data.items():
|
| 370 |
-
if not user_data.get("should_remember", False):
|
| 371 |
-
continue
|
| 372 |
-
|
| 373 |
-
memories = []
|
| 374 |
-
for item in user_data.get("memories", []):
|
| 375 |
-
memories.append(
|
| 376 |
-
Memory(
|
| 377 |
-
content=item["content"],
|
| 378 |
-
category=item.get("category", "fact"),
|
| 379 |
-
importance=item.get("importance", 0.5),
|
| 380 |
-
)
|
| 381 |
-
)
|
| 382 |
-
|
| 383 |
-
if memories:
|
| 384 |
-
result[user_id] = memories
|
| 385 |
-
|
| 386 |
-
return result
|
| 387 |
-
|
| 388 |
-
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
| 389 |
-
logger.warning(f"Failed to parse batch response: {e}")
|
| 390 |
-
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Factory module for creating memory system components.
|
| 2 |
+
|
| 3 |
+
Provides a unified factory function that creates all memory system components
|
| 4 |
+
from a single configuration object, ensuring consistent initialization
|
| 5 |
+
and proper wiring between components.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import TYPE_CHECKING
|
| 11 |
+
|
| 12 |
+
from headroom.memory.config import (
|
| 13 |
+
EmbedderBackend,
|
| 14 |
+
MemoryConfig,
|
| 15 |
+
StoreBackend,
|
| 16 |
+
TextBackend,
|
| 17 |
+
VectorBackend,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING:
|
| 21 |
+
from headroom.memory.ports import Embedder, MemoryCache, MemoryStore, TextIndex, VectorIndex
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
async def create_memory_system(
|
| 25 |
+
config: MemoryConfig | None = None,
|
| 26 |
+
) -> tuple[MemoryStore, VectorIndex, TextIndex, Embedder, MemoryCache | None]:
|
| 27 |
+
"""Create a complete memory system from configuration.
|
| 28 |
+
|
| 29 |
+
This factory function creates and initializes all memory system components
|
| 30 |
+
based on the provided configuration. Components are created in dependency
|
| 31 |
+
order to ensure proper initialization.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
config: Memory system configuration. If None, uses default configuration.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
A tuple of (store, vector_index, text_index, embedder, cache) where:
|
| 38 |
+
- store: The memory persistence backend
|
| 39 |
+
- vector_index: The vector similarity search index
|
| 40 |
+
- text_index: The full-text search index
|
| 41 |
+
- embedder: The text embedding generator
|
| 42 |
+
- cache: The memory cache (or None if caching is disabled)
|
| 43 |
+
|
| 44 |
+
Raises:
|
| 45 |
+
ValueError: If an unknown backend type is specified in the config.
|
| 46 |
+
|
| 47 |
+
Example:
|
| 48 |
+
config = MemoryConfig(
|
| 49 |
+
embedder_backend=EmbedderBackend.LOCAL,
|
| 50 |
+
cache_max_size=2000,
|
| 51 |
+
)
|
| 52 |
+
store, vector, text, embedder, cache = await create_memory_system(config)
|
| 53 |
+
"""
|
| 54 |
+
config = config or MemoryConfig()
|
| 55 |
+
|
| 56 |
+
# Create store
|
| 57 |
+
store = _create_store(config)
|
| 58 |
+
|
| 59 |
+
# Create embedder (needed by vector index for text queries)
|
| 60 |
+
embedder = _create_embedder(config)
|
| 61 |
+
|
| 62 |
+
# Create vector index
|
| 63 |
+
vector_index = _create_vector_index(config)
|
| 64 |
+
|
| 65 |
+
# Create text index
|
| 66 |
+
text_index = _create_text_index(config)
|
| 67 |
+
|
| 68 |
+
# Create cache (optional)
|
| 69 |
+
cache = _create_cache(config) if config.cache_enabled else None
|
| 70 |
+
|
| 71 |
+
return store, vector_index, text_index, embedder, cache
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _create_store(config: MemoryConfig) -> MemoryStore:
|
| 75 |
+
"""Create a memory store backend.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
config: Memory system configuration.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
A MemoryStore implementation based on config.store_backend.
|
| 82 |
+
|
| 83 |
+
Raises:
|
| 84 |
+
ValueError: If the store backend is not supported.
|
| 85 |
+
"""
|
| 86 |
+
if config.store_backend == StoreBackend.SQLITE:
|
| 87 |
+
from headroom.memory.adapters.sqlite import SQLiteMemoryStore
|
| 88 |
+
|
| 89 |
+
return SQLiteMemoryStore(config.db_path)
|
| 90 |
+
|
| 91 |
+
raise ValueError(f"Unknown store backend: {config.store_backend}")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _create_embedder(config: MemoryConfig) -> Embedder:
|
| 95 |
+
"""Create an embedder backend.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
config: Memory system configuration.
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
An Embedder implementation based on config.embedder_backend.
|
| 102 |
+
|
| 103 |
+
Raises:
|
| 104 |
+
ValueError: If the embedder backend is not supported.
|
| 105 |
+
"""
|
| 106 |
+
if config.embedder_backend == EmbedderBackend.LOCAL:
|
| 107 |
+
from headroom.memory.adapters.embedders import LocalEmbedder
|
| 108 |
+
|
| 109 |
+
return LocalEmbedder(model_name=config.embedder_model)
|
| 110 |
+
|
| 111 |
+
if config.embedder_backend == EmbedderBackend.OPENAI:
|
| 112 |
+
from headroom.memory.adapters.embedders import OpenAIEmbedder
|
| 113 |
+
|
| 114 |
+
if not config.openai_api_key:
|
| 115 |
+
raise ValueError("openai_api_key is required for OpenAI embedder")
|
| 116 |
+
return OpenAIEmbedder(
|
| 117 |
+
api_key=config.openai_api_key,
|
| 118 |
+
model_name=config.embedder_model,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
if config.embedder_backend == EmbedderBackend.OLLAMA:
|
| 122 |
+
from headroom.memory.adapters.embedders import OllamaEmbedder
|
| 123 |
+
|
| 124 |
+
return OllamaEmbedder(
|
| 125 |
+
base_url=config.ollama_base_url,
|
| 126 |
+
model_name=config.embedder_model,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
raise ValueError(f"Unknown embedder backend: {config.embedder_backend}")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _create_vector_index(config: MemoryConfig) -> VectorIndex:
|
| 133 |
+
"""Create a vector index backend.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
config: Memory system configuration.
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
A VectorIndex implementation based on config.vector_backend.
|
| 140 |
+
|
| 141 |
+
Raises:
|
| 142 |
+
ValueError: If the vector backend is not supported.
|
| 143 |
+
"""
|
| 144 |
+
if config.vector_backend == VectorBackend.HNSW:
|
| 145 |
+
from headroom.memory.adapters.hnsw import HNSWVectorIndex
|
| 146 |
+
|
| 147 |
+
return HNSWVectorIndex(
|
| 148 |
+
dimension=config.vector_dimension,
|
| 149 |
+
ef_construction=config.hnsw_ef_construction,
|
| 150 |
+
m=config.hnsw_m,
|
| 151 |
+
ef_search=config.hnsw_ef_search,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
raise ValueError(f"Unknown vector backend: {config.vector_backend}")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _create_text_index(config: MemoryConfig) -> TextIndex:
|
| 158 |
+
"""Create a text index backend.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
config: Memory system configuration.
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
A TextIndex implementation based on config.text_backend.
|
| 165 |
+
|
| 166 |
+
Raises:
|
| 167 |
+
ValueError: If the text backend is not supported.
|
| 168 |
+
"""
|
| 169 |
+
if config.text_backend == TextBackend.FTS5:
|
| 170 |
+
from headroom.memory.adapters.fts5 import FTS5TextIndex
|
| 171 |
+
|
| 172 |
+
# FTS5TextIndex has a compatible interface but different method signatures
|
| 173 |
+
return FTS5TextIndex(db_path=config.db_path) # type: ignore[return-value]
|
| 174 |
+
|
| 175 |
+
raise ValueError(f"Unknown text backend: {config.text_backend}")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _create_cache(config: MemoryConfig) -> MemoryCache:
|
| 179 |
+
"""Create a memory cache.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
config: Memory system configuration.
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
A MemoryCache implementation.
|
| 186 |
+
"""
|
| 187 |
+
from headroom.memory.adapters.cache import LRUMemoryCache
|
| 188 |
+
|
| 189 |
+
# LRUMemoryCache implements MemoryCache protocol
|
| 190 |
+
return LRUMemoryCache(max_size=config.cache_max_size) # type: ignore[return-value]
|
|
@@ -1,621 +0,0 @@
|
|
| 1 |
-
"""Fast embedding-based memory store.
|
| 2 |
-
|
| 3 |
-
Sub-100ms write and read latency by:
|
| 4 |
-
1. NO LLM extraction - just embed and store
|
| 5 |
-
2. Vector similarity search - not keyword matching
|
| 6 |
-
3. Optional local embeddings for sub-10ms latency
|
| 7 |
-
|
| 8 |
-
This replaces the slow LLM-based extraction approach.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import json
|
| 14 |
-
import logging
|
| 15 |
-
import sqlite3
|
| 16 |
-
import time
|
| 17 |
-
from collections.abc import Callable
|
| 18 |
-
from dataclasses import dataclass, field
|
| 19 |
-
from datetime import datetime
|
| 20 |
-
from pathlib import Path
|
| 21 |
-
from typing import Any
|
| 22 |
-
from uuid import uuid4
|
| 23 |
-
|
| 24 |
-
import numpy as np
|
| 25 |
-
|
| 26 |
-
logger = logging.getLogger(__name__)
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
@dataclass
|
| 30 |
-
class MemoryChunk:
|
| 31 |
-
"""A memory chunk with text and embedding."""
|
| 32 |
-
|
| 33 |
-
id: str = field(default_factory=lambda: str(uuid4()))
|
| 34 |
-
text: str = ""
|
| 35 |
-
role: str = "user" # "user" or "assistant"
|
| 36 |
-
embedding: np.ndarray | None = None
|
| 37 |
-
timestamp: datetime = field(default_factory=datetime.utcnow)
|
| 38 |
-
metadata: dict[str, Any] = field(default_factory=dict)
|
| 39 |
-
|
| 40 |
-
def to_dict(self) -> dict:
|
| 41 |
-
"""Convert to dictionary for storage."""
|
| 42 |
-
return {
|
| 43 |
-
"id": self.id,
|
| 44 |
-
"text": self.text,
|
| 45 |
-
"role": self.role,
|
| 46 |
-
"embedding": self.embedding.tolist() if self.embedding is not None else None,
|
| 47 |
-
"timestamp": self.timestamp.isoformat(),
|
| 48 |
-
"metadata": self.metadata,
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
@classmethod
|
| 52 |
-
def from_dict(cls, data: dict) -> MemoryChunk:
|
| 53 |
-
"""Create from dictionary."""
|
| 54 |
-
embedding = None
|
| 55 |
-
if data.get("embedding"):
|
| 56 |
-
embedding = np.array(data["embedding"], dtype=np.float32)
|
| 57 |
-
return cls(
|
| 58 |
-
id=data["id"],
|
| 59 |
-
text=data["text"],
|
| 60 |
-
role=data.get("role", "user"),
|
| 61 |
-
embedding=embedding,
|
| 62 |
-
timestamp=datetime.fromisoformat(data["timestamp"]),
|
| 63 |
-
metadata=data.get("metadata", {}),
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# Type aliases for embedding functions
|
| 68 |
-
EmbedFn = Callable[[str], np.ndarray]
|
| 69 |
-
BatchEmbedFn = Callable[[list[str]], list[np.ndarray]]
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
| 73 |
-
"""Compute cosine similarity between two vectors."""
|
| 74 |
-
norm_a = np.linalg.norm(a)
|
| 75 |
-
norm_b = np.linalg.norm(b)
|
| 76 |
-
if norm_a == 0 or norm_b == 0:
|
| 77 |
-
return 0.0
|
| 78 |
-
return float(np.dot(a, b) / (norm_a * norm_b))
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def cosine_similarity_batch(query: np.ndarray, vectors: np.ndarray) -> np.ndarray:
|
| 82 |
-
"""Compute cosine similarity between query and multiple vectors."""
|
| 83 |
-
# Normalize query
|
| 84 |
-
query_norm = query / (np.linalg.norm(query) + 1e-9)
|
| 85 |
-
# Normalize vectors
|
| 86 |
-
norms = np.linalg.norm(vectors, axis=1, keepdims=True) + 1e-9
|
| 87 |
-
vectors_norm = vectors / norms
|
| 88 |
-
# Dot product - cast to ndarray to satisfy mypy
|
| 89 |
-
result: np.ndarray = np.dot(vectors_norm, query_norm)
|
| 90 |
-
return result
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
class FastMemoryStore:
|
| 94 |
-
"""Fast embedding-based memory store.
|
| 95 |
-
|
| 96 |
-
Features:
|
| 97 |
-
- Sub-100ms write latency (no LLM, just embedding)
|
| 98 |
-
- Sub-50ms read latency (vector similarity search)
|
| 99 |
-
- Pluggable embedding functions (local or API)
|
| 100 |
-
- SQLite storage with in-memory vector cache
|
| 101 |
-
|
| 102 |
-
Usage:
|
| 103 |
-
store = FastMemoryStore(db_path, embed_fn=my_embed_fn)
|
| 104 |
-
store.add("user_123", "I prefer Python", role="user")
|
| 105 |
-
results = store.search("user_123", "programming language", top_k=5)
|
| 106 |
-
"""
|
| 107 |
-
|
| 108 |
-
def __init__(
|
| 109 |
-
self,
|
| 110 |
-
db_path: str | Path,
|
| 111 |
-
embed_fn: EmbedFn | None = None,
|
| 112 |
-
embedding_dim: int = 1536, # OpenAI default
|
| 113 |
-
):
|
| 114 |
-
"""Initialize the store.
|
| 115 |
-
|
| 116 |
-
Args:
|
| 117 |
-
db_path: Path to SQLite database
|
| 118 |
-
embed_fn: Function to embed text (if None, must call set_embed_fn later)
|
| 119 |
-
embedding_dim: Dimension of embeddings
|
| 120 |
-
"""
|
| 121 |
-
self.db_path = Path(db_path)
|
| 122 |
-
self.embed_fn = embed_fn
|
| 123 |
-
self.embedding_dim = embedding_dim
|
| 124 |
-
|
| 125 |
-
# In-memory vector cache for fast similarity search
|
| 126 |
-
self._vector_cache: dict[
|
| 127 |
-
str, dict[str, np.ndarray]
|
| 128 |
-
] = {} # user_id -> {chunk_id -> embedding}
|
| 129 |
-
self._chunk_cache: dict[str, dict[str, MemoryChunk]] = {} # user_id -> {chunk_id -> chunk}
|
| 130 |
-
|
| 131 |
-
self._init_db()
|
| 132 |
-
self._load_cache()
|
| 133 |
-
|
| 134 |
-
def _init_db(self) -> None:
|
| 135 |
-
"""Initialize SQLite database."""
|
| 136 |
-
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 137 |
-
|
| 138 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 139 |
-
conn.execute("""
|
| 140 |
-
CREATE TABLE IF NOT EXISTS memory_chunks (
|
| 141 |
-
id TEXT PRIMARY KEY,
|
| 142 |
-
user_id TEXT NOT NULL,
|
| 143 |
-
text TEXT NOT NULL,
|
| 144 |
-
role TEXT DEFAULT 'user',
|
| 145 |
-
embedding BLOB,
|
| 146 |
-
timestamp TEXT NOT NULL,
|
| 147 |
-
metadata TEXT DEFAULT '{}',
|
| 148 |
-
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 149 |
-
)
|
| 150 |
-
""")
|
| 151 |
-
conn.execute("""
|
| 152 |
-
CREATE INDEX IF NOT EXISTS idx_chunks_user_id
|
| 153 |
-
ON memory_chunks(user_id)
|
| 154 |
-
""")
|
| 155 |
-
conn.execute("""
|
| 156 |
-
CREATE INDEX IF NOT EXISTS idx_chunks_timestamp
|
| 157 |
-
ON memory_chunks(user_id, timestamp DESC)
|
| 158 |
-
""")
|
| 159 |
-
conn.commit()
|
| 160 |
-
|
| 161 |
-
def _load_cache(self) -> None:
|
| 162 |
-
"""Load all embeddings into memory for fast search."""
|
| 163 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 164 |
-
cursor = conn.execute("""
|
| 165 |
-
SELECT id, user_id, text, role, embedding, timestamp, metadata
|
| 166 |
-
FROM memory_chunks
|
| 167 |
-
WHERE embedding IS NOT NULL
|
| 168 |
-
""")
|
| 169 |
-
|
| 170 |
-
for row in cursor:
|
| 171 |
-
chunk_id, user_id, text, role, embedding_blob, timestamp, metadata = row
|
| 172 |
-
|
| 173 |
-
if user_id not in self._vector_cache:
|
| 174 |
-
self._vector_cache[user_id] = {}
|
| 175 |
-
self._chunk_cache[user_id] = {}
|
| 176 |
-
|
| 177 |
-
# Deserialize embedding
|
| 178 |
-
embedding = np.frombuffer(embedding_blob, dtype=np.float32)
|
| 179 |
-
|
| 180 |
-
self._vector_cache[user_id][chunk_id] = embedding
|
| 181 |
-
|
| 182 |
-
chunk = MemoryChunk(
|
| 183 |
-
id=chunk_id,
|
| 184 |
-
text=text,
|
| 185 |
-
role=role,
|
| 186 |
-
embedding=embedding,
|
| 187 |
-
timestamp=datetime.fromisoformat(timestamp),
|
| 188 |
-
metadata=json.loads(metadata) if metadata else {},
|
| 189 |
-
)
|
| 190 |
-
self._chunk_cache[user_id][chunk_id] = chunk
|
| 191 |
-
|
| 192 |
-
logger.debug(f"Loaded {sum(len(v) for v in self._vector_cache.values())} chunks into cache")
|
| 193 |
-
|
| 194 |
-
def set_embed_fn(self, embed_fn: EmbedFn) -> None:
|
| 195 |
-
"""Set the embedding function."""
|
| 196 |
-
self.embed_fn = embed_fn
|
| 197 |
-
|
| 198 |
-
def add(
|
| 199 |
-
self,
|
| 200 |
-
user_id: str,
|
| 201 |
-
text: str,
|
| 202 |
-
role: str = "user",
|
| 203 |
-
metadata: dict[str, Any] | None = None,
|
| 204 |
-
) -> MemoryChunk:
|
| 205 |
-
"""Add a memory chunk.
|
| 206 |
-
|
| 207 |
-
This is the FAST path - just embed and store, no LLM extraction.
|
| 208 |
-
Typical latency: <50ms with API embeddings, <10ms with local.
|
| 209 |
-
|
| 210 |
-
Args:
|
| 211 |
-
user_id: User/entity identifier
|
| 212 |
-
text: Text to store
|
| 213 |
-
role: "user" or "assistant"
|
| 214 |
-
metadata: Optional metadata
|
| 215 |
-
|
| 216 |
-
Returns:
|
| 217 |
-
The created MemoryChunk
|
| 218 |
-
"""
|
| 219 |
-
if not self.embed_fn:
|
| 220 |
-
raise ValueError("No embedding function set. Call set_embed_fn() first.")
|
| 221 |
-
|
| 222 |
-
start_time = time.perf_counter()
|
| 223 |
-
|
| 224 |
-
# Embed the text
|
| 225 |
-
embedding = self.embed_fn(text)
|
| 226 |
-
embed_time = time.perf_counter() - start_time
|
| 227 |
-
|
| 228 |
-
# Create chunk
|
| 229 |
-
chunk = MemoryChunk(
|
| 230 |
-
text=text,
|
| 231 |
-
role=role,
|
| 232 |
-
embedding=embedding,
|
| 233 |
-
metadata=metadata or {},
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
-
# Store in SQLite
|
| 237 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 238 |
-
conn.execute(
|
| 239 |
-
"""
|
| 240 |
-
INSERT INTO memory_chunks (id, user_id, text, role, embedding, timestamp, metadata)
|
| 241 |
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 242 |
-
""",
|
| 243 |
-
(
|
| 244 |
-
chunk.id,
|
| 245 |
-
user_id,
|
| 246 |
-
chunk.text,
|
| 247 |
-
chunk.role,
|
| 248 |
-
embedding.astype(np.float32).tobytes(),
|
| 249 |
-
chunk.timestamp.isoformat(),
|
| 250 |
-
json.dumps(chunk.metadata),
|
| 251 |
-
),
|
| 252 |
-
)
|
| 253 |
-
conn.commit()
|
| 254 |
-
|
| 255 |
-
# Update cache
|
| 256 |
-
if user_id not in self._vector_cache:
|
| 257 |
-
self._vector_cache[user_id] = {}
|
| 258 |
-
self._chunk_cache[user_id] = {}
|
| 259 |
-
|
| 260 |
-
self._vector_cache[user_id][chunk.id] = embedding
|
| 261 |
-
self._chunk_cache[user_id][chunk.id] = chunk
|
| 262 |
-
|
| 263 |
-
total_time = time.perf_counter() - start_time
|
| 264 |
-
logger.debug(f"Added chunk in {total_time * 1000:.1f}ms (embed: {embed_time * 1000:.1f}ms)")
|
| 265 |
-
|
| 266 |
-
return chunk
|
| 267 |
-
|
| 268 |
-
def add_turn(
|
| 269 |
-
self,
|
| 270 |
-
user_id: str,
|
| 271 |
-
user_message: str,
|
| 272 |
-
assistant_response: str,
|
| 273 |
-
metadata: dict[str, Any] | None = None,
|
| 274 |
-
) -> tuple[MemoryChunk, MemoryChunk]:
|
| 275 |
-
"""Add a conversation turn (user message + assistant response).
|
| 276 |
-
|
| 277 |
-
Convenience method that stores both parts of a turn.
|
| 278 |
-
|
| 279 |
-
Args:
|
| 280 |
-
user_id: User/entity identifier
|
| 281 |
-
user_message: The user's message
|
| 282 |
-
assistant_response: The assistant's response
|
| 283 |
-
metadata: Optional metadata for both chunks
|
| 284 |
-
|
| 285 |
-
Returns:
|
| 286 |
-
Tuple of (user_chunk, assistant_chunk)
|
| 287 |
-
"""
|
| 288 |
-
user_chunk = self.add(user_id, user_message, role="user", metadata=metadata)
|
| 289 |
-
assistant_chunk = self.add(user_id, assistant_response, role="assistant", metadata=metadata)
|
| 290 |
-
return user_chunk, assistant_chunk
|
| 291 |
-
|
| 292 |
-
def add_turn_batched(
|
| 293 |
-
self,
|
| 294 |
-
user_id: str,
|
| 295 |
-
user_message: str,
|
| 296 |
-
assistant_response: str,
|
| 297 |
-
batch_embed_fn: BatchEmbedFn,
|
| 298 |
-
metadata: dict[str, Any] | None = None,
|
| 299 |
-
) -> tuple[MemoryChunk, MemoryChunk]:
|
| 300 |
-
"""Add a conversation turn using BATCHED embedding (single API call).
|
| 301 |
-
|
| 302 |
-
This is the FASTEST path - embeds both messages in ONE API call.
|
| 303 |
-
Typical latency: 50-100ms total vs 200-400ms with individual calls.
|
| 304 |
-
|
| 305 |
-
Args:
|
| 306 |
-
user_id: User/entity identifier
|
| 307 |
-
user_message: The user's message
|
| 308 |
-
assistant_response: The assistant's response
|
| 309 |
-
batch_embed_fn: Batch embedding function
|
| 310 |
-
metadata: Optional metadata for both chunks
|
| 311 |
-
|
| 312 |
-
Returns:
|
| 313 |
-
Tuple of (user_chunk, assistant_chunk)
|
| 314 |
-
"""
|
| 315 |
-
start_time = time.perf_counter()
|
| 316 |
-
|
| 317 |
-
# Embed BOTH messages in ONE API call
|
| 318 |
-
embeddings = batch_embed_fn([user_message, assistant_response])
|
| 319 |
-
embed_time = time.perf_counter() - start_time
|
| 320 |
-
|
| 321 |
-
# Create chunks
|
| 322 |
-
user_chunk = MemoryChunk(
|
| 323 |
-
text=user_message,
|
| 324 |
-
role="user",
|
| 325 |
-
embedding=embeddings[0],
|
| 326 |
-
metadata=metadata or {},
|
| 327 |
-
)
|
| 328 |
-
assistant_chunk = MemoryChunk(
|
| 329 |
-
text=assistant_response,
|
| 330 |
-
role="assistant",
|
| 331 |
-
embedding=embeddings[1],
|
| 332 |
-
metadata=metadata or {},
|
| 333 |
-
)
|
| 334 |
-
|
| 335 |
-
# Store in SQLite (batch insert)
|
| 336 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 337 |
-
conn.executemany(
|
| 338 |
-
"""
|
| 339 |
-
INSERT INTO memory_chunks (id, user_id, text, role, embedding, timestamp, metadata)
|
| 340 |
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 341 |
-
""",
|
| 342 |
-
[
|
| 343 |
-
(
|
| 344 |
-
user_chunk.id,
|
| 345 |
-
user_id,
|
| 346 |
-
user_chunk.text,
|
| 347 |
-
user_chunk.role,
|
| 348 |
-
embeddings[0].astype(np.float32).tobytes(),
|
| 349 |
-
user_chunk.timestamp.isoformat(),
|
| 350 |
-
json.dumps(user_chunk.metadata),
|
| 351 |
-
),
|
| 352 |
-
(
|
| 353 |
-
assistant_chunk.id,
|
| 354 |
-
user_id,
|
| 355 |
-
assistant_chunk.text,
|
| 356 |
-
assistant_chunk.role,
|
| 357 |
-
embeddings[1].astype(np.float32).tobytes(),
|
| 358 |
-
assistant_chunk.timestamp.isoformat(),
|
| 359 |
-
json.dumps(assistant_chunk.metadata),
|
| 360 |
-
),
|
| 361 |
-
],
|
| 362 |
-
)
|
| 363 |
-
conn.commit()
|
| 364 |
-
|
| 365 |
-
# Update cache
|
| 366 |
-
if user_id not in self._vector_cache:
|
| 367 |
-
self._vector_cache[user_id] = {}
|
| 368 |
-
self._chunk_cache[user_id] = {}
|
| 369 |
-
|
| 370 |
-
self._vector_cache[user_id][user_chunk.id] = embeddings[0]
|
| 371 |
-
self._vector_cache[user_id][assistant_chunk.id] = embeddings[1]
|
| 372 |
-
self._chunk_cache[user_id][user_chunk.id] = user_chunk
|
| 373 |
-
self._chunk_cache[user_id][assistant_chunk.id] = assistant_chunk
|
| 374 |
-
|
| 375 |
-
total_time = time.perf_counter() - start_time
|
| 376 |
-
logger.debug(
|
| 377 |
-
f"Added turn (batched) in {total_time * 1000:.1f}ms (embed: {embed_time * 1000:.1f}ms)"
|
| 378 |
-
)
|
| 379 |
-
|
| 380 |
-
return user_chunk, assistant_chunk
|
| 381 |
-
|
| 382 |
-
def search(
|
| 383 |
-
self,
|
| 384 |
-
user_id: str,
|
| 385 |
-
query: str,
|
| 386 |
-
top_k: int = 5,
|
| 387 |
-
min_similarity: float = 0.0,
|
| 388 |
-
role_filter: str | None = None,
|
| 389 |
-
) -> list[tuple[MemoryChunk, float]]:
|
| 390 |
-
"""Search for relevant memory chunks.
|
| 391 |
-
|
| 392 |
-
Uses vector similarity search for semantic matching.
|
| 393 |
-
Typical latency: <50ms with API embeddings, <10ms with local.
|
| 394 |
-
|
| 395 |
-
Args:
|
| 396 |
-
user_id: User/entity identifier
|
| 397 |
-
query: Search query
|
| 398 |
-
top_k: Number of results to return
|
| 399 |
-
min_similarity: Minimum cosine similarity threshold
|
| 400 |
-
role_filter: Optional filter by role ("user" or "assistant")
|
| 401 |
-
|
| 402 |
-
Returns:
|
| 403 |
-
List of (chunk, similarity_score) tuples, sorted by relevance
|
| 404 |
-
"""
|
| 405 |
-
if not self.embed_fn:
|
| 406 |
-
raise ValueError("No embedding function set. Call set_embed_fn() first.")
|
| 407 |
-
|
| 408 |
-
start_time = time.perf_counter()
|
| 409 |
-
|
| 410 |
-
# Check if user has any memories
|
| 411 |
-
if user_id not in self._vector_cache or not self._vector_cache[user_id]:
|
| 412 |
-
return []
|
| 413 |
-
|
| 414 |
-
# Embed query
|
| 415 |
-
query_embedding = self.embed_fn(query)
|
| 416 |
-
embed_time = time.perf_counter() - start_time
|
| 417 |
-
|
| 418 |
-
# Get user's vectors
|
| 419 |
-
chunk_ids = list(self._vector_cache[user_id].keys())
|
| 420 |
-
vectors = np.array([self._vector_cache[user_id][cid] for cid in chunk_ids])
|
| 421 |
-
|
| 422 |
-
# Compute similarities
|
| 423 |
-
similarities = cosine_similarity_batch(query_embedding, vectors)
|
| 424 |
-
search_time = time.perf_counter() - start_time - embed_time
|
| 425 |
-
|
| 426 |
-
# Sort by similarity
|
| 427 |
-
sorted_indices = np.argsort(similarities)[::-1]
|
| 428 |
-
|
| 429 |
-
# Collect results
|
| 430 |
-
results = []
|
| 431 |
-
for idx in sorted_indices:
|
| 432 |
-
chunk_id = chunk_ids[idx]
|
| 433 |
-
similarity = float(similarities[idx])
|
| 434 |
-
|
| 435 |
-
if similarity < min_similarity:
|
| 436 |
-
break
|
| 437 |
-
|
| 438 |
-
chunk = self._chunk_cache[user_id][chunk_id]
|
| 439 |
-
|
| 440 |
-
# Apply role filter
|
| 441 |
-
if role_filter and chunk.role != role_filter:
|
| 442 |
-
continue
|
| 443 |
-
|
| 444 |
-
results.append((chunk, similarity))
|
| 445 |
-
|
| 446 |
-
if len(results) >= top_k:
|
| 447 |
-
break
|
| 448 |
-
|
| 449 |
-
total_time = time.perf_counter() - start_time
|
| 450 |
-
logger.debug(
|
| 451 |
-
f"Search completed in {total_time * 1000:.1f}ms "
|
| 452 |
-
f"(embed: {embed_time * 1000:.1f}ms, search: {search_time * 1000:.1f}ms)"
|
| 453 |
-
)
|
| 454 |
-
|
| 455 |
-
return results
|
| 456 |
-
|
| 457 |
-
def get_recent(
|
| 458 |
-
self,
|
| 459 |
-
user_id: str,
|
| 460 |
-
limit: int = 10,
|
| 461 |
-
role_filter: str | None = None,
|
| 462 |
-
) -> list[MemoryChunk]:
|
| 463 |
-
"""Get recent memory chunks.
|
| 464 |
-
|
| 465 |
-
Args:
|
| 466 |
-
user_id: User/entity identifier
|
| 467 |
-
limit: Maximum number of chunks to return
|
| 468 |
-
role_filter: Optional filter by role
|
| 469 |
-
|
| 470 |
-
Returns:
|
| 471 |
-
List of chunks, sorted by timestamp (newest first)
|
| 472 |
-
"""
|
| 473 |
-
if user_id not in self._chunk_cache:
|
| 474 |
-
return []
|
| 475 |
-
|
| 476 |
-
chunks = list(self._chunk_cache[user_id].values())
|
| 477 |
-
|
| 478 |
-
# Apply role filter
|
| 479 |
-
if role_filter:
|
| 480 |
-
chunks = [c for c in chunks if c.role == role_filter]
|
| 481 |
-
|
| 482 |
-
# Sort by timestamp
|
| 483 |
-
chunks.sort(key=lambda c: c.timestamp, reverse=True)
|
| 484 |
-
|
| 485 |
-
return chunks[:limit]
|
| 486 |
-
|
| 487 |
-
def get_all(self, user_id: str) -> list[MemoryChunk]:
|
| 488 |
-
"""Get all memory chunks for a user."""
|
| 489 |
-
if user_id not in self._chunk_cache:
|
| 490 |
-
return []
|
| 491 |
-
return list(self._chunk_cache[user_id].values())
|
| 492 |
-
|
| 493 |
-
def delete(self, user_id: str, chunk_id: str) -> bool:
|
| 494 |
-
"""Delete a specific chunk."""
|
| 495 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 496 |
-
cursor = conn.execute(
|
| 497 |
-
"DELETE FROM memory_chunks WHERE id = ? AND user_id = ?",
|
| 498 |
-
(chunk_id, user_id),
|
| 499 |
-
)
|
| 500 |
-
conn.commit()
|
| 501 |
-
deleted = cursor.rowcount > 0
|
| 502 |
-
|
| 503 |
-
if deleted and user_id in self._vector_cache:
|
| 504 |
-
self._vector_cache[user_id].pop(chunk_id, None)
|
| 505 |
-
self._chunk_cache[user_id].pop(chunk_id, None)
|
| 506 |
-
|
| 507 |
-
return deleted
|
| 508 |
-
|
| 509 |
-
def clear(self, user_id: str) -> int:
|
| 510 |
-
"""Clear all memories for a user."""
|
| 511 |
-
with sqlite3.connect(str(self.db_path)) as conn:
|
| 512 |
-
cursor = conn.execute(
|
| 513 |
-
"DELETE FROM memory_chunks WHERE user_id = ?",
|
| 514 |
-
(user_id,),
|
| 515 |
-
)
|
| 516 |
-
conn.commit()
|
| 517 |
-
count = cursor.rowcount
|
| 518 |
-
|
| 519 |
-
self._vector_cache.pop(user_id, None)
|
| 520 |
-
self._chunk_cache.pop(user_id, None)
|
| 521 |
-
|
| 522 |
-
return count
|
| 523 |
-
|
| 524 |
-
def stats(self, user_id: str) -> dict[str, Any]:
|
| 525 |
-
"""Get statistics for a user."""
|
| 526 |
-
chunks = self.get_all(user_id)
|
| 527 |
-
return {
|
| 528 |
-
"total": len(chunks),
|
| 529 |
-
"user_messages": sum(1 for c in chunks if c.role == "user"),
|
| 530 |
-
"assistant_messages": sum(1 for c in chunks if c.role == "assistant"),
|
| 531 |
-
}
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
# =============================================================================
|
| 535 |
-
# Embedding Functions
|
| 536 |
-
# =============================================================================
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
def create_openai_embed_fn(
|
| 540 |
-
client: Any,
|
| 541 |
-
model: str = "text-embedding-3-small",
|
| 542 |
-
) -> EmbedFn:
|
| 543 |
-
"""Create an embedding function using OpenAI API.
|
| 544 |
-
|
| 545 |
-
Typical latency: 30-100ms per call.
|
| 546 |
-
|
| 547 |
-
Args:
|
| 548 |
-
client: OpenAI client
|
| 549 |
-
model: Embedding model to use
|
| 550 |
-
|
| 551 |
-
Returns:
|
| 552 |
-
Embedding function
|
| 553 |
-
"""
|
| 554 |
-
|
| 555 |
-
def embed(text: str) -> np.ndarray:
|
| 556 |
-
response = client.embeddings.create(
|
| 557 |
-
model=model,
|
| 558 |
-
input=text,
|
| 559 |
-
)
|
| 560 |
-
return np.array(response.data[0].embedding, dtype=np.float32)
|
| 561 |
-
|
| 562 |
-
return embed
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
def create_openai_batch_embed_fn(
|
| 566 |
-
client: Any,
|
| 567 |
-
model: str = "text-embedding-3-small",
|
| 568 |
-
) -> BatchEmbedFn:
|
| 569 |
-
"""Create a BATCH embedding function using OpenAI API.
|
| 570 |
-
|
| 571 |
-
Much faster than individual calls - single API round trip for multiple texts.
|
| 572 |
-
Typical latency: 50-200ms for 10 texts vs 500-2000ms for 10 individual calls.
|
| 573 |
-
|
| 574 |
-
Args:
|
| 575 |
-
client: OpenAI client
|
| 576 |
-
model: Embedding model to use
|
| 577 |
-
|
| 578 |
-
Returns:
|
| 579 |
-
Batch embedding function
|
| 580 |
-
"""
|
| 581 |
-
|
| 582 |
-
def embed_batch(texts: list[str]) -> list[np.ndarray]:
|
| 583 |
-
if not texts:
|
| 584 |
-
return []
|
| 585 |
-
response = client.embeddings.create(
|
| 586 |
-
model=model,
|
| 587 |
-
input=texts,
|
| 588 |
-
)
|
| 589 |
-
# Sort by index to maintain order
|
| 590 |
-
sorted_data = sorted(response.data, key=lambda x: x.index)
|
| 591 |
-
return [np.array(d.embedding, dtype=np.float32) for d in sorted_data]
|
| 592 |
-
|
| 593 |
-
return embed_batch
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
def create_local_embed_fn(
|
| 597 |
-
model_name: str = "all-MiniLM-L6-v2",
|
| 598 |
-
) -> EmbedFn:
|
| 599 |
-
"""Create an embedding function using local sentence-transformers.
|
| 600 |
-
|
| 601 |
-
Typical latency: 5-20ms per call (after model load).
|
| 602 |
-
|
| 603 |
-
Args:
|
| 604 |
-
model_name: Sentence-transformers model name
|
| 605 |
-
|
| 606 |
-
Returns:
|
| 607 |
-
Embedding function
|
| 608 |
-
"""
|
| 609 |
-
try:
|
| 610 |
-
from sentence_transformers import SentenceTransformer
|
| 611 |
-
except ImportError:
|
| 612 |
-
raise ImportError(
|
| 613 |
-
"sentence-transformers not installed. Install with: pip install sentence-transformers"
|
| 614 |
-
) from None
|
| 615 |
-
|
| 616 |
-
model = SentenceTransformer(model_name)
|
| 617 |
-
|
| 618 |
-
def embed(text: str) -> np.ndarray:
|
| 619 |
-
return model.encode(text, convert_to_numpy=True).astype(np.float32)
|
| 620 |
-
|
| 621 |
-
return embed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,311 +0,0 @@
|
|
| 1 |
-
"""Fast Memory Wrapper - Zero-latency inline extraction + semantic retrieval.
|
| 2 |
-
|
| 3 |
-
This is the ultimate memory solution:
|
| 4 |
-
1. ZERO extra latency - memories extracted as part of LLM response (Letta-style)
|
| 5 |
-
2. Semantic retrieval - vector similarity for intelligent memory lookup
|
| 6 |
-
3. Local embeddings - sub-50ms retrieval, no API calls needed
|
| 7 |
-
|
| 8 |
-
Usage:
|
| 9 |
-
from headroom.memory import with_fast_memory
|
| 10 |
-
|
| 11 |
-
client = with_fast_memory(OpenAI(), user_id="alice")
|
| 12 |
-
response = client.chat.completions.create(
|
| 13 |
-
model="gpt-4o",
|
| 14 |
-
messages=[{"role": "user", "content": "I prefer Python"}]
|
| 15 |
-
)
|
| 16 |
-
# Memory extracted INLINE - zero extra latency!
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
from __future__ import annotations
|
| 20 |
-
|
| 21 |
-
import copy
|
| 22 |
-
from pathlib import Path
|
| 23 |
-
from typing import Any
|
| 24 |
-
|
| 25 |
-
from headroom.memory.fast_store import (
|
| 26 |
-
FastMemoryStore,
|
| 27 |
-
MemoryChunk,
|
| 28 |
-
create_local_embed_fn,
|
| 29 |
-
create_openai_embed_fn,
|
| 30 |
-
)
|
| 31 |
-
from headroom.memory.inline_extractor import (
|
| 32 |
-
inject_memory_instruction,
|
| 33 |
-
parse_response_with_memory,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class FastMemoryWrapper:
|
| 38 |
-
"""Wraps an LLM client with zero-latency inline memory extraction.
|
| 39 |
-
|
| 40 |
-
Architecture:
|
| 41 |
-
1. BEFORE: Inject relevant memories into user message (semantic search)
|
| 42 |
-
2. DURING: Memory instruction is in system prompt
|
| 43 |
-
3. AFTER: Parse memory block from response, store extracted memories
|
| 44 |
-
|
| 45 |
-
All memory operations happen as part of the normal LLM flow - no extra calls!
|
| 46 |
-
"""
|
| 47 |
-
|
| 48 |
-
def __init__(
|
| 49 |
-
self,
|
| 50 |
-
client: Any,
|
| 51 |
-
user_id: str,
|
| 52 |
-
db_path: str | Path = "headroom_fast_memory.db",
|
| 53 |
-
top_k: int = 5,
|
| 54 |
-
use_local_embeddings: bool = True,
|
| 55 |
-
embedding_model: str = "all-MiniLM-L6-v2",
|
| 56 |
-
_store: FastMemoryStore | None = None,
|
| 57 |
-
):
|
| 58 |
-
"""Initialize the fast memory wrapper.
|
| 59 |
-
|
| 60 |
-
Args:
|
| 61 |
-
client: OpenAI-compatible LLM client
|
| 62 |
-
user_id: User identifier for memory isolation
|
| 63 |
-
db_path: Path to SQLite database
|
| 64 |
-
top_k: Number of memories to inject
|
| 65 |
-
use_local_embeddings: Use local model (fast) or OpenAI API
|
| 66 |
-
embedding_model: Model name for local embeddings
|
| 67 |
-
_store: Override store (for testing)
|
| 68 |
-
"""
|
| 69 |
-
self._client = client
|
| 70 |
-
self._user_id = user_id
|
| 71 |
-
self._top_k = top_k
|
| 72 |
-
|
| 73 |
-
# Initialize store with appropriate embedding function
|
| 74 |
-
if _store:
|
| 75 |
-
self._store = _store
|
| 76 |
-
elif use_local_embeddings:
|
| 77 |
-
embed_fn = create_local_embed_fn(embedding_model)
|
| 78 |
-
# MiniLM-L6-v2 produces 384-dim embeddings
|
| 79 |
-
self._store = FastMemoryStore(db_path, embed_fn=embed_fn, embedding_dim=384)
|
| 80 |
-
else:
|
| 81 |
-
embed_fn = create_openai_embed_fn(client)
|
| 82 |
-
self._store = FastMemoryStore(db_path, embed_fn=embed_fn)
|
| 83 |
-
|
| 84 |
-
# Create wrapped chat interface
|
| 85 |
-
self.chat = _FastWrappedChat(self)
|
| 86 |
-
|
| 87 |
-
@property
|
| 88 |
-
def memory(self) -> _FastMemoryAPI:
|
| 89 |
-
"""Direct access to memory operations."""
|
| 90 |
-
return _FastMemoryAPI(self._store, self._user_id)
|
| 91 |
-
|
| 92 |
-
def _inject_memories(self, messages: list[dict]) -> list[dict]:
|
| 93 |
-
"""Inject relevant memories into user message.
|
| 94 |
-
|
| 95 |
-
Uses semantic search (vector similarity) to find relevant memories.
|
| 96 |
-
Injects into FIRST user message to preserve system prompt caching.
|
| 97 |
-
|
| 98 |
-
Args:
|
| 99 |
-
messages: Original messages list
|
| 100 |
-
|
| 101 |
-
Returns:
|
| 102 |
-
New messages with memories injected
|
| 103 |
-
"""
|
| 104 |
-
# Find the last user message for search context
|
| 105 |
-
user_content = None
|
| 106 |
-
for msg in reversed(messages):
|
| 107 |
-
if msg.get("role") == "user":
|
| 108 |
-
user_content = msg.get("content", "")
|
| 109 |
-
break
|
| 110 |
-
|
| 111 |
-
if not user_content:
|
| 112 |
-
return messages
|
| 113 |
-
|
| 114 |
-
# Semantic search for relevant memories
|
| 115 |
-
results = self._store.search(self._user_id, str(user_content), top_k=self._top_k)
|
| 116 |
-
|
| 117 |
-
if not results:
|
| 118 |
-
return messages
|
| 119 |
-
|
| 120 |
-
# Build context block
|
| 121 |
-
context_lines = ["<context>"]
|
| 122 |
-
for chunk, _score in results:
|
| 123 |
-
context_lines.append(f"- {chunk.text}")
|
| 124 |
-
context_lines.append("</context>")
|
| 125 |
-
context_block = "\n".join(context_lines)
|
| 126 |
-
|
| 127 |
-
# Inject into first user message
|
| 128 |
-
new_messages = copy.deepcopy(messages)
|
| 129 |
-
for msg in new_messages:
|
| 130 |
-
if msg.get("role") == "user":
|
| 131 |
-
original = msg.get("content", "")
|
| 132 |
-
msg["content"] = f"{context_block}\n\n{original}"
|
| 133 |
-
break
|
| 134 |
-
|
| 135 |
-
return new_messages
|
| 136 |
-
|
| 137 |
-
def _store_memories(self, memories: list[dict[str, Any]]) -> None:
|
| 138 |
-
"""Store extracted memories.
|
| 139 |
-
|
| 140 |
-
Args:
|
| 141 |
-
memories: List of memory dicts from inline extraction
|
| 142 |
-
"""
|
| 143 |
-
for mem in memories:
|
| 144 |
-
content = mem.get("content", "")
|
| 145 |
-
category = mem.get("category", "fact")
|
| 146 |
-
if content:
|
| 147 |
-
self._store.add(
|
| 148 |
-
self._user_id,
|
| 149 |
-
content,
|
| 150 |
-
role="memory",
|
| 151 |
-
metadata={"category": category, "source": "inline_extraction"},
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
class _FastWrappedChat:
|
| 156 |
-
"""Wrapped chat interface."""
|
| 157 |
-
|
| 158 |
-
def __init__(self, wrapper: FastMemoryWrapper):
|
| 159 |
-
self._wrapper = wrapper
|
| 160 |
-
self.completions = _FastWrappedCompletions(wrapper)
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
class _FastWrappedCompletions:
|
| 164 |
-
"""Wrapped completions with inline memory extraction."""
|
| 165 |
-
|
| 166 |
-
def __init__(self, wrapper: FastMemoryWrapper):
|
| 167 |
-
self._wrapper = wrapper
|
| 168 |
-
|
| 169 |
-
def create(self, **kwargs: Any) -> Any:
|
| 170 |
-
"""Create chat completion with inline memory extraction.
|
| 171 |
-
|
| 172 |
-
Flow:
|
| 173 |
-
1. Search for relevant memories (semantic)
|
| 174 |
-
2. Inject memories into user message
|
| 175 |
-
3. Add memory instruction to system prompt
|
| 176 |
-
4. Forward to LLM
|
| 177 |
-
5. Parse response to extract memories
|
| 178 |
-
6. Store extracted memories
|
| 179 |
-
7. Return clean response (without memory block)
|
| 180 |
-
"""
|
| 181 |
-
messages = kwargs.get("messages", [])
|
| 182 |
-
|
| 183 |
-
# 1. Inject relevant memories into user message
|
| 184 |
-
enhanced_messages = self._wrapper._inject_memories(messages)
|
| 185 |
-
|
| 186 |
-
# 2. Add memory extraction instruction to system prompt
|
| 187 |
-
enhanced_messages = inject_memory_instruction(enhanced_messages, short=True)
|
| 188 |
-
kwargs["messages"] = enhanced_messages
|
| 189 |
-
|
| 190 |
-
# 3. Forward to LLM
|
| 191 |
-
response = self._wrapper._client.chat.completions.create(**kwargs)
|
| 192 |
-
|
| 193 |
-
# 4. Parse response and extract memories
|
| 194 |
-
raw_content = response.choices[0].message.content
|
| 195 |
-
parsed = parse_response_with_memory(raw_content)
|
| 196 |
-
|
| 197 |
-
# 5. Store extracted memories
|
| 198 |
-
if parsed.memories:
|
| 199 |
-
self._wrapper._store_memories(parsed.memories)
|
| 200 |
-
|
| 201 |
-
# 6. Return clean response (modify in place)
|
| 202 |
-
response.choices[0].message.content = parsed.content
|
| 203 |
-
|
| 204 |
-
return response
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
class _FastMemoryAPI:
|
| 208 |
-
"""Direct API for memory operations."""
|
| 209 |
-
|
| 210 |
-
def __init__(self, store: FastMemoryStore, user_id: str):
|
| 211 |
-
self._store = store
|
| 212 |
-
self._user_id = user_id
|
| 213 |
-
|
| 214 |
-
def search(self, query: str, top_k: int = 5) -> list[tuple[MemoryChunk, float]]:
|
| 215 |
-
"""Semantic search for memories.
|
| 216 |
-
|
| 217 |
-
Args:
|
| 218 |
-
query: Search query
|
| 219 |
-
top_k: Max results
|
| 220 |
-
|
| 221 |
-
Returns:
|
| 222 |
-
List of (memory, similarity_score) tuples
|
| 223 |
-
"""
|
| 224 |
-
return self._store.search(self._user_id, query, top_k)
|
| 225 |
-
|
| 226 |
-
def add(self, content: str, category: str = "fact") -> MemoryChunk:
|
| 227 |
-
"""Manually add a memory.
|
| 228 |
-
|
| 229 |
-
Args:
|
| 230 |
-
content: Memory content
|
| 231 |
-
category: preference, fact, or context
|
| 232 |
-
|
| 233 |
-
Returns:
|
| 234 |
-
The created memory chunk
|
| 235 |
-
"""
|
| 236 |
-
return self._store.add(
|
| 237 |
-
self._user_id,
|
| 238 |
-
content,
|
| 239 |
-
role="memory",
|
| 240 |
-
metadata={"category": category, "source": "manual"},
|
| 241 |
-
)
|
| 242 |
-
|
| 243 |
-
def get_all(self) -> list[MemoryChunk]:
|
| 244 |
-
"""Get all memories for this user."""
|
| 245 |
-
return self._store.get_all(self._user_id)
|
| 246 |
-
|
| 247 |
-
def clear(self) -> int:
|
| 248 |
-
"""Clear all memories for this user."""
|
| 249 |
-
return self._store.clear(self._user_id)
|
| 250 |
-
|
| 251 |
-
def stats(self) -> dict:
|
| 252 |
-
"""Get memory statistics."""
|
| 253 |
-
return self._store.stats(self._user_id)
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
def with_fast_memory(
|
| 257 |
-
client: Any,
|
| 258 |
-
user_id: str,
|
| 259 |
-
db_path: str | Path = "headroom_fast_memory.db",
|
| 260 |
-
top_k: int = 5,
|
| 261 |
-
use_local_embeddings: bool = True,
|
| 262 |
-
embedding_model: str = "all-MiniLM-L6-v2",
|
| 263 |
-
**kwargs: Any,
|
| 264 |
-
) -> FastMemoryWrapper:
|
| 265 |
-
"""Wrap an LLM client with zero-latency inline memory extraction.
|
| 266 |
-
|
| 267 |
-
This is the fastest memory solution:
|
| 268 |
-
1. ZERO extra LLM calls - memories extracted inline as part of response
|
| 269 |
-
2. Sub-50ms retrieval - local embeddings, no API calls
|
| 270 |
-
3. Semantic search - finds conceptually related memories
|
| 271 |
-
|
| 272 |
-
Args:
|
| 273 |
-
client: OpenAI-compatible LLM client
|
| 274 |
-
user_id: User identifier for memory isolation
|
| 275 |
-
db_path: Path to SQLite database
|
| 276 |
-
top_k: Number of memories to inject per request
|
| 277 |
-
use_local_embeddings: Use local model (True) or OpenAI API (False)
|
| 278 |
-
embedding_model: Model name for local embeddings
|
| 279 |
-
**kwargs: Additional arguments
|
| 280 |
-
|
| 281 |
-
Returns:
|
| 282 |
-
Wrapped client with automatic memory
|
| 283 |
-
|
| 284 |
-
Example:
|
| 285 |
-
from openai import OpenAI
|
| 286 |
-
from headroom.memory import with_fast_memory
|
| 287 |
-
|
| 288 |
-
client = with_fast_memory(OpenAI(), user_id="alice")
|
| 289 |
-
|
| 290 |
-
# First conversation - memory extracted INLINE
|
| 291 |
-
response = client.chat.completions.create(
|
| 292 |
-
model="gpt-4o",
|
| 293 |
-
messages=[{"role": "user", "content": "I prefer Python for backend work"}]
|
| 294 |
-
)
|
| 295 |
-
|
| 296 |
-
# Later - memories automatically retrieved
|
| 297 |
-
response = client.chat.completions.create(
|
| 298 |
-
model="gpt-4o",
|
| 299 |
-
messages=[{"role": "user", "content": "What language should I use?"}]
|
| 300 |
-
)
|
| 301 |
-
# User sees: "Based on your preference for Python..."
|
| 302 |
-
"""
|
| 303 |
-
return FastMemoryWrapper(
|
| 304 |
-
client=client,
|
| 305 |
-
user_id=user_id,
|
| 306 |
-
db_path=db_path,
|
| 307 |
-
top_k=top_k,
|
| 308 |
-
use_local_embeddings=use_local_embeddings,
|
| 309 |
-
embedding_model=embedding_model,
|
| 310 |
-
**kwargs,
|
| 311 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hierarchical memory data models for Headroom."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ScopeLevel(Enum):
|
| 15 |
+
"""Memory scope hierarchy levels."""
|
| 16 |
+
|
| 17 |
+
USER = "user" # Persistent across all sessions
|
| 18 |
+
SESSION = "session" # Persistent within a task/conversation
|
| 19 |
+
AGENT = "agent" # Persistent within an agent's lifetime
|
| 20 |
+
TURN = "turn" # Ephemeral, single LLM call
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class MemoryCategory(Enum):
|
| 24 |
+
"""Memory classification categories."""
|
| 25 |
+
|
| 26 |
+
PREFERENCE = "preference" # User preferences ("likes Rust")
|
| 27 |
+
FACT = "fact" # Factual information ("auth is in src/")
|
| 28 |
+
CONTEXT = "context" # Contextual info ("working on CLI tool")
|
| 29 |
+
ENTITY = "entity" # Entity reference ("John from team X")
|
| 30 |
+
DECISION = "decision" # Decisions made ("chose OAuth over JWT")
|
| 31 |
+
INSIGHT = "insight" # Derived insights ("user is senior dev")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class Memory:
|
| 36 |
+
"""A hierarchically-scoped memory with temporal awareness."""
|
| 37 |
+
|
| 38 |
+
# Identity
|
| 39 |
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 40 |
+
content: str = ""
|
| 41 |
+
|
| 42 |
+
# Hierarchical Scoping (required: user_id, optional: narrower scopes)
|
| 43 |
+
user_id: str = ""
|
| 44 |
+
session_id: str | None = None
|
| 45 |
+
agent_id: str | None = None
|
| 46 |
+
turn_id: str | None = None
|
| 47 |
+
|
| 48 |
+
# Temporal
|
| 49 |
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 50 |
+
valid_from: datetime = field(default_factory=datetime.utcnow)
|
| 51 |
+
valid_until: datetime | None = None # None = current/active
|
| 52 |
+
|
| 53 |
+
# Classification
|
| 54 |
+
category: MemoryCategory = MemoryCategory.FACT
|
| 55 |
+
importance: float = 0.5 # 0.0 - 1.0
|
| 56 |
+
|
| 57 |
+
# Lineage (for supersession and bubbling)
|
| 58 |
+
supersedes: str | None = None # ID of memory this replaced
|
| 59 |
+
superseded_by: str | None = None # ID of memory that replaced this
|
| 60 |
+
promoted_from: str | None = None # ID of child memory (if bubbled up)
|
| 61 |
+
promotion_chain: list[str] = field(default_factory=list)
|
| 62 |
+
|
| 63 |
+
# Access tracking
|
| 64 |
+
access_count: int = 0
|
| 65 |
+
last_accessed: datetime | None = None
|
| 66 |
+
|
| 67 |
+
# Entity references
|
| 68 |
+
entity_refs: list[str] = field(default_factory=list)
|
| 69 |
+
|
| 70 |
+
# Embedding (for vector search)
|
| 71 |
+
embedding: np.ndarray | None = None
|
| 72 |
+
|
| 73 |
+
# Metadata
|
| 74 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def scope_level(self) -> ScopeLevel:
|
| 78 |
+
"""Compute the scope level from hierarchy fields."""
|
| 79 |
+
if self.turn_id is not None:
|
| 80 |
+
return ScopeLevel.TURN
|
| 81 |
+
if self.agent_id is not None:
|
| 82 |
+
return ScopeLevel.AGENT
|
| 83 |
+
if self.session_id is not None:
|
| 84 |
+
return ScopeLevel.SESSION
|
| 85 |
+
return ScopeLevel.USER
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def is_current(self) -> bool:
|
| 89 |
+
"""Check if this memory is current (not superseded)."""
|
| 90 |
+
return self.valid_until is None
|
| 91 |
+
|
| 92 |
+
def to_dict(self) -> dict[str, Any]:
|
| 93 |
+
"""Convert to dictionary for serialization."""
|
| 94 |
+
return {
|
| 95 |
+
"id": self.id,
|
| 96 |
+
"content": self.content,
|
| 97 |
+
"user_id": self.user_id,
|
| 98 |
+
"session_id": self.session_id,
|
| 99 |
+
"agent_id": self.agent_id,
|
| 100 |
+
"turn_id": self.turn_id,
|
| 101 |
+
"created_at": self.created_at.isoformat(),
|
| 102 |
+
"valid_from": self.valid_from.isoformat(),
|
| 103 |
+
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
| 104 |
+
"category": self.category.value,
|
| 105 |
+
"importance": self.importance,
|
| 106 |
+
"supersedes": self.supersedes,
|
| 107 |
+
"superseded_by": self.superseded_by,
|
| 108 |
+
"promoted_from": self.promoted_from,
|
| 109 |
+
"promotion_chain": self.promotion_chain,
|
| 110 |
+
"access_count": self.access_count,
|
| 111 |
+
"last_accessed": self.last_accessed.isoformat() if self.last_accessed else None,
|
| 112 |
+
"entity_refs": self.entity_refs,
|
| 113 |
+
"embedding": self.embedding.tolist() if self.embedding is not None else None,
|
| 114 |
+
"metadata": self.metadata,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
@classmethod
|
| 118 |
+
def from_dict(cls, data: dict[str, Any]) -> Memory:
|
| 119 |
+
"""Create from dictionary."""
|
| 120 |
+
embedding = None
|
| 121 |
+
if data.get("embedding"):
|
| 122 |
+
embedding = np.array(data["embedding"], dtype=np.float32)
|
| 123 |
+
|
| 124 |
+
return cls(
|
| 125 |
+
id=data["id"],
|
| 126 |
+
content=data["content"],
|
| 127 |
+
user_id=data["user_id"],
|
| 128 |
+
session_id=data.get("session_id"),
|
| 129 |
+
agent_id=data.get("agent_id"),
|
| 130 |
+
turn_id=data.get("turn_id"),
|
| 131 |
+
created_at=datetime.fromisoformat(data["created_at"]),
|
| 132 |
+
valid_from=datetime.fromisoformat(data["valid_from"]),
|
| 133 |
+
valid_until=datetime.fromisoformat(data["valid_until"])
|
| 134 |
+
if data.get("valid_until")
|
| 135 |
+
else None,
|
| 136 |
+
category=MemoryCategory(data["category"]),
|
| 137 |
+
importance=data["importance"],
|
| 138 |
+
supersedes=data.get("supersedes"),
|
| 139 |
+
superseded_by=data.get("superseded_by"),
|
| 140 |
+
promoted_from=data.get("promoted_from"),
|
| 141 |
+
promotion_chain=data.get("promotion_chain", []),
|
| 142 |
+
access_count=data.get("access_count", 0),
|
| 143 |
+
last_accessed=datetime.fromisoformat(data["last_accessed"])
|
| 144 |
+
if data.get("last_accessed")
|
| 145 |
+
else None,
|
| 146 |
+
entity_refs=data.get("entity_refs", []),
|
| 147 |
+
embedding=embedding,
|
| 148 |
+
metadata=data.get("metadata", {}),
|
| 149 |
+
)
|
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protocol interfaces for pluggable memory system components."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Any, Protocol, runtime_checkable
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from headroom.memory.models import Memory, MemoryCategory, ScopeLevel
|
| 12 |
+
|
| 13 |
+
# =============================================================================
|
| 14 |
+
# Filter Dataclasses
|
| 15 |
+
# =============================================================================
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class MemoryFilter:
|
| 20 |
+
"""Filter criteria for memory store queries."""
|
| 21 |
+
|
| 22 |
+
# Scope filters
|
| 23 |
+
user_id: str | None = None
|
| 24 |
+
session_id: str | None = None
|
| 25 |
+
agent_id: str | None = None
|
| 26 |
+
turn_id: str | None = None
|
| 27 |
+
scope_levels: list[ScopeLevel] | None = None
|
| 28 |
+
|
| 29 |
+
# Category filters
|
| 30 |
+
categories: list[MemoryCategory] | None = None
|
| 31 |
+
|
| 32 |
+
# Temporal filters
|
| 33 |
+
created_after: datetime | None = None
|
| 34 |
+
created_before: datetime | None = None
|
| 35 |
+
valid_at: datetime | None = None # Point-in-time query
|
| 36 |
+
include_superseded: bool = False # Include historical versions
|
| 37 |
+
|
| 38 |
+
# Importance filters
|
| 39 |
+
min_importance: float | None = None
|
| 40 |
+
max_importance: float | None = None
|
| 41 |
+
|
| 42 |
+
# Entity filters
|
| 43 |
+
entity_refs: list[str] | None = None # Any of these entities
|
| 44 |
+
|
| 45 |
+
# Lineage filters
|
| 46 |
+
has_supersedes: bool | None = None
|
| 47 |
+
has_promoted_from: bool | None = None
|
| 48 |
+
|
| 49 |
+
# Pagination
|
| 50 |
+
limit: int | None = None
|
| 51 |
+
offset: int = 0
|
| 52 |
+
|
| 53 |
+
# Sorting
|
| 54 |
+
order_by: str = "created_at" # created_at, importance, access_count, last_accessed
|
| 55 |
+
order_desc: bool = True
|
| 56 |
+
|
| 57 |
+
# Metadata filters
|
| 58 |
+
metadata_filters: dict[str, Any] = field(default_factory=dict)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class VectorFilter:
|
| 63 |
+
"""Filter criteria for vector similarity searches."""
|
| 64 |
+
|
| 65 |
+
# Required: query vector or text (one must be provided)
|
| 66 |
+
query_vector: np.ndarray | None = None
|
| 67 |
+
query_text: str | None = None # Will be embedded if vector not provided
|
| 68 |
+
|
| 69 |
+
# Search parameters
|
| 70 |
+
top_k: int = 10
|
| 71 |
+
min_similarity: float = 0.0 # Minimum cosine similarity threshold
|
| 72 |
+
|
| 73 |
+
# Scope filters (inherited from MemoryFilter)
|
| 74 |
+
user_id: str | None = None
|
| 75 |
+
session_id: str | None = None
|
| 76 |
+
agent_id: str | None = None
|
| 77 |
+
scope_levels: list[ScopeLevel] | None = None
|
| 78 |
+
|
| 79 |
+
# Category filters
|
| 80 |
+
categories: list[MemoryCategory] | None = None
|
| 81 |
+
|
| 82 |
+
# Temporal filters
|
| 83 |
+
valid_at: datetime | None = None
|
| 84 |
+
include_superseded: bool = False
|
| 85 |
+
|
| 86 |
+
# Entity filters
|
| 87 |
+
entity_refs: list[str] | None = None
|
| 88 |
+
|
| 89 |
+
# Metadata filters
|
| 90 |
+
metadata_filters: dict[str, Any] = field(default_factory=dict)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class TextFilter:
|
| 95 |
+
"""Filter criteria for full-text searches."""
|
| 96 |
+
|
| 97 |
+
# Required: search query
|
| 98 |
+
query: str = ""
|
| 99 |
+
|
| 100 |
+
# Search mode
|
| 101 |
+
match_mode: str = "contains" # contains, prefix, exact, fuzzy, regex
|
| 102 |
+
case_sensitive: bool = False
|
| 103 |
+
|
| 104 |
+
# Result parameters
|
| 105 |
+
limit: int = 100
|
| 106 |
+
|
| 107 |
+
# Scope filters (inherited from MemoryFilter)
|
| 108 |
+
user_id: str | None = None
|
| 109 |
+
session_id: str | None = None
|
| 110 |
+
agent_id: str | None = None
|
| 111 |
+
scope_levels: list[ScopeLevel] | None = None
|
| 112 |
+
|
| 113 |
+
# Category filters
|
| 114 |
+
categories: list[MemoryCategory] | None = None
|
| 115 |
+
|
| 116 |
+
# Temporal filters
|
| 117 |
+
valid_at: datetime | None = None
|
| 118 |
+
include_superseded: bool = False
|
| 119 |
+
|
| 120 |
+
# Metadata filters
|
| 121 |
+
metadata_filters: dict[str, Any] = field(default_factory=dict)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# =============================================================================
|
| 125 |
+
# Search Result Dataclasses
|
| 126 |
+
# =============================================================================
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@dataclass
|
| 130 |
+
class VectorSearchResult:
|
| 131 |
+
"""Result from a vector similarity search."""
|
| 132 |
+
|
| 133 |
+
memory: Memory
|
| 134 |
+
similarity: float # Cosine similarity score (0.0 - 1.0)
|
| 135 |
+
rank: int # Position in results (1-indexed)
|
| 136 |
+
|
| 137 |
+
def __lt__(self, other: VectorSearchResult) -> bool:
|
| 138 |
+
"""Enable sorting by similarity (descending)."""
|
| 139 |
+
return self.similarity > other.similarity
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@dataclass
|
| 143 |
+
class TextSearchResult:
|
| 144 |
+
"""Result from a full-text search."""
|
| 145 |
+
|
| 146 |
+
memory: Memory
|
| 147 |
+
score: float # Relevance score (implementation-specific)
|
| 148 |
+
rank: int # Position in results (1-indexed)
|
| 149 |
+
highlights: list[str] = field(default_factory=list) # Matching snippets
|
| 150 |
+
matched_terms: list[str] = field(default_factory=list) # Terms that matched
|
| 151 |
+
|
| 152 |
+
def __lt__(self, other: TextSearchResult) -> bool:
|
| 153 |
+
"""Enable sorting by score (descending)."""
|
| 154 |
+
return self.score > other.score
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# =============================================================================
|
| 158 |
+
# Protocol Interfaces
|
| 159 |
+
# =============================================================================
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@runtime_checkable
|
| 163 |
+
class MemoryStore(Protocol):
|
| 164 |
+
"""
|
| 165 |
+
Protocol for memory persistence backends.
|
| 166 |
+
|
| 167 |
+
Implementations handle CRUD operations and filtering for Memory objects.
|
| 168 |
+
Examples: SQLite, PostgreSQL, DynamoDB, Redis, in-memory.
|
| 169 |
+
"""
|
| 170 |
+
|
| 171 |
+
async def save(self, memory: Memory) -> None:
|
| 172 |
+
"""
|
| 173 |
+
Save or update a memory.
|
| 174 |
+
|
| 175 |
+
If a memory with the same ID exists, it will be updated.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
memory: The memory to save.
|
| 179 |
+
"""
|
| 180 |
+
...
|
| 181 |
+
|
| 182 |
+
async def save_batch(self, memories: list[Memory]) -> None:
|
| 183 |
+
"""
|
| 184 |
+
Save multiple memories in a single operation.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
memories: List of memories to save.
|
| 188 |
+
"""
|
| 189 |
+
...
|
| 190 |
+
|
| 191 |
+
async def get(self, memory_id: str) -> Memory | None:
|
| 192 |
+
"""
|
| 193 |
+
Retrieve a memory by ID.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
memory_id: The unique identifier of the memory.
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
The memory if found, None otherwise.
|
| 200 |
+
"""
|
| 201 |
+
...
|
| 202 |
+
|
| 203 |
+
async def get_batch(self, memory_ids: list[str]) -> list[Memory]:
|
| 204 |
+
"""
|
| 205 |
+
Retrieve multiple memories by ID.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
memory_ids: List of memory IDs to retrieve.
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
List of found memories (may be shorter than input if some not found).
|
| 212 |
+
"""
|
| 213 |
+
...
|
| 214 |
+
|
| 215 |
+
async def delete(self, memory_id: str) -> bool:
|
| 216 |
+
"""
|
| 217 |
+
Delete a memory by ID.
|
| 218 |
+
|
| 219 |
+
Args:
|
| 220 |
+
memory_id: The unique identifier of the memory.
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
True if the memory was deleted, False if not found.
|
| 224 |
+
"""
|
| 225 |
+
...
|
| 226 |
+
|
| 227 |
+
async def delete_batch(self, memory_ids: list[str]) -> int:
|
| 228 |
+
"""
|
| 229 |
+
Delete multiple memories by ID.
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
memory_ids: List of memory IDs to delete.
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
Number of memories actually deleted.
|
| 236 |
+
"""
|
| 237 |
+
...
|
| 238 |
+
|
| 239 |
+
async def query(self, filter: MemoryFilter) -> list[Memory]:
|
| 240 |
+
"""
|
| 241 |
+
Query memories matching the given filter.
|
| 242 |
+
|
| 243 |
+
Args:
|
| 244 |
+
filter: Filter criteria for the query.
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
List of matching memories.
|
| 248 |
+
"""
|
| 249 |
+
...
|
| 250 |
+
|
| 251 |
+
async def count(self, filter: MemoryFilter) -> int:
|
| 252 |
+
"""
|
| 253 |
+
Count memories matching the given filter.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
filter: Filter criteria for the count.
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
Number of matching memories.
|
| 260 |
+
"""
|
| 261 |
+
...
|
| 262 |
+
|
| 263 |
+
async def supersede(
|
| 264 |
+
self,
|
| 265 |
+
old_memory_id: str,
|
| 266 |
+
new_memory: Memory,
|
| 267 |
+
supersede_time: datetime | None = None,
|
| 268 |
+
) -> Memory:
|
| 269 |
+
"""
|
| 270 |
+
Supersede an existing memory with a new version.
|
| 271 |
+
|
| 272 |
+
This creates a temporal chain: the old memory's valid_until is set,
|
| 273 |
+
and the new memory's supersedes field points to the old one.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
old_memory_id: ID of the memory to supersede.
|
| 277 |
+
new_memory: The new memory that replaces it.
|
| 278 |
+
supersede_time: When the supersession occurred (defaults to now).
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
The saved new memory with lineage fields populated.
|
| 282 |
+
"""
|
| 283 |
+
...
|
| 284 |
+
|
| 285 |
+
async def get_history(
|
| 286 |
+
self,
|
| 287 |
+
memory_id: str,
|
| 288 |
+
include_future: bool = False,
|
| 289 |
+
) -> list[Memory]:
|
| 290 |
+
"""
|
| 291 |
+
Get the full history chain for a memory.
|
| 292 |
+
|
| 293 |
+
Follows the supersedes/superseded_by chain to return all versions.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
memory_id: ID of any memory in the chain.
|
| 297 |
+
include_future: Whether to include memories that superseded this one.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
List of memories in temporal order (oldest first).
|
| 301 |
+
"""
|
| 302 |
+
...
|
| 303 |
+
|
| 304 |
+
async def clear_scope(
|
| 305 |
+
self,
|
| 306 |
+
user_id: str,
|
| 307 |
+
session_id: str | None = None,
|
| 308 |
+
agent_id: str | None = None,
|
| 309 |
+
turn_id: str | None = None,
|
| 310 |
+
) -> int:
|
| 311 |
+
"""
|
| 312 |
+
Clear all memories at or below a scope level.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
user_id: Required user scope.
|
| 316 |
+
session_id: If provided, clear session and below.
|
| 317 |
+
agent_id: If provided, clear agent and below.
|
| 318 |
+
turn_id: If provided, clear only that turn.
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
Number of memories deleted.
|
| 322 |
+
"""
|
| 323 |
+
...
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@runtime_checkable
|
| 327 |
+
class VectorIndex(Protocol):
|
| 328 |
+
"""
|
| 329 |
+
Protocol for vector similarity search backends.
|
| 330 |
+
|
| 331 |
+
Implementations handle embedding storage and similarity search.
|
| 332 |
+
Examples: FAISS, Annoy, Pinecone, Weaviate, Qdrant.
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
async def index(self, memory: Memory) -> None:
|
| 336 |
+
"""
|
| 337 |
+
Index a memory's embedding for similarity search.
|
| 338 |
+
|
| 339 |
+
The memory must have an embedding set.
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
memory: The memory to index.
|
| 343 |
+
|
| 344 |
+
Raises:
|
| 345 |
+
ValueError: If the memory has no embedding.
|
| 346 |
+
"""
|
| 347 |
+
...
|
| 348 |
+
|
| 349 |
+
async def index_batch(self, memories: list[Memory]) -> int:
|
| 350 |
+
"""
|
| 351 |
+
Index multiple memories' embeddings.
|
| 352 |
+
|
| 353 |
+
Memories without embeddings are skipped.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
memories: List of memories to index.
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
Number of memories actually indexed.
|
| 360 |
+
"""
|
| 361 |
+
...
|
| 362 |
+
|
| 363 |
+
async def remove(self, memory_id: str) -> bool:
|
| 364 |
+
"""
|
| 365 |
+
Remove a memory from the vector index.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
memory_id: The unique identifier of the memory.
|
| 369 |
+
|
| 370 |
+
Returns:
|
| 371 |
+
True if removed, False if not found.
|
| 372 |
+
"""
|
| 373 |
+
...
|
| 374 |
+
|
| 375 |
+
async def remove_batch(self, memory_ids: list[str]) -> int:
|
| 376 |
+
"""
|
| 377 |
+
Remove multiple memories from the vector index.
|
| 378 |
+
|
| 379 |
+
Args:
|
| 380 |
+
memory_ids: List of memory IDs to remove.
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
Number of memories actually removed.
|
| 384 |
+
"""
|
| 385 |
+
...
|
| 386 |
+
|
| 387 |
+
async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
|
| 388 |
+
"""
|
| 389 |
+
Search for similar memories using vector similarity.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
filter: Vector search filter with query and constraints.
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
List of search results sorted by similarity (descending).
|
| 396 |
+
"""
|
| 397 |
+
...
|
| 398 |
+
|
| 399 |
+
async def update_embedding(self, memory_id: str, embedding: np.ndarray) -> bool:
|
| 400 |
+
"""
|
| 401 |
+
Update the embedding for an indexed memory.
|
| 402 |
+
|
| 403 |
+
Args:
|
| 404 |
+
memory_id: The unique identifier of the memory.
|
| 405 |
+
embedding: The new embedding vector.
|
| 406 |
+
|
| 407 |
+
Returns:
|
| 408 |
+
True if updated, False if memory not found in index.
|
| 409 |
+
"""
|
| 410 |
+
...
|
| 411 |
+
|
| 412 |
+
@property
|
| 413 |
+
def dimension(self) -> int:
|
| 414 |
+
"""Return the embedding dimension this index expects."""
|
| 415 |
+
...
|
| 416 |
+
|
| 417 |
+
@property
|
| 418 |
+
def size(self) -> int:
|
| 419 |
+
"""Return the number of vectors currently indexed."""
|
| 420 |
+
...
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
@runtime_checkable
|
| 424 |
+
class TextIndex(Protocol):
|
| 425 |
+
"""
|
| 426 |
+
Protocol for full-text search backends.
|
| 427 |
+
|
| 428 |
+
Implementations handle text indexing and keyword search.
|
| 429 |
+
Examples: SQLite FTS5, Elasticsearch, Tantivy, in-memory.
|
| 430 |
+
"""
|
| 431 |
+
|
| 432 |
+
async def index(self, memory: Memory) -> None:
|
| 433 |
+
"""
|
| 434 |
+
Index a memory's content for full-text search.
|
| 435 |
+
|
| 436 |
+
Args:
|
| 437 |
+
memory: The memory to index.
|
| 438 |
+
"""
|
| 439 |
+
...
|
| 440 |
+
|
| 441 |
+
async def index_batch(self, memories: list[Memory]) -> int:
|
| 442 |
+
"""
|
| 443 |
+
Index multiple memories for full-text search.
|
| 444 |
+
|
| 445 |
+
Args:
|
| 446 |
+
memories: List of memories to index.
|
| 447 |
+
|
| 448 |
+
Returns:
|
| 449 |
+
Number of memories actually indexed.
|
| 450 |
+
"""
|
| 451 |
+
...
|
| 452 |
+
|
| 453 |
+
async def remove(self, memory_id: str) -> bool:
|
| 454 |
+
"""
|
| 455 |
+
Remove a memory from the text index.
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
memory_id: The unique identifier of the memory.
|
| 459 |
+
|
| 460 |
+
Returns:
|
| 461 |
+
True if removed, False if not found.
|
| 462 |
+
"""
|
| 463 |
+
...
|
| 464 |
+
|
| 465 |
+
async def remove_batch(self, memory_ids: list[str]) -> int:
|
| 466 |
+
"""
|
| 467 |
+
Remove multiple memories from the text index.
|
| 468 |
+
|
| 469 |
+
Args:
|
| 470 |
+
memory_ids: List of memory IDs to remove.
|
| 471 |
+
|
| 472 |
+
Returns:
|
| 473 |
+
Number of memories actually removed.
|
| 474 |
+
"""
|
| 475 |
+
...
|
| 476 |
+
|
| 477 |
+
async def search(self, filter: TextFilter) -> list[TextSearchResult]:
|
| 478 |
+
"""
|
| 479 |
+
Search for memories using full-text search.
|
| 480 |
+
|
| 481 |
+
Args:
|
| 482 |
+
filter: Text search filter with query and constraints.
|
| 483 |
+
|
| 484 |
+
Returns:
|
| 485 |
+
List of search results sorted by relevance.
|
| 486 |
+
"""
|
| 487 |
+
...
|
| 488 |
+
|
| 489 |
+
async def update_content(self, memory_id: str, content: str) -> bool:
|
| 490 |
+
"""
|
| 491 |
+
Update the indexed content for a memory.
|
| 492 |
+
|
| 493 |
+
Args:
|
| 494 |
+
memory_id: The unique identifier of the memory.
|
| 495 |
+
content: The new content to index.
|
| 496 |
+
|
| 497 |
+
Returns:
|
| 498 |
+
True if updated, False if memory not found in index.
|
| 499 |
+
"""
|
| 500 |
+
...
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
@runtime_checkable
|
| 504 |
+
class Embedder(Protocol):
|
| 505 |
+
"""
|
| 506 |
+
Protocol for text embedding generation.
|
| 507 |
+
|
| 508 |
+
Implementations convert text to dense vector representations.
|
| 509 |
+
Examples: OpenAI embeddings, sentence-transformers, Cohere.
|
| 510 |
+
"""
|
| 511 |
+
|
| 512 |
+
async def embed(self, text: str) -> np.ndarray:
|
| 513 |
+
"""
|
| 514 |
+
Generate an embedding for a single text.
|
| 515 |
+
|
| 516 |
+
Args:
|
| 517 |
+
text: The text to embed.
|
| 518 |
+
|
| 519 |
+
Returns:
|
| 520 |
+
The embedding vector as a numpy array.
|
| 521 |
+
"""
|
| 522 |
+
...
|
| 523 |
+
|
| 524 |
+
async def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 525 |
+
"""
|
| 526 |
+
Generate embeddings for multiple texts.
|
| 527 |
+
|
| 528 |
+
Args:
|
| 529 |
+
texts: List of texts to embed.
|
| 530 |
+
|
| 531 |
+
Returns:
|
| 532 |
+
List of embedding vectors.
|
| 533 |
+
"""
|
| 534 |
+
...
|
| 535 |
+
|
| 536 |
+
@property
|
| 537 |
+
def dimension(self) -> int:
|
| 538 |
+
"""Return the dimension of generated embeddings."""
|
| 539 |
+
...
|
| 540 |
+
|
| 541 |
+
@property
|
| 542 |
+
def model_name(self) -> str:
|
| 543 |
+
"""Return the name/identifier of the embedding model."""
|
| 544 |
+
...
|
| 545 |
+
|
| 546 |
+
@property
|
| 547 |
+
def max_tokens(self) -> int:
|
| 548 |
+
"""Return the maximum number of tokens the model can process."""
|
| 549 |
+
...
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
@runtime_checkable
|
| 553 |
+
class MemoryCache(Protocol):
|
| 554 |
+
"""
|
| 555 |
+
Protocol for memory caching layer.
|
| 556 |
+
|
| 557 |
+
Implementations provide fast access to frequently-used memories.
|
| 558 |
+
Examples: LRU cache, Redis, in-memory dict with TTL.
|
| 559 |
+
"""
|
| 560 |
+
|
| 561 |
+
async def get(self, memory_id: str) -> Memory | None:
|
| 562 |
+
"""
|
| 563 |
+
Get a memory from cache.
|
| 564 |
+
|
| 565 |
+
Args:
|
| 566 |
+
memory_id: The unique identifier of the memory.
|
| 567 |
+
|
| 568 |
+
Returns:
|
| 569 |
+
The cached memory if found, None otherwise.
|
| 570 |
+
"""
|
| 571 |
+
...
|
| 572 |
+
|
| 573 |
+
async def get_batch(self, memory_ids: list[str]) -> dict[str, Memory]:
|
| 574 |
+
"""
|
| 575 |
+
Get multiple memories from cache.
|
| 576 |
+
|
| 577 |
+
Args:
|
| 578 |
+
memory_ids: List of memory IDs to retrieve.
|
| 579 |
+
|
| 580 |
+
Returns:
|
| 581 |
+
Dict mapping found memory IDs to their memories.
|
| 582 |
+
"""
|
| 583 |
+
...
|
| 584 |
+
|
| 585 |
+
async def put(self, memory: Memory, ttl_seconds: int | None = None) -> None:
|
| 586 |
+
"""
|
| 587 |
+
Put a memory in cache.
|
| 588 |
+
|
| 589 |
+
Args:
|
| 590 |
+
memory: The memory to cache.
|
| 591 |
+
ttl_seconds: Optional time-to-live in seconds.
|
| 592 |
+
"""
|
| 593 |
+
...
|
| 594 |
+
|
| 595 |
+
async def put_batch(
|
| 596 |
+
self,
|
| 597 |
+
memories: list[Memory],
|
| 598 |
+
ttl_seconds: int | None = None,
|
| 599 |
+
) -> None:
|
| 600 |
+
"""
|
| 601 |
+
Put multiple memories in cache.
|
| 602 |
+
|
| 603 |
+
Args:
|
| 604 |
+
memories: List of memories to cache.
|
| 605 |
+
ttl_seconds: Optional time-to-live in seconds.
|
| 606 |
+
"""
|
| 607 |
+
...
|
| 608 |
+
|
| 609 |
+
async def invalidate(self, memory_id: str) -> bool:
|
| 610 |
+
"""
|
| 611 |
+
Invalidate (remove) a memory from cache.
|
| 612 |
+
|
| 613 |
+
Args:
|
| 614 |
+
memory_id: The unique identifier of the memory.
|
| 615 |
+
|
| 616 |
+
Returns:
|
| 617 |
+
True if the memory was in cache, False otherwise.
|
| 618 |
+
"""
|
| 619 |
+
...
|
| 620 |
+
|
| 621 |
+
async def invalidate_batch(self, memory_ids: list[str]) -> int:
|
| 622 |
+
"""
|
| 623 |
+
Invalidate multiple memories from cache.
|
| 624 |
+
|
| 625 |
+
Args:
|
| 626 |
+
memory_ids: List of memory IDs to invalidate.
|
| 627 |
+
|
| 628 |
+
Returns:
|
| 629 |
+
Number of memories that were in cache.
|
| 630 |
+
"""
|
| 631 |
+
...
|
| 632 |
+
|
| 633 |
+
async def invalidate_scope(
|
| 634 |
+
self,
|
| 635 |
+
user_id: str,
|
| 636 |
+
session_id: str | None = None,
|
| 637 |
+
agent_id: str | None = None,
|
| 638 |
+
) -> int:
|
| 639 |
+
"""
|
| 640 |
+
Invalidate all cached memories at or below a scope.
|
| 641 |
+
|
| 642 |
+
Args:
|
| 643 |
+
user_id: Required user scope.
|
| 644 |
+
session_id: If provided, invalidate session and below.
|
| 645 |
+
agent_id: If provided, invalidate agent and below.
|
| 646 |
+
|
| 647 |
+
Returns:
|
| 648 |
+
Number of memories invalidated.
|
| 649 |
+
"""
|
| 650 |
+
...
|
| 651 |
+
|
| 652 |
+
async def clear(self) -> None:
|
| 653 |
+
"""Clear all entries from the cache."""
|
| 654 |
+
...
|
| 655 |
+
|
| 656 |
+
@property
|
| 657 |
+
def size(self) -> int:
|
| 658 |
+
"""Return the current number of cached entries."""
|
| 659 |
+
...
|
| 660 |
+
|
| 661 |
+
@property
|
| 662 |
+
def max_size(self) -> int | None:
|
| 663 |
+
"""Return the maximum cache size, or None if unbounded."""
|
| 664 |
+
...
|
|
@@ -1,434 +0,0 @@
|
|
| 1 |
-
"""SQLite + FTS5 memory storage for Headroom Memory.
|
| 2 |
-
|
| 3 |
-
Simple, fast, local-first storage with full-text search.
|
| 4 |
-
No external dependencies - just SQLite (built into Python).
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import json
|
| 10 |
-
import sqlite3
|
| 11 |
-
import uuid
|
| 12 |
-
from dataclasses import dataclass, field
|
| 13 |
-
from datetime import datetime
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
from typing import Literal
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
@dataclass
|
| 19 |
-
class Memory:
|
| 20 |
-
"""A single memory entry."""
|
| 21 |
-
|
| 22 |
-
content: str
|
| 23 |
-
category: Literal["preference", "fact", "context"] = "fact"
|
| 24 |
-
importance: float = 0.5
|
| 25 |
-
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 26 |
-
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 27 |
-
metadata: dict = field(default_factory=dict)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
@dataclass
|
| 31 |
-
class PendingExtraction:
|
| 32 |
-
"""A conversation pending memory extraction."""
|
| 33 |
-
|
| 34 |
-
user_id: str
|
| 35 |
-
query: str
|
| 36 |
-
response: str
|
| 37 |
-
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 38 |
-
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 39 |
-
status: Literal["pending", "processing", "done", "failed"] = "pending"
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
class SQLiteMemoryStore:
|
| 43 |
-
"""SQLite + FTS5 storage for memories.
|
| 44 |
-
|
| 45 |
-
Features:
|
| 46 |
-
- Full-text search via FTS5
|
| 47 |
-
- User isolation (each user_id has separate memories)
|
| 48 |
-
- Pending extractions for crash recovery
|
| 49 |
-
- Thread-safe with connection per call
|
| 50 |
-
|
| 51 |
-
Usage:
|
| 52 |
-
store = SQLiteMemoryStore("./memory.db")
|
| 53 |
-
store.save("alice", Memory(content="Prefers Python"))
|
| 54 |
-
results = store.search("alice", "python")
|
| 55 |
-
"""
|
| 56 |
-
|
| 57 |
-
def __init__(self, db_path: str | Path = "headroom_memory.db"):
|
| 58 |
-
"""Initialize the store.
|
| 59 |
-
|
| 60 |
-
Args:
|
| 61 |
-
db_path: Path to SQLite database file. Created if doesn't exist.
|
| 62 |
-
"""
|
| 63 |
-
self.db_path = Path(db_path)
|
| 64 |
-
self._init_db()
|
| 65 |
-
|
| 66 |
-
def _get_conn(self) -> sqlite3.Connection:
|
| 67 |
-
"""Get a new connection (thread-safe pattern)."""
|
| 68 |
-
conn = sqlite3.connect(str(self.db_path))
|
| 69 |
-
conn.row_factory = sqlite3.Row
|
| 70 |
-
return conn
|
| 71 |
-
|
| 72 |
-
def _init_db(self) -> None:
|
| 73 |
-
"""Initialize database schema."""
|
| 74 |
-
with self._get_conn() as conn:
|
| 75 |
-
# Main memories table
|
| 76 |
-
conn.execute("""
|
| 77 |
-
CREATE TABLE IF NOT EXISTS memories (
|
| 78 |
-
id TEXT PRIMARY KEY,
|
| 79 |
-
user_id TEXT NOT NULL,
|
| 80 |
-
content TEXT NOT NULL,
|
| 81 |
-
category TEXT NOT NULL DEFAULT 'fact',
|
| 82 |
-
importance REAL NOT NULL DEFAULT 0.5,
|
| 83 |
-
created_at TEXT NOT NULL,
|
| 84 |
-
metadata TEXT NOT NULL DEFAULT '{}'
|
| 85 |
-
)
|
| 86 |
-
""")
|
| 87 |
-
|
| 88 |
-
# FTS5 virtual table for full-text search
|
| 89 |
-
conn.execute("""
|
| 90 |
-
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
| 91 |
-
content,
|
| 92 |
-
content='memories',
|
| 93 |
-
content_rowid='rowid'
|
| 94 |
-
)
|
| 95 |
-
""")
|
| 96 |
-
|
| 97 |
-
# Triggers to keep FTS in sync
|
| 98 |
-
conn.execute("""
|
| 99 |
-
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
| 100 |
-
INSERT INTO memories_fts(rowid, content)
|
| 101 |
-
VALUES (new.rowid, new.content);
|
| 102 |
-
END
|
| 103 |
-
""")
|
| 104 |
-
|
| 105 |
-
conn.execute("""
|
| 106 |
-
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
| 107 |
-
INSERT INTO memories_fts(memories_fts, rowid, content)
|
| 108 |
-
VALUES ('delete', old.rowid, old.content);
|
| 109 |
-
END
|
| 110 |
-
""")
|
| 111 |
-
|
| 112 |
-
conn.execute("""
|
| 113 |
-
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
| 114 |
-
INSERT INTO memories_fts(memories_fts, rowid, content)
|
| 115 |
-
VALUES ('delete', old.rowid, old.content);
|
| 116 |
-
INSERT INTO memories_fts(rowid, content)
|
| 117 |
-
VALUES (new.rowid, new.content);
|
| 118 |
-
END
|
| 119 |
-
""")
|
| 120 |
-
|
| 121 |
-
# Index for user_id filtering
|
| 122 |
-
conn.execute("""
|
| 123 |
-
CREATE INDEX IF NOT EXISTS idx_memories_user_id
|
| 124 |
-
ON memories(user_id)
|
| 125 |
-
""")
|
| 126 |
-
|
| 127 |
-
# Pending extractions table (for crash recovery)
|
| 128 |
-
conn.execute("""
|
| 129 |
-
CREATE TABLE IF NOT EXISTS pending_extractions (
|
| 130 |
-
id TEXT PRIMARY KEY,
|
| 131 |
-
user_id TEXT NOT NULL,
|
| 132 |
-
query TEXT NOT NULL,
|
| 133 |
-
response TEXT NOT NULL,
|
| 134 |
-
created_at TEXT NOT NULL,
|
| 135 |
-
status TEXT NOT NULL DEFAULT 'pending'
|
| 136 |
-
)
|
| 137 |
-
""")
|
| 138 |
-
|
| 139 |
-
conn.execute("""
|
| 140 |
-
CREATE INDEX IF NOT EXISTS idx_pending_status
|
| 141 |
-
ON pending_extractions(status)
|
| 142 |
-
""")
|
| 143 |
-
|
| 144 |
-
conn.commit()
|
| 145 |
-
|
| 146 |
-
def save(self, user_id: str, memory: Memory) -> None:
|
| 147 |
-
"""Save a memory for a user.
|
| 148 |
-
|
| 149 |
-
Args:
|
| 150 |
-
user_id: User identifier for isolation
|
| 151 |
-
memory: Memory to save
|
| 152 |
-
"""
|
| 153 |
-
with self._get_conn() as conn:
|
| 154 |
-
conn.execute(
|
| 155 |
-
"""
|
| 156 |
-
INSERT INTO memories (id, user_id, content, category, importance, created_at, metadata)
|
| 157 |
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 158 |
-
""",
|
| 159 |
-
(
|
| 160 |
-
memory.id,
|
| 161 |
-
user_id,
|
| 162 |
-
memory.content,
|
| 163 |
-
memory.category,
|
| 164 |
-
memory.importance,
|
| 165 |
-
memory.created_at.isoformat(),
|
| 166 |
-
json.dumps(memory.metadata),
|
| 167 |
-
),
|
| 168 |
-
)
|
| 169 |
-
conn.commit()
|
| 170 |
-
|
| 171 |
-
def search(self, user_id: str, query: str, top_k: int = 5) -> list[Memory]:
|
| 172 |
-
"""Search memories using FTS5 full-text search.
|
| 173 |
-
|
| 174 |
-
Args:
|
| 175 |
-
user_id: User identifier for isolation
|
| 176 |
-
query: Search query (auto-escaped, or use raw FTS5 syntax with prefix '_raw:')
|
| 177 |
-
top_k: Maximum number of results
|
| 178 |
-
|
| 179 |
-
Returns:
|
| 180 |
-
List of matching memories, ranked by relevance
|
| 181 |
-
"""
|
| 182 |
-
# Sanitize query for FTS5 (escape special characters unless raw mode)
|
| 183 |
-
if query.startswith("_raw:"):
|
| 184 |
-
fts_query = query[5:] # Use raw FTS5 syntax
|
| 185 |
-
else:
|
| 186 |
-
fts_query = self._sanitize_fts_query(query)
|
| 187 |
-
|
| 188 |
-
if not fts_query.strip():
|
| 189 |
-
return []
|
| 190 |
-
|
| 191 |
-
with self._get_conn() as conn:
|
| 192 |
-
# Use FTS5 MATCH with BM25 ranking, filtered by user_id
|
| 193 |
-
cursor = conn.execute(
|
| 194 |
-
"""
|
| 195 |
-
SELECT m.*, bm25(memories_fts) as rank
|
| 196 |
-
FROM memories m
|
| 197 |
-
JOIN memories_fts ON m.rowid = memories_fts.rowid
|
| 198 |
-
WHERE memories_fts MATCH ? AND m.user_id = ?
|
| 199 |
-
ORDER BY rank
|
| 200 |
-
LIMIT ?
|
| 201 |
-
""",
|
| 202 |
-
(fts_query, user_id, top_k),
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
results = []
|
| 206 |
-
for row in cursor:
|
| 207 |
-
results.append(
|
| 208 |
-
Memory(
|
| 209 |
-
id=row["id"],
|
| 210 |
-
content=row["content"],
|
| 211 |
-
category=row["category"],
|
| 212 |
-
importance=row["importance"],
|
| 213 |
-
created_at=datetime.fromisoformat(row["created_at"]),
|
| 214 |
-
metadata=json.loads(row["metadata"]),
|
| 215 |
-
)
|
| 216 |
-
)
|
| 217 |
-
return results
|
| 218 |
-
|
| 219 |
-
def _sanitize_fts_query(self, query: str) -> str:
|
| 220 |
-
"""Sanitize a query for FTS5.
|
| 221 |
-
|
| 222 |
-
Escapes special characters and converts to prefix search for better matching.
|
| 223 |
-
|
| 224 |
-
Args:
|
| 225 |
-
query: Raw user query
|
| 226 |
-
|
| 227 |
-
Returns:
|
| 228 |
-
FTS5-safe query string
|
| 229 |
-
"""
|
| 230 |
-
# FTS5 special characters that need escaping
|
| 231 |
-
# We use a simple approach: extract words and use OR between them
|
| 232 |
-
import re
|
| 233 |
-
|
| 234 |
-
# Extract alphanumeric words
|
| 235 |
-
words = re.findall(r"\w+", query)
|
| 236 |
-
|
| 237 |
-
if not words:
|
| 238 |
-
return ""
|
| 239 |
-
|
| 240 |
-
# Use OR between words with prefix matching for flexibility
|
| 241 |
-
# This allows "What language" to match "Python" memories when searching
|
| 242 |
-
# by using prefix matching (word*)
|
| 243 |
-
escaped_words = []
|
| 244 |
-
for word in words:
|
| 245 |
-
# Quote each word to handle any remaining special chars
|
| 246 |
-
escaped_words.append(f'"{word}"')
|
| 247 |
-
|
| 248 |
-
return " OR ".join(escaped_words)
|
| 249 |
-
|
| 250 |
-
def get_all(self, user_id: str) -> list[Memory]:
|
| 251 |
-
"""Get all memories for a user.
|
| 252 |
-
|
| 253 |
-
Args:
|
| 254 |
-
user_id: User identifier
|
| 255 |
-
|
| 256 |
-
Returns:
|
| 257 |
-
All memories for the user, ordered by creation time (newest first)
|
| 258 |
-
"""
|
| 259 |
-
with self._get_conn() as conn:
|
| 260 |
-
cursor = conn.execute(
|
| 261 |
-
"""
|
| 262 |
-
SELECT * FROM memories
|
| 263 |
-
WHERE user_id = ?
|
| 264 |
-
ORDER BY created_at DESC
|
| 265 |
-
""",
|
| 266 |
-
(user_id,),
|
| 267 |
-
)
|
| 268 |
-
|
| 269 |
-
return [
|
| 270 |
-
Memory(
|
| 271 |
-
id=row["id"],
|
| 272 |
-
content=row["content"],
|
| 273 |
-
category=row["category"],
|
| 274 |
-
importance=row["importance"],
|
| 275 |
-
created_at=datetime.fromisoformat(row["created_at"]),
|
| 276 |
-
metadata=json.loads(row["metadata"]),
|
| 277 |
-
)
|
| 278 |
-
for row in cursor
|
| 279 |
-
]
|
| 280 |
-
|
| 281 |
-
def delete(self, user_id: str, memory_id: str) -> bool:
|
| 282 |
-
"""Delete a specific memory.
|
| 283 |
-
|
| 284 |
-
Args:
|
| 285 |
-
user_id: User identifier
|
| 286 |
-
memory_id: ID of memory to delete
|
| 287 |
-
|
| 288 |
-
Returns:
|
| 289 |
-
True if deleted, False if not found
|
| 290 |
-
"""
|
| 291 |
-
with self._get_conn() as conn:
|
| 292 |
-
cursor = conn.execute(
|
| 293 |
-
"DELETE FROM memories WHERE id = ? AND user_id = ?",
|
| 294 |
-
(memory_id, user_id),
|
| 295 |
-
)
|
| 296 |
-
conn.commit()
|
| 297 |
-
return cursor.rowcount > 0
|
| 298 |
-
|
| 299 |
-
def clear(self, user_id: str) -> int:
|
| 300 |
-
"""Delete all memories for a user.
|
| 301 |
-
|
| 302 |
-
Args:
|
| 303 |
-
user_id: User identifier
|
| 304 |
-
|
| 305 |
-
Returns:
|
| 306 |
-
Number of memories deleted
|
| 307 |
-
"""
|
| 308 |
-
with self._get_conn() as conn:
|
| 309 |
-
cursor = conn.execute(
|
| 310 |
-
"DELETE FROM memories WHERE user_id = ?",
|
| 311 |
-
(user_id,),
|
| 312 |
-
)
|
| 313 |
-
conn.commit()
|
| 314 |
-
return cursor.rowcount
|
| 315 |
-
|
| 316 |
-
def stats(self, user_id: str) -> dict:
|
| 317 |
-
"""Get memory statistics for a user.
|
| 318 |
-
|
| 319 |
-
Args:
|
| 320 |
-
user_id: User identifier
|
| 321 |
-
|
| 322 |
-
Returns:
|
| 323 |
-
Dict with count, categories breakdown, etc.
|
| 324 |
-
"""
|
| 325 |
-
with self._get_conn() as conn:
|
| 326 |
-
# Total count
|
| 327 |
-
total = conn.execute(
|
| 328 |
-
"SELECT COUNT(*) as count FROM memories WHERE user_id = ?",
|
| 329 |
-
(user_id,),
|
| 330 |
-
).fetchone()["count"]
|
| 331 |
-
|
| 332 |
-
# Category breakdown
|
| 333 |
-
categories = {}
|
| 334 |
-
for row in conn.execute(
|
| 335 |
-
"""
|
| 336 |
-
SELECT category, COUNT(*) as count
|
| 337 |
-
FROM memories WHERE user_id = ?
|
| 338 |
-
GROUP BY category
|
| 339 |
-
""",
|
| 340 |
-
(user_id,),
|
| 341 |
-
):
|
| 342 |
-
categories[row["category"]] = row["count"]
|
| 343 |
-
|
| 344 |
-
return {
|
| 345 |
-
"total": total,
|
| 346 |
-
"categories": categories,
|
| 347 |
-
}
|
| 348 |
-
|
| 349 |
-
# --- Pending Extractions (for crash recovery) ---
|
| 350 |
-
|
| 351 |
-
def queue_extraction(self, pending: PendingExtraction) -> None:
|
| 352 |
-
"""Queue a conversation for memory extraction.
|
| 353 |
-
|
| 354 |
-
Args:
|
| 355 |
-
pending: The pending extraction to queue
|
| 356 |
-
"""
|
| 357 |
-
with self._get_conn() as conn:
|
| 358 |
-
conn.execute(
|
| 359 |
-
"""
|
| 360 |
-
INSERT INTO pending_extractions (id, user_id, query, response, created_at, status)
|
| 361 |
-
VALUES (?, ?, ?, ?, ?, ?)
|
| 362 |
-
""",
|
| 363 |
-
(
|
| 364 |
-
pending.id,
|
| 365 |
-
pending.user_id,
|
| 366 |
-
pending.query,
|
| 367 |
-
pending.response,
|
| 368 |
-
pending.created_at.isoformat(),
|
| 369 |
-
pending.status,
|
| 370 |
-
),
|
| 371 |
-
)
|
| 372 |
-
conn.commit()
|
| 373 |
-
|
| 374 |
-
def get_pending_extractions(
|
| 375 |
-
self, limit: int = 10, status: str = "pending"
|
| 376 |
-
) -> list[PendingExtraction]:
|
| 377 |
-
"""Get pending extractions for processing.
|
| 378 |
-
|
| 379 |
-
Args:
|
| 380 |
-
limit: Maximum number to return
|
| 381 |
-
status: Filter by status
|
| 382 |
-
|
| 383 |
-
Returns:
|
| 384 |
-
List of pending extractions
|
| 385 |
-
"""
|
| 386 |
-
with self._get_conn() as conn:
|
| 387 |
-
cursor = conn.execute(
|
| 388 |
-
"""
|
| 389 |
-
SELECT * FROM pending_extractions
|
| 390 |
-
WHERE status = ?
|
| 391 |
-
ORDER BY created_at ASC
|
| 392 |
-
LIMIT ?
|
| 393 |
-
""",
|
| 394 |
-
(status, limit),
|
| 395 |
-
)
|
| 396 |
-
|
| 397 |
-
return [
|
| 398 |
-
PendingExtraction(
|
| 399 |
-
id=row["id"],
|
| 400 |
-
user_id=row["user_id"],
|
| 401 |
-
query=row["query"],
|
| 402 |
-
response=row["response"],
|
| 403 |
-
created_at=datetime.fromisoformat(row["created_at"]),
|
| 404 |
-
status=row["status"],
|
| 405 |
-
)
|
| 406 |
-
for row in cursor
|
| 407 |
-
]
|
| 408 |
-
|
| 409 |
-
def update_extraction_status(self, extraction_id: str, status: str) -> None:
|
| 410 |
-
"""Update the status of a pending extraction.
|
| 411 |
-
|
| 412 |
-
Args:
|
| 413 |
-
extraction_id: ID of the extraction
|
| 414 |
-
status: New status
|
| 415 |
-
"""
|
| 416 |
-
with self._get_conn() as conn:
|
| 417 |
-
conn.execute(
|
| 418 |
-
"UPDATE pending_extractions SET status = ? WHERE id = ?",
|
| 419 |
-
(status, extraction_id),
|
| 420 |
-
)
|
| 421 |
-
conn.commit()
|
| 422 |
-
|
| 423 |
-
def delete_extraction(self, extraction_id: str) -> None:
|
| 424 |
-
"""Delete a completed extraction.
|
| 425 |
-
|
| 426 |
-
Args:
|
| 427 |
-
extraction_id: ID of the extraction to delete
|
| 428 |
-
"""
|
| 429 |
-
with self._get_conn() as conn:
|
| 430 |
-
conn.execute(
|
| 431 |
-
"DELETE FROM pending_extractions WHERE id = ?",
|
| 432 |
-
(extraction_id,),
|
| 433 |
-
)
|
| 434 |
-
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,260 +0,0 @@
|
|
| 1 |
-
"""Background worker for batched memory extraction.
|
| 2 |
-
|
| 3 |
-
Collects conversations in a queue and processes them in batches,
|
| 4 |
-
reducing LLM calls and improving efficiency.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import atexit
|
| 10 |
-
import logging
|
| 11 |
-
import threading
|
| 12 |
-
import time
|
| 13 |
-
from typing import TYPE_CHECKING
|
| 14 |
-
|
| 15 |
-
if TYPE_CHECKING:
|
| 16 |
-
from headroom.memory.extractor import MemoryExtractor
|
| 17 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
logger = logging.getLogger(__name__)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class ExtractionWorker:
|
| 24 |
-
"""Background worker that batches memory extractions.
|
| 25 |
-
|
| 26 |
-
Features:
|
| 27 |
-
- Collects conversations in a queue
|
| 28 |
-
- Processes in batches (configurable size and timeout)
|
| 29 |
-
- Persists pending work to SQLite for crash recovery
|
| 30 |
-
- Thread-safe, daemon thread (stops with main program)
|
| 31 |
-
|
| 32 |
-
Usage:
|
| 33 |
-
worker = ExtractionWorker(store, extractor)
|
| 34 |
-
worker.start()
|
| 35 |
-
worker.schedule("alice", "I prefer Python", "Great choice!")
|
| 36 |
-
# ... later, memories are extracted and saved automatically
|
| 37 |
-
"""
|
| 38 |
-
|
| 39 |
-
def __init__(
|
| 40 |
-
self,
|
| 41 |
-
store: SQLiteMemoryStore,
|
| 42 |
-
extractor: MemoryExtractor,
|
| 43 |
-
batch_size: int = 10,
|
| 44 |
-
max_wait_seconds: float = 30.0,
|
| 45 |
-
):
|
| 46 |
-
"""Initialize the worker.
|
| 47 |
-
|
| 48 |
-
Args:
|
| 49 |
-
store: Memory store for saving extracted memories
|
| 50 |
-
extractor: Extractor for processing conversations
|
| 51 |
-
batch_size: Max conversations per batch
|
| 52 |
-
max_wait_seconds: Max time to wait before processing partial batch
|
| 53 |
-
"""
|
| 54 |
-
self.store = store
|
| 55 |
-
self.extractor = extractor
|
| 56 |
-
self.batch_size = batch_size
|
| 57 |
-
self.max_wait_seconds = max_wait_seconds
|
| 58 |
-
|
| 59 |
-
self._queue: list[tuple[str, str, str]] = [] # (user_id, query, response)
|
| 60 |
-
self._lock = threading.Lock()
|
| 61 |
-
self._event = threading.Event()
|
| 62 |
-
self._running = False
|
| 63 |
-
self._thread: threading.Thread | None = None
|
| 64 |
-
|
| 65 |
-
# Register cleanup on exit
|
| 66 |
-
atexit.register(self._cleanup)
|
| 67 |
-
|
| 68 |
-
def start(self) -> None:
|
| 69 |
-
"""Start the background worker thread."""
|
| 70 |
-
if self._running:
|
| 71 |
-
return
|
| 72 |
-
|
| 73 |
-
self._running = True
|
| 74 |
-
self._thread = threading.Thread(target=self._run, daemon=True)
|
| 75 |
-
self._thread.start()
|
| 76 |
-
|
| 77 |
-
# Process any pending extractions from previous runs (crash recovery)
|
| 78 |
-
self._recover_pending()
|
| 79 |
-
|
| 80 |
-
def stop(self, wait: bool = True, timeout: float = 5.0) -> None:
|
| 81 |
-
"""Stop the worker.
|
| 82 |
-
|
| 83 |
-
Args:
|
| 84 |
-
wait: If True, process remaining queue before stopping
|
| 85 |
-
timeout: Max time to wait for remaining work
|
| 86 |
-
"""
|
| 87 |
-
if not self._running:
|
| 88 |
-
return
|
| 89 |
-
|
| 90 |
-
self._running = False
|
| 91 |
-
self._event.set() # Wake up the thread
|
| 92 |
-
|
| 93 |
-
if wait and self._thread:
|
| 94 |
-
self._thread.join(timeout=timeout)
|
| 95 |
-
|
| 96 |
-
def schedule(self, user_id: str, query: str, response: str) -> None:
|
| 97 |
-
"""Schedule a conversation for memory extraction.
|
| 98 |
-
|
| 99 |
-
Non-blocking - returns immediately and extracts in background.
|
| 100 |
-
|
| 101 |
-
Args:
|
| 102 |
-
user_id: User identifier
|
| 103 |
-
query: User's message
|
| 104 |
-
response: Assistant's response
|
| 105 |
-
"""
|
| 106 |
-
# Persist to SQLite first (crash recovery)
|
| 107 |
-
from headroom.memory.store import PendingExtraction
|
| 108 |
-
|
| 109 |
-
pending = PendingExtraction(
|
| 110 |
-
user_id=user_id,
|
| 111 |
-
query=query,
|
| 112 |
-
response=response,
|
| 113 |
-
)
|
| 114 |
-
self.store.queue_extraction(pending)
|
| 115 |
-
|
| 116 |
-
# Add to in-memory queue
|
| 117 |
-
with self._lock:
|
| 118 |
-
self._queue.append((user_id, query, response))
|
| 119 |
-
|
| 120 |
-
# Wake up worker if batch is full
|
| 121 |
-
if len(self._queue) >= self.batch_size:
|
| 122 |
-
self._event.set()
|
| 123 |
-
|
| 124 |
-
def flush(self, timeout: float = 60.0) -> bool:
|
| 125 |
-
"""Force immediate processing of all queued extractions.
|
| 126 |
-
|
| 127 |
-
Blocks until all pending extractions are processed or timeout.
|
| 128 |
-
|
| 129 |
-
Args:
|
| 130 |
-
timeout: Max time to wait in seconds
|
| 131 |
-
|
| 132 |
-
Returns:
|
| 133 |
-
True if all extractions completed, False if timed out
|
| 134 |
-
"""
|
| 135 |
-
# Signal worker to process immediately by temporarily setting max_wait to 0
|
| 136 |
-
original_max_wait = self.max_wait_seconds
|
| 137 |
-
self.max_wait_seconds = 0
|
| 138 |
-
self._event.set()
|
| 139 |
-
|
| 140 |
-
# Wait for queue to empty
|
| 141 |
-
start = time.time()
|
| 142 |
-
while time.time() - start < timeout:
|
| 143 |
-
pending = self.store.get_pending_extractions(limit=1, status="pending")
|
| 144 |
-
if not pending:
|
| 145 |
-
self.max_wait_seconds = original_max_wait
|
| 146 |
-
return True
|
| 147 |
-
time.sleep(0.5)
|
| 148 |
-
|
| 149 |
-
self.max_wait_seconds = original_max_wait
|
| 150 |
-
return False
|
| 151 |
-
|
| 152 |
-
def _run(self) -> None:
|
| 153 |
-
"""Main worker loop."""
|
| 154 |
-
last_process_time = time.time()
|
| 155 |
-
|
| 156 |
-
while self._running:
|
| 157 |
-
# Wait for batch to fill or timeout
|
| 158 |
-
self._event.wait(timeout=1.0)
|
| 159 |
-
self._event.clear()
|
| 160 |
-
|
| 161 |
-
now = time.time()
|
| 162 |
-
time_since_last = now - last_process_time
|
| 163 |
-
|
| 164 |
-
with self._lock:
|
| 165 |
-
should_process = len(self._queue) >= self.batch_size or (
|
| 166 |
-
self._queue and time_since_last >= self.max_wait_seconds
|
| 167 |
-
)
|
| 168 |
-
|
| 169 |
-
if should_process:
|
| 170 |
-
batch = self._queue[: self.batch_size]
|
| 171 |
-
self._queue = self._queue[self.batch_size :]
|
| 172 |
-
else:
|
| 173 |
-
batch = []
|
| 174 |
-
|
| 175 |
-
if batch:
|
| 176 |
-
self._process_batch(batch)
|
| 177 |
-
last_process_time = time.time()
|
| 178 |
-
|
| 179 |
-
# Process remaining queue on shutdown
|
| 180 |
-
with self._lock:
|
| 181 |
-
remaining = self._queue[:]
|
| 182 |
-
self._queue = []
|
| 183 |
-
|
| 184 |
-
if remaining:
|
| 185 |
-
self._process_batch(remaining)
|
| 186 |
-
|
| 187 |
-
def _process_batch(self, batch: list[tuple[str, str, str]]) -> None:
|
| 188 |
-
"""Process a batch of conversations.
|
| 189 |
-
|
| 190 |
-
Args:
|
| 191 |
-
batch: List of (user_id, query, response) tuples
|
| 192 |
-
"""
|
| 193 |
-
logger.debug(f"Processing batch of {len(batch)} conversations")
|
| 194 |
-
|
| 195 |
-
try:
|
| 196 |
-
# Extract memories
|
| 197 |
-
result = self.extractor.extract_batch(batch)
|
| 198 |
-
|
| 199 |
-
# Save memories
|
| 200 |
-
for user_id, memories in result.items():
|
| 201 |
-
for memory in memories:
|
| 202 |
-
self.store.save(user_id, memory)
|
| 203 |
-
logger.debug(f"Saved memory for {user_id}: {memory.content[:50]}...")
|
| 204 |
-
|
| 205 |
-
# Mark pending extractions as done
|
| 206 |
-
# Note: In a production system, we'd track exact IDs
|
| 207 |
-
# For simplicity, we clear pending by matching user/query/response
|
| 208 |
-
self._mark_batch_done(batch)
|
| 209 |
-
|
| 210 |
-
except Exception as e:
|
| 211 |
-
logger.error(f"Batch extraction failed: {e}")
|
| 212 |
-
self._mark_batch_failed(batch)
|
| 213 |
-
|
| 214 |
-
def _recover_pending(self) -> None:
|
| 215 |
-
"""Recover pending extractions from previous runs."""
|
| 216 |
-
pending = self.store.get_pending_extractions(limit=100, status="pending")
|
| 217 |
-
|
| 218 |
-
if not pending:
|
| 219 |
-
return
|
| 220 |
-
|
| 221 |
-
logger.info(f"Recovering {len(pending)} pending extractions")
|
| 222 |
-
|
| 223 |
-
with self._lock:
|
| 224 |
-
for p in pending:
|
| 225 |
-
self._queue.append((p.user_id, p.query, p.response))
|
| 226 |
-
|
| 227 |
-
# Trigger processing
|
| 228 |
-
self._event.set()
|
| 229 |
-
|
| 230 |
-
def _mark_batch_done(self, batch: list[tuple[str, str, str]]) -> None:
|
| 231 |
-
"""Mark batch items as completed in the pending table."""
|
| 232 |
-
# Get pending extractions and mark matching ones as done
|
| 233 |
-
pending = self.store.get_pending_extractions(limit=100)
|
| 234 |
-
|
| 235 |
-
for user_id, query, response in batch:
|
| 236 |
-
for p in pending:
|
| 237 |
-
if p.user_id == user_id and p.query == query and p.response == response:
|
| 238 |
-
self.store.delete_extraction(p.id)
|
| 239 |
-
break
|
| 240 |
-
|
| 241 |
-
def _mark_batch_failed(self, batch: list[tuple[str, str, str]]) -> None:
|
| 242 |
-
"""Mark batch items as failed in the pending table."""
|
| 243 |
-
pending = self.store.get_pending_extractions(limit=100)
|
| 244 |
-
|
| 245 |
-
for user_id, query, response in batch:
|
| 246 |
-
for p in pending:
|
| 247 |
-
if p.user_id == user_id and p.query == query and p.response == response:
|
| 248 |
-
self.store.update_extraction_status(p.id, "failed")
|
| 249 |
-
break
|
| 250 |
-
|
| 251 |
-
def _cleanup(self) -> None:
|
| 252 |
-
"""Cleanup on program exit."""
|
| 253 |
-
if self._running:
|
| 254 |
-
self.stop(wait=True, timeout=2.0)
|
| 255 |
-
|
| 256 |
-
@property
|
| 257 |
-
def queue_size(self) -> int:
|
| 258 |
-
"""Get current queue size."""
|
| 259 |
-
with self._lock:
|
| 260 |
-
return len(self._queue)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,32 +1,47 @@
|
|
| 1 |
"""Memory wrapper - the main API for Headroom Memory.
|
| 2 |
|
| 3 |
-
One-line integration:
|
| 4 |
from headroom import with_memory
|
| 5 |
client = with_memory(OpenAI(), user_id="alice")
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import copy
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Any
|
| 13 |
|
| 14 |
-
from headroom.memory.
|
| 15 |
-
from headroom.memory.
|
| 16 |
-
from headroom.memory.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class MemoryWrapper:
|
| 20 |
-
"""Wraps an LLM client to add automatic memory.
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
Intercepts chat completions to:
|
| 23 |
-
1. BEFORE: Inject relevant memories into user message
|
| 24 |
-
2.
|
|
|
|
| 25 |
|
| 26 |
-
The system prompt is
|
| 27 |
|
| 28 |
Usage:
|
| 29 |
-
client =
|
| 30 |
response = client.chat.completions.create(...)
|
| 31 |
"""
|
| 32 |
|
|
@@ -35,10 +50,12 @@ class MemoryWrapper:
|
|
| 35 |
client: Any,
|
| 36 |
user_id: str,
|
| 37 |
db_path: str | Path = "headroom_memory.db",
|
| 38 |
-
extraction_model: str | None = None,
|
| 39 |
top_k: int = 5,
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
):
|
| 43 |
"""Initialize the memory wrapper.
|
| 44 |
|
|
@@ -46,53 +63,61 @@ class MemoryWrapper:
|
|
| 46 |
client: LLM client (OpenAI, Anthropic, etc.)
|
| 47 |
user_id: User identifier for memory isolation
|
| 48 |
db_path: Path to SQLite database
|
| 49 |
-
extraction_model: Override extraction model (auto-detect if None)
|
| 50 |
top_k: Number of memories to inject
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
"""
|
| 54 |
self._client = client
|
| 55 |
self._user_id = user_id
|
|
|
|
|
|
|
| 56 |
self._top_k = top_k
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
self._worker = ExtractionWorker(
|
| 66 |
-
store=self._store,
|
| 67 |
-
extractor=self._extractor,
|
| 68 |
-
max_wait_seconds=5.0, # Process partial batches after 5s
|
| 69 |
)
|
| 70 |
-
self.
|
| 71 |
|
| 72 |
# Create wrapped chat interface
|
| 73 |
self.chat = _WrappedChat(self)
|
| 74 |
|
| 75 |
-
def
|
| 76 |
-
"""
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
|
| 88 |
@property
|
| 89 |
def memory(self) -> _MemoryAPI:
|
| 90 |
"""Direct access to memory operations."""
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
def _inject_memories(self, messages: list[dict]) -> list[dict]:
|
| 94 |
"""Inject relevant memories into messages.
|
| 95 |
|
|
|
|
| 96 |
Memories are prepended to the FIRST user message to preserve
|
| 97 |
system prompt caching.
|
| 98 |
|
|
@@ -102,7 +127,10 @@ class MemoryWrapper:
|
|
| 102 |
Returns:
|
| 103 |
New messages list with memories injected
|
| 104 |
"""
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
user_content = None
|
| 107 |
for msg in reversed(messages):
|
| 108 |
if msg.get("role") == "user":
|
|
@@ -112,20 +140,27 @@ class MemoryWrapper:
|
|
| 112 |
if not user_content:
|
| 113 |
return messages
|
| 114 |
|
| 115 |
-
# Search for relevant memories
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
if not memories:
|
| 123 |
return messages
|
| 124 |
|
| 125 |
-
# Build context block
|
| 126 |
context_lines = ["<context>"]
|
| 127 |
-
for
|
| 128 |
-
context_lines.append(f"- {
|
| 129 |
context_lines.append("</context>")
|
| 130 |
context_block = "\n".join(context_lines)
|
| 131 |
|
|
@@ -139,14 +174,42 @@ class MemoryWrapper:
|
|
| 139 |
|
| 140 |
return new_messages
|
| 141 |
|
| 142 |
-
def
|
| 143 |
-
"""
|
| 144 |
|
| 145 |
Args:
|
| 146 |
-
|
| 147 |
-
response: Assistant's response
|
| 148 |
"""
|
| 149 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
class _WrappedChat:
|
|
@@ -158,66 +221,77 @@ class _WrappedChat:
|
|
| 158 |
|
| 159 |
|
| 160 |
class _WrappedCompletions:
|
| 161 |
-
"""Wrapped completions
|
| 162 |
|
| 163 |
def __init__(self, wrapper: MemoryWrapper):
|
| 164 |
self._wrapper = wrapper
|
| 165 |
|
| 166 |
def create(self, **kwargs: Any) -> Any:
|
| 167 |
-
"""Create a chat completion with memory injection.
|
| 168 |
|
| 169 |
-
|
| 170 |
-
1.
|
| 171 |
-
2.
|
| 172 |
-
3.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
All kwargs are passed through to the underlying client.
|
| 175 |
"""
|
| 176 |
messages = kwargs.get("messages", [])
|
| 177 |
|
| 178 |
-
# 1. Inject memories into user message
|
| 179 |
enhanced_messages = self._wrapper._inject_memories(messages)
|
|
|
|
|
|
|
|
|
|
| 180 |
kwargs["messages"] = enhanced_messages
|
| 181 |
|
| 182 |
-
#
|
| 183 |
response = self._wrapper._client.chat.completions.create(**kwargs)
|
| 184 |
|
| 185 |
-
#
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
return response
|
| 189 |
-
|
| 190 |
-
def _extract_and_queue(self, original_messages: list[dict], response: Any) -> None:
|
| 191 |
-
"""Extract query and response, queue for extraction."""
|
| 192 |
-
# Get the last user message (without context injection)
|
| 193 |
-
user_query = None
|
| 194 |
-
for msg in reversed(original_messages):
|
| 195 |
-
if msg.get("role") == "user":
|
| 196 |
-
user_query = msg.get("content", "")
|
| 197 |
-
break
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
#
|
| 203 |
-
|
| 204 |
-
assistant_response = response.choices[0].message.content
|
| 205 |
-
except (AttributeError, IndexError):
|
| 206 |
-
return
|
| 207 |
|
| 208 |
-
|
| 209 |
-
self._wrapper._queue_extraction(user_query, assistant_response)
|
| 210 |
|
| 211 |
|
| 212 |
class _MemoryAPI:
|
| 213 |
"""Direct API for memory operations."""
|
| 214 |
|
| 215 |
-
def __init__(
|
| 216 |
-
self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
self._user_id = user_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
def search(self, query: str, top_k: int = 5) -> list[Memory]:
|
| 220 |
-
"""
|
| 221 |
|
| 222 |
Args:
|
| 223 |
query: Search query
|
|
@@ -226,67 +300,113 @@ class _MemoryAPI:
|
|
| 226 |
Returns:
|
| 227 |
Matching memories
|
| 228 |
"""
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
def add(
|
| 232 |
self,
|
| 233 |
content: str,
|
| 234 |
-
category: str = "fact",
|
| 235 |
importance: float = 0.5,
|
| 236 |
) -> Memory:
|
| 237 |
"""Manually add a memory.
|
| 238 |
|
| 239 |
Args:
|
| 240 |
content: Memory content
|
| 241 |
-
category: preference, fact, or context
|
| 242 |
importance: 0.0-1.0
|
| 243 |
|
| 244 |
Returns:
|
| 245 |
The created memory
|
| 246 |
"""
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
)
|
| 252 |
-
|
| 253 |
-
return memory
|
| 254 |
|
| 255 |
def get_all(self) -> list[Memory]:
|
| 256 |
"""Get all memories for this user."""
|
| 257 |
-
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
return
|
| 262 |
|
| 263 |
def clear(self) -> int:
|
| 264 |
"""Clear all memories for this user."""
|
| 265 |
-
|
|
|
|
| 266 |
|
| 267 |
def stats(self) -> dict:
|
| 268 |
"""Get memory statistics."""
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
|
| 272 |
def with_memory(
|
| 273 |
client: Any,
|
| 274 |
user_id: str,
|
| 275 |
db_path: str | Path = "headroom_memory.db",
|
| 276 |
-
extraction_model: str | None = None,
|
| 277 |
top_k: int = 5,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
**kwargs: Any,
|
| 279 |
) -> MemoryWrapper:
|
| 280 |
-
"""Wrap an LLM client to add automatic memory.
|
| 281 |
|
| 282 |
-
|
|
|
|
| 283 |
|
| 284 |
Args:
|
| 285 |
client: LLM client (OpenAI, Anthropic, Mistral, Groq, etc.)
|
| 286 |
user_id: User identifier for memory isolation
|
| 287 |
db_path: Path to SQLite database (default: headroom_memory.db)
|
| 288 |
-
extraction_model: Override extraction model (auto-detects by default)
|
| 289 |
top_k: Number of memories to inject per request (default: 5)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
**kwargs: Additional arguments passed to MemoryWrapper
|
| 291 |
|
| 292 |
Returns:
|
|
@@ -302,7 +422,7 @@ def with_memory(
|
|
| 302 |
model="gpt-4o",
|
| 303 |
messages=[{"role": "user", "content": "I prefer Python"}]
|
| 304 |
)
|
| 305 |
-
# Memory automatically extracted
|
| 306 |
|
| 307 |
# Later...
|
| 308 |
response = client.chat.completions.create(
|
|
@@ -315,7 +435,10 @@ def with_memory(
|
|
| 315 |
client=client,
|
| 316 |
user_id=user_id,
|
| 317 |
db_path=db_path,
|
| 318 |
-
extraction_model=extraction_model,
|
| 319 |
top_k=top_k,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
**kwargs,
|
| 321 |
)
|
|
|
|
| 1 |
"""Memory wrapper - the main API for Headroom Memory.
|
| 2 |
|
| 3 |
+
One-line integration with zero-latency inline extraction:
|
| 4 |
from headroom import with_memory
|
| 5 |
client = with_memory(OpenAI(), user_id="alice")
|
| 6 |
+
|
| 7 |
+
This uses the Letta/MemGPT approach - memories are extracted inline
|
| 8 |
+
as part of the LLM response, not in a separate API call.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import asyncio
|
| 14 |
import copy
|
| 15 |
+
import logging
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
| 18 |
|
| 19 |
+
from headroom.memory.config import EmbedderBackend, MemoryConfig
|
| 20 |
+
from headroom.memory.core import HierarchicalMemory
|
| 21 |
+
from headroom.memory.inline_extractor import (
|
| 22 |
+
inject_memory_instruction,
|
| 23 |
+
parse_response_with_memory,
|
| 24 |
+
)
|
| 25 |
+
from headroom.memory.models import Memory, MemoryCategory
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
|
| 30 |
class MemoryWrapper:
|
| 31 |
+
"""Wraps an LLM client to add automatic memory with zero extra latency.
|
| 32 |
+
|
| 33 |
+
Uses inline extraction (Letta-style) - memories are extracted as part
|
| 34 |
+
of the LLM response, not in a separate API call.
|
| 35 |
|
| 36 |
Intercepts chat completions to:
|
| 37 |
+
1. BEFORE: Inject relevant memories into user message (semantic search)
|
| 38 |
+
2. DURING: Memory instruction in system prompt
|
| 39 |
+
3. AFTER: Parse response to extract and store memories
|
| 40 |
|
| 41 |
+
The original system prompt is preserved for caching.
|
| 42 |
|
| 43 |
Usage:
|
| 44 |
+
client = with_memory(OpenAI(), user_id="alice")
|
| 45 |
response = client.chat.completions.create(...)
|
| 46 |
"""
|
| 47 |
|
|
|
|
| 50 |
client: Any,
|
| 51 |
user_id: str,
|
| 52 |
db_path: str | Path = "headroom_memory.db",
|
|
|
|
| 53 |
top_k: int = 5,
|
| 54 |
+
session_id: str | None = None,
|
| 55 |
+
agent_id: str | None = None,
|
| 56 |
+
embedder_backend: EmbedderBackend = EmbedderBackend.LOCAL,
|
| 57 |
+
openai_api_key: str | None = None,
|
| 58 |
+
_memory: HierarchicalMemory | None = None, # For testing
|
| 59 |
):
|
| 60 |
"""Initialize the memory wrapper.
|
| 61 |
|
|
|
|
| 63 |
client: LLM client (OpenAI, Anthropic, etc.)
|
| 64 |
user_id: User identifier for memory isolation
|
| 65 |
db_path: Path to SQLite database
|
|
|
|
| 66 |
top_k: Number of memories to inject
|
| 67 |
+
session_id: Optional session ID for session-scoped memories
|
| 68 |
+
agent_id: Optional agent ID for agent-scoped memories
|
| 69 |
+
embedder_backend: Which embedder to use (LOCAL or OPENAI)
|
| 70 |
+
openai_api_key: API key if using OpenAI embeddings
|
| 71 |
+
_memory: Override memory system (for testing)
|
| 72 |
"""
|
| 73 |
self._client = client
|
| 74 |
self._user_id = user_id
|
| 75 |
+
self._session_id = session_id
|
| 76 |
+
self._agent_id = agent_id
|
| 77 |
self._top_k = top_k
|
| 78 |
+
self._db_path = Path(db_path)
|
| 79 |
+
|
| 80 |
+
# Initialize memory system (async, so we defer)
|
| 81 |
+
self._memory = _memory
|
| 82 |
+
self._memory_config = MemoryConfig(
|
| 83 |
+
db_path=self._db_path,
|
| 84 |
+
embedder_backend=embedder_backend,
|
| 85 |
+
openai_api_key=openai_api_key,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
+
self._initialized = _memory is not None
|
| 88 |
|
| 89 |
# Create wrapped chat interface
|
| 90 |
self.chat = _WrappedChat(self)
|
| 91 |
|
| 92 |
+
def _ensure_initialized(self) -> None:
|
| 93 |
+
"""Ensure memory system is initialized (sync wrapper for async init)."""
|
| 94 |
+
if not self._initialized:
|
| 95 |
+
# Run async initialization in sync context
|
| 96 |
+
loop = asyncio.new_event_loop()
|
| 97 |
+
try:
|
| 98 |
+
self._memory = loop.run_until_complete(
|
| 99 |
+
HierarchicalMemory.create(self._memory_config)
|
| 100 |
+
)
|
| 101 |
+
self._initialized = True
|
| 102 |
+
finally:
|
| 103 |
+
loop.close()
|
| 104 |
|
| 105 |
@property
|
| 106 |
def memory(self) -> _MemoryAPI:
|
| 107 |
"""Direct access to memory operations."""
|
| 108 |
+
self._ensure_initialized()
|
| 109 |
+
assert self._memory is not None
|
| 110 |
+
return _MemoryAPI(
|
| 111 |
+
self._memory,
|
| 112 |
+
self._user_id,
|
| 113 |
+
self._session_id,
|
| 114 |
+
self._agent_id,
|
| 115 |
+
)
|
| 116 |
|
| 117 |
def _inject_memories(self, messages: list[dict]) -> list[dict]:
|
| 118 |
"""Inject relevant memories into messages.
|
| 119 |
|
| 120 |
+
Uses semantic search to find relevant memories.
|
| 121 |
Memories are prepended to the FIRST user message to preserve
|
| 122 |
system prompt caching.
|
| 123 |
|
|
|
|
| 127 |
Returns:
|
| 128 |
New messages list with memories injected
|
| 129 |
"""
|
| 130 |
+
self._ensure_initialized()
|
| 131 |
+
assert self._memory is not None
|
| 132 |
+
|
| 133 |
+
# Find the last user message for search context
|
| 134 |
user_content = None
|
| 135 |
for msg in reversed(messages):
|
| 136 |
if msg.get("role") == "user":
|
|
|
|
| 140 |
if not user_content:
|
| 141 |
return messages
|
| 142 |
|
| 143 |
+
# Search for relevant memories (async -> sync)
|
| 144 |
+
loop = asyncio.new_event_loop()
|
| 145 |
+
try:
|
| 146 |
+
memories = loop.run_until_complete(
|
| 147 |
+
self._memory.search(
|
| 148 |
+
query=str(user_content),
|
| 149 |
+
user_id=self._user_id,
|
| 150 |
+
session_id=self._session_id,
|
| 151 |
+
top_k=self._top_k,
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
finally:
|
| 155 |
+
loop.close()
|
| 156 |
|
| 157 |
if not memories:
|
| 158 |
return messages
|
| 159 |
|
| 160 |
+
# Build context block (search returns VectorSearchResult with .memory attr)
|
| 161 |
context_lines = ["<context>"]
|
| 162 |
+
for result in memories:
|
| 163 |
+
context_lines.append(f"- {result.memory.content}")
|
| 164 |
context_lines.append("</context>")
|
| 165 |
context_block = "\n".join(context_lines)
|
| 166 |
|
|
|
|
| 174 |
|
| 175 |
return new_messages
|
| 176 |
|
| 177 |
+
def _store_memories(self, memories: list[dict[str, Any]]) -> None:
|
| 178 |
+
"""Store extracted memories.
|
| 179 |
|
| 180 |
Args:
|
| 181 |
+
memories: List of memory dicts from inline extraction
|
|
|
|
| 182 |
"""
|
| 183 |
+
self._ensure_initialized()
|
| 184 |
+
assert self._memory is not None
|
| 185 |
+
|
| 186 |
+
loop = asyncio.new_event_loop()
|
| 187 |
+
try:
|
| 188 |
+
for mem in memories:
|
| 189 |
+
content = mem.get("content", "")
|
| 190 |
+
category_str = mem.get("category", "fact")
|
| 191 |
+
|
| 192 |
+
# Map string category to enum
|
| 193 |
+
category_map = {
|
| 194 |
+
"preference": MemoryCategory.PREFERENCE,
|
| 195 |
+
"fact": MemoryCategory.FACT,
|
| 196 |
+
"context": MemoryCategory.CONTEXT,
|
| 197 |
+
}
|
| 198 |
+
category = category_map.get(category_str, MemoryCategory.FACT)
|
| 199 |
+
|
| 200 |
+
if content:
|
| 201 |
+
loop.run_until_complete(
|
| 202 |
+
self._memory.add(
|
| 203 |
+
content=content,
|
| 204 |
+
user_id=self._user_id,
|
| 205 |
+
session_id=self._session_id,
|
| 206 |
+
agent_id=self._agent_id,
|
| 207 |
+
category=category,
|
| 208 |
+
importance=0.7, # Default importance for extracted memories
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
finally:
|
| 212 |
+
loop.close()
|
| 213 |
|
| 214 |
|
| 215 |
class _WrappedChat:
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
class _WrappedCompletions:
|
| 224 |
+
"""Wrapped completions with inline memory extraction."""
|
| 225 |
|
| 226 |
def __init__(self, wrapper: MemoryWrapper):
|
| 227 |
self._wrapper = wrapper
|
| 228 |
|
| 229 |
def create(self, **kwargs: Any) -> Any:
|
| 230 |
+
"""Create a chat completion with memory injection and inline extraction.
|
| 231 |
|
| 232 |
+
Flow:
|
| 233 |
+
1. Search for relevant memories (semantic)
|
| 234 |
+
2. Inject memories into user message
|
| 235 |
+
3. Add memory extraction instruction to system prompt
|
| 236 |
+
4. Forward to LLM
|
| 237 |
+
5. Parse response to extract memories
|
| 238 |
+
6. Store extracted memories
|
| 239 |
+
7. Return clean response (without memory block)
|
| 240 |
|
| 241 |
All kwargs are passed through to the underlying client.
|
| 242 |
"""
|
| 243 |
messages = kwargs.get("messages", [])
|
| 244 |
|
| 245 |
+
# 1. Inject relevant memories into user message
|
| 246 |
enhanced_messages = self._wrapper._inject_memories(messages)
|
| 247 |
+
|
| 248 |
+
# 2. Add memory extraction instruction to system prompt
|
| 249 |
+
enhanced_messages = inject_memory_instruction(enhanced_messages, short=True)
|
| 250 |
kwargs["messages"] = enhanced_messages
|
| 251 |
|
| 252 |
+
# 3. Forward to LLM
|
| 253 |
response = self._wrapper._client.chat.completions.create(**kwargs)
|
| 254 |
|
| 255 |
+
# 4. Parse response and extract memories
|
| 256 |
+
raw_content = response.choices[0].message.content
|
| 257 |
+
parsed = parse_response_with_memory(raw_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
+
# 5. Store extracted memories
|
| 260 |
+
if parsed.memories:
|
| 261 |
+
self._wrapper._store_memories(parsed.memories)
|
| 262 |
+
logger.debug(f"Extracted and stored {len(parsed.memories)} memories")
|
| 263 |
|
| 264 |
+
# 6. Return clean response (modify in place)
|
| 265 |
+
response.choices[0].message.content = parsed.content
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
+
return response
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
class _MemoryAPI:
|
| 271 |
"""Direct API for memory operations."""
|
| 272 |
|
| 273 |
+
def __init__(
|
| 274 |
+
self,
|
| 275 |
+
memory: HierarchicalMemory,
|
| 276 |
+
user_id: str,
|
| 277 |
+
session_id: str | None = None,
|
| 278 |
+
agent_id: str | None = None,
|
| 279 |
+
):
|
| 280 |
+
self._memory = memory
|
| 281 |
self._user_id = user_id
|
| 282 |
+
self._session_id = session_id
|
| 283 |
+
self._agent_id = agent_id
|
| 284 |
+
|
| 285 |
+
def _run_async(self, coro: Any) -> Any:
|
| 286 |
+
"""Run async coroutine in sync context."""
|
| 287 |
+
loop = asyncio.new_event_loop()
|
| 288 |
+
try:
|
| 289 |
+
return loop.run_until_complete(coro)
|
| 290 |
+
finally:
|
| 291 |
+
loop.close()
|
| 292 |
|
| 293 |
def search(self, query: str, top_k: int = 5) -> list[Memory]:
|
| 294 |
+
"""Semantic search for memories.
|
| 295 |
|
| 296 |
Args:
|
| 297 |
query: Search query
|
|
|
|
| 300 |
Returns:
|
| 301 |
Matching memories
|
| 302 |
"""
|
| 303 |
+
results = self._run_async(
|
| 304 |
+
self._memory.search(
|
| 305 |
+
query=query,
|
| 306 |
+
user_id=self._user_id,
|
| 307 |
+
session_id=self._session_id,
|
| 308 |
+
top_k=top_k,
|
| 309 |
+
)
|
| 310 |
+
)
|
| 311 |
+
# Extract Memory objects from VectorSearchResult
|
| 312 |
+
return [r.memory for r in results]
|
| 313 |
|
| 314 |
def add(
|
| 315 |
self,
|
| 316 |
content: str,
|
| 317 |
+
category: str | MemoryCategory = "fact",
|
| 318 |
importance: float = 0.5,
|
| 319 |
) -> Memory:
|
| 320 |
"""Manually add a memory.
|
| 321 |
|
| 322 |
Args:
|
| 323 |
content: Memory content
|
| 324 |
+
category: preference, fact, or context (or MemoryCategory enum)
|
| 325 |
importance: 0.0-1.0
|
| 326 |
|
| 327 |
Returns:
|
| 328 |
The created memory
|
| 329 |
"""
|
| 330 |
+
# Map string category to enum
|
| 331 |
+
cat_enum: MemoryCategory
|
| 332 |
+
if isinstance(category, str):
|
| 333 |
+
category_map: dict[str, MemoryCategory] = {
|
| 334 |
+
"preference": MemoryCategory.PREFERENCE,
|
| 335 |
+
"fact": MemoryCategory.FACT,
|
| 336 |
+
"context": MemoryCategory.CONTEXT,
|
| 337 |
+
"episodic": MemoryCategory.CONTEXT,
|
| 338 |
+
"entity": MemoryCategory.ENTITY,
|
| 339 |
+
"decision": MemoryCategory.DECISION,
|
| 340 |
+
"insight": MemoryCategory.INSIGHT,
|
| 341 |
+
}
|
| 342 |
+
cat_enum = category_map.get(category, MemoryCategory.FACT)
|
| 343 |
+
else:
|
| 344 |
+
cat_enum = category
|
| 345 |
+
|
| 346 |
+
result: Memory = self._run_async(
|
| 347 |
+
self._memory.add(
|
| 348 |
+
content=content,
|
| 349 |
+
user_id=self._user_id,
|
| 350 |
+
session_id=self._session_id,
|
| 351 |
+
agent_id=self._agent_id,
|
| 352 |
+
category=cat_enum,
|
| 353 |
+
importance=importance,
|
| 354 |
+
)
|
| 355 |
)
|
| 356 |
+
return result
|
|
|
|
| 357 |
|
| 358 |
def get_all(self) -> list[Memory]:
|
| 359 |
"""Get all memories for this user."""
|
| 360 |
+
from headroom.memory.ports import MemoryFilter
|
| 361 |
|
| 362 |
+
filter = MemoryFilter(user_id=self._user_id)
|
| 363 |
+
memories: list[Memory] = self._run_async(self._memory.query(filter))
|
| 364 |
+
return memories
|
| 365 |
|
| 366 |
def clear(self) -> int:
|
| 367 |
"""Clear all memories for this user."""
|
| 368 |
+
count: int = self._run_async(self._memory.clear_scope(user_id=self._user_id))
|
| 369 |
+
return count
|
| 370 |
|
| 371 |
def stats(self) -> dict:
|
| 372 |
"""Get memory statistics."""
|
| 373 |
+
memories = self.get_all()
|
| 374 |
+
categories: dict[str, int] = {}
|
| 375 |
+
for mem in memories:
|
| 376 |
+
cat = mem.category.value if hasattr(mem.category, "value") else str(mem.category)
|
| 377 |
+
categories[cat] = categories.get(cat, 0) + 1
|
| 378 |
+
|
| 379 |
+
return {
|
| 380 |
+
"total": len(memories),
|
| 381 |
+
"categories": categories,
|
| 382 |
+
}
|
| 383 |
|
| 384 |
|
| 385 |
def with_memory(
|
| 386 |
client: Any,
|
| 387 |
user_id: str,
|
| 388 |
db_path: str | Path = "headroom_memory.db",
|
|
|
|
| 389 |
top_k: int = 5,
|
| 390 |
+
session_id: str | None = None,
|
| 391 |
+
agent_id: str | None = None,
|
| 392 |
+
embedder_backend: EmbedderBackend = EmbedderBackend.LOCAL,
|
| 393 |
+
openai_api_key: str | None = None,
|
| 394 |
**kwargs: Any,
|
| 395 |
) -> MemoryWrapper:
|
| 396 |
+
"""Wrap an LLM client to add automatic memory with zero extra latency.
|
| 397 |
|
| 398 |
+
Uses inline extraction (Letta-style) - memories are extracted as part
|
| 399 |
+
of the LLM response, not in a separate API call.
|
| 400 |
|
| 401 |
Args:
|
| 402 |
client: LLM client (OpenAI, Anthropic, Mistral, Groq, etc.)
|
| 403 |
user_id: User identifier for memory isolation
|
| 404 |
db_path: Path to SQLite database (default: headroom_memory.db)
|
|
|
|
| 405 |
top_k: Number of memories to inject per request (default: 5)
|
| 406 |
+
session_id: Optional session ID for session-scoped memories
|
| 407 |
+
agent_id: Optional agent ID for agent-scoped memories
|
| 408 |
+
embedder_backend: Which embedder to use (LOCAL or OPENAI)
|
| 409 |
+
openai_api_key: API key if using OpenAI embeddings
|
| 410 |
**kwargs: Additional arguments passed to MemoryWrapper
|
| 411 |
|
| 412 |
Returns:
|
|
|
|
| 422 |
model="gpt-4o",
|
| 423 |
messages=[{"role": "user", "content": "I prefer Python"}]
|
| 424 |
)
|
| 425 |
+
# Memory automatically extracted INLINE (zero extra latency!)
|
| 426 |
|
| 427 |
# Later...
|
| 428 |
response = client.chat.completions.create(
|
|
|
|
| 435 |
client=client,
|
| 436 |
user_id=user_id,
|
| 437 |
db_path=db_path,
|
|
|
|
| 438 |
top_k=top_k,
|
| 439 |
+
session_id=session_id,
|
| 440 |
+
agent_id=agent_id,
|
| 441 |
+
embedder_backend=embedder_backend,
|
| 442 |
+
openai_api_key=openai_api_key,
|
| 443 |
**kwargs,
|
| 444 |
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "headroom-ai"
|
| 7 |
-
version = "0.
|
| 8 |
description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
|
| 9 |
readme = "README.md"
|
| 10 |
license = "Apache-2.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "headroom-ai"
|
| 7 |
+
version = "0.3.0"
|
| 8 |
description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
|
| 9 |
readme = "README.md"
|
| 10 |
license = "Apache-2.0"
|
|
@@ -1,5 +1,11 @@
|
|
| 1 |
"""Shared pytest fixtures for Headroom tests."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import json
|
| 4 |
import tempfile
|
| 5 |
from datetime import datetime
|
|
|
|
| 1 |
"""Shared pytest fixtures for Headroom tests."""
|
| 2 |
|
| 3 |
+
# CRITICAL: Must be set before ANY imports that could trigger sentence_transformers
|
| 4 |
+
# The Rust tokenizers use parallelism that deadlocks with pytest-asyncio
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 8 |
+
|
| 9 |
import json
|
| 10 |
import tempfile
|
| 11 |
from datetime import datetime
|
|
@@ -1,147 +1,8 @@
|
|
| 1 |
-
"""Test fixtures for Headroom Memory.
|
| 2 |
-
|
| 3 |
-
Philosophy: Mock at boundaries, not internals.
|
| 4 |
-
- SQLite: REAL (local, fast, no side effects)
|
| 5 |
-
- LLM clients: MOCKED (external dependency)
|
| 6 |
-
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
from typing import Any
|
| 13 |
-
|
| 14 |
-
import pytest
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
@pytest.fixture
|
| 18 |
-
def temp_db():
|
| 19 |
-
"""Fresh SQLite DB for each test - REAL database, auto-cleanup."""
|
| 20 |
-
with tempfile.TemporaryDirectory() as tmpdir:
|
| 21 |
-
db_path = Path(tmpdir) / "test_memory.db"
|
| 22 |
-
yield db_path
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
@pytest.fixture
|
| 26 |
-
def memory_store(temp_db):
|
| 27 |
-
"""Real SQLite memory store."""
|
| 28 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 29 |
-
|
| 30 |
-
return SQLiteMemoryStore(temp_db)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
@pytest.fixture
|
| 34 |
-
def mock_extractor():
|
| 35 |
-
"""Extractor with controllable responses - for testing worker/wrapper."""
|
| 36 |
-
from headroom.memory.store import Memory
|
| 37 |
-
|
| 38 |
-
class MockExtractor:
|
| 39 |
-
def __init__(self):
|
| 40 |
-
self.calls: list[tuple[str, str]] = []
|
| 41 |
-
self.batch_calls: list[list[tuple[str, str, str]]] = []
|
| 42 |
-
self._response: list[Memory] = []
|
| 43 |
-
self._batch_response: dict[str, list[Memory]] = {}
|
| 44 |
-
|
| 45 |
-
def set_response(self, memories: list[Memory]) -> None:
|
| 46 |
-
self._response = memories
|
| 47 |
-
|
| 48 |
-
def set_batch_response(self, response: dict[str, list[Memory]]) -> None:
|
| 49 |
-
self._batch_response = response
|
| 50 |
-
|
| 51 |
-
def extract(self, query: str, response: str) -> list[Memory]:
|
| 52 |
-
self.calls.append((query, response))
|
| 53 |
-
return self._response
|
| 54 |
-
|
| 55 |
-
def extract_batch(
|
| 56 |
-
self, conversations: list[tuple[str, str, str]]
|
| 57 |
-
) -> dict[str, list[Memory]]:
|
| 58 |
-
self.batch_calls.append(conversations)
|
| 59 |
-
return self._batch_response
|
| 60 |
-
|
| 61 |
-
return MockExtractor()
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
@pytest.fixture
|
| 65 |
-
def mock_openai_client():
|
| 66 |
-
"""Fake OpenAI client - for testing wrapper without API calls."""
|
| 67 |
-
|
| 68 |
-
class MockMessage:
|
| 69 |
-
def __init__(self, content: str):
|
| 70 |
-
self.content = content
|
| 71 |
-
|
| 72 |
-
class MockChoice:
|
| 73 |
-
def __init__(self, content: str):
|
| 74 |
-
self.message = MockMessage(content)
|
| 75 |
-
|
| 76 |
-
class MockResponse:
|
| 77 |
-
def __init__(self, content: str = "Hello!"):
|
| 78 |
-
self.choices = [MockChoice(content)]
|
| 79 |
-
|
| 80 |
-
class MockCompletions:
|
| 81 |
-
def __init__(self):
|
| 82 |
-
self.calls: list[dict[str, Any]] = []
|
| 83 |
-
self._response = MockResponse()
|
| 84 |
-
|
| 85 |
-
def set_response(self, content: str) -> None:
|
| 86 |
-
self._response = MockResponse(content)
|
| 87 |
-
|
| 88 |
-
def create(self, **kwargs: Any) -> MockResponse:
|
| 89 |
-
self.calls.append(kwargs)
|
| 90 |
-
return self._response
|
| 91 |
-
|
| 92 |
-
class MockChat:
|
| 93 |
-
def __init__(self):
|
| 94 |
-
self.completions = MockCompletions()
|
| 95 |
-
|
| 96 |
-
class MockClient:
|
| 97 |
-
"""Mock OpenAI client."""
|
| 98 |
-
|
| 99 |
-
def __init__(self):
|
| 100 |
-
self.chat = MockChat()
|
| 101 |
-
|
| 102 |
-
return MockClient()
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
@pytest.fixture
|
| 106 |
-
def mock_anthropic_client():
|
| 107 |
-
"""Fake Anthropic client - for testing wrapper without API calls."""
|
| 108 |
-
|
| 109 |
-
class MockTextBlock:
|
| 110 |
-
def __init__(self, text: str):
|
| 111 |
-
self.text = text
|
| 112 |
-
|
| 113 |
-
class MockResponse:
|
| 114 |
-
def __init__(self, content: str = "Hello!"):
|
| 115 |
-
self.content = [MockTextBlock(content)]
|
| 116 |
-
|
| 117 |
-
class MockMessages:
|
| 118 |
-
def __init__(self):
|
| 119 |
-
self.calls: list[dict[str, Any]] = []
|
| 120 |
-
self._response = MockResponse()
|
| 121 |
-
|
| 122 |
-
def set_response(self, content: str) -> None:
|
| 123 |
-
self._response = MockResponse(content)
|
| 124 |
-
|
| 125 |
-
def create(self, **kwargs: Any) -> MockResponse:
|
| 126 |
-
self.calls.append(kwargs)
|
| 127 |
-
return self._response
|
| 128 |
-
|
| 129 |
-
class MockClient:
|
| 130 |
-
"""Mock Anthropic client."""
|
| 131 |
-
|
| 132 |
-
def __init__(self):
|
| 133 |
-
self.messages = MockMessages()
|
| 134 |
-
|
| 135 |
-
return MockClient()
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
@pytest.fixture
|
| 139 |
-
def sample_memories():
|
| 140 |
-
"""Sample memories for testing."""
|
| 141 |
-
from headroom.memory.store import Memory
|
| 142 |
|
| 143 |
-
|
| 144 |
-
Memory(content="User prefers Python", category="preference", importance=0.8),
|
| 145 |
-
Memory(content="User works at a startup", category="fact", importance=0.7),
|
| 146 |
-
Memory(content="User is building an AI agent", category="context", importance=0.6),
|
| 147 |
-
]
|
|
|
|
| 1 |
+
"""Test fixtures for Headroom Memory."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
# CRITICAL: Must be set before ANY imports that could trigger sentence_transformers
|
| 6 |
+
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,337 +0,0 @@
|
|
| 1 |
-
"""Tests for memory extractor.
|
| 2 |
-
|
| 3 |
-
Mocks LLM HTTP responses to test extraction logic without external calls.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
from unittest.mock import MagicMock
|
| 9 |
-
|
| 10 |
-
from headroom.memory.extractor import (
|
| 11 |
-
CHEAP_MODELS,
|
| 12 |
-
MemoryExtractor,
|
| 13 |
-
detect_provider,
|
| 14 |
-
get_cheap_model,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class TestProviderDetection:
|
| 19 |
-
"""Test provider detection from client class."""
|
| 20 |
-
|
| 21 |
-
def test_detect_openai(self):
|
| 22 |
-
"""Detect OpenAI from module path."""
|
| 23 |
-
mock_client = MagicMock()
|
| 24 |
-
mock_client.__class__.__module__ = "openai.resources.chat"
|
| 25 |
-
|
| 26 |
-
result = detect_provider(mock_client)
|
| 27 |
-
|
| 28 |
-
assert result == "openai"
|
| 29 |
-
|
| 30 |
-
def test_detect_anthropic(self):
|
| 31 |
-
"""Detect Anthropic from module path."""
|
| 32 |
-
mock_client = MagicMock()
|
| 33 |
-
mock_client.__class__.__module__ = "anthropic.resources"
|
| 34 |
-
|
| 35 |
-
result = detect_provider(mock_client)
|
| 36 |
-
|
| 37 |
-
assert result == "anthropic"
|
| 38 |
-
|
| 39 |
-
def test_detect_groq(self):
|
| 40 |
-
"""Detect Groq from module path."""
|
| 41 |
-
mock_client = MagicMock()
|
| 42 |
-
mock_client.__class__.__module__ = "groq.resources"
|
| 43 |
-
|
| 44 |
-
result = detect_provider(mock_client)
|
| 45 |
-
|
| 46 |
-
assert result == "groq"
|
| 47 |
-
|
| 48 |
-
def test_detect_together(self):
|
| 49 |
-
"""Detect Together from module path."""
|
| 50 |
-
mock_client = MagicMock()
|
| 51 |
-
mock_client.__class__.__module__ = "together.client"
|
| 52 |
-
|
| 53 |
-
result = detect_provider(mock_client)
|
| 54 |
-
|
| 55 |
-
assert result == "together"
|
| 56 |
-
|
| 57 |
-
def test_detect_fireworks(self):
|
| 58 |
-
"""Detect Fireworks from module path."""
|
| 59 |
-
mock_client = MagicMock()
|
| 60 |
-
mock_client.__class__.__module__ = "fireworks.client"
|
| 61 |
-
|
| 62 |
-
result = detect_provider(mock_client)
|
| 63 |
-
|
| 64 |
-
assert result == "fireworks"
|
| 65 |
-
|
| 66 |
-
def test_detect_mistralai(self):
|
| 67 |
-
"""Detect Mistral from module path."""
|
| 68 |
-
mock_client = MagicMock()
|
| 69 |
-
mock_client.__class__.__module__ = "mistralai.client"
|
| 70 |
-
|
| 71 |
-
result = detect_provider(mock_client)
|
| 72 |
-
|
| 73 |
-
assert result == "mistralai"
|
| 74 |
-
|
| 75 |
-
def test_detect_cohere(self):
|
| 76 |
-
"""Detect Cohere from module path."""
|
| 77 |
-
mock_client = MagicMock()
|
| 78 |
-
mock_client.__class__.__module__ = "cohere.client"
|
| 79 |
-
|
| 80 |
-
result = detect_provider(mock_client)
|
| 81 |
-
|
| 82 |
-
assert result == "cohere"
|
| 83 |
-
|
| 84 |
-
def test_detect_google(self):
|
| 85 |
-
"""Detect Google from module path."""
|
| 86 |
-
mock_client = MagicMock()
|
| 87 |
-
mock_client.__class__.__module__ = "google.generativeai"
|
| 88 |
-
|
| 89 |
-
result = detect_provider(mock_client)
|
| 90 |
-
|
| 91 |
-
assert result == "google"
|
| 92 |
-
|
| 93 |
-
def test_detect_unknown_returns_none(self):
|
| 94 |
-
"""Unknown provider returns None."""
|
| 95 |
-
mock_client = MagicMock()
|
| 96 |
-
mock_client.__class__.__module__ = "some.unknown.provider"
|
| 97 |
-
|
| 98 |
-
result = detect_provider(mock_client)
|
| 99 |
-
|
| 100 |
-
assert result is None
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
class TestCheapModelMapping:
|
| 104 |
-
"""Test cheap model selection."""
|
| 105 |
-
|
| 106 |
-
def test_all_providers_have_models(self):
|
| 107 |
-
"""All expected providers have cheap models defined."""
|
| 108 |
-
expected_providers = [
|
| 109 |
-
"openai",
|
| 110 |
-
"anthropic",
|
| 111 |
-
"mistralai",
|
| 112 |
-
"groq",
|
| 113 |
-
"together",
|
| 114 |
-
"fireworks",
|
| 115 |
-
"google",
|
| 116 |
-
"cohere",
|
| 117 |
-
]
|
| 118 |
-
|
| 119 |
-
for provider in expected_providers:
|
| 120 |
-
assert provider in CHEAP_MODELS, f"Missing model for {provider}"
|
| 121 |
-
assert CHEAP_MODELS[provider], f"Empty model for {provider}"
|
| 122 |
-
|
| 123 |
-
def test_get_cheap_model_returns_correct_model(self):
|
| 124 |
-
"""get_cheap_model returns correct model for provider."""
|
| 125 |
-
assert get_cheap_model("openai") == "gpt-4o-mini"
|
| 126 |
-
assert get_cheap_model("anthropic") == "claude-3-5-haiku-latest"
|
| 127 |
-
assert get_cheap_model("groq") == "llama-3.3-70b-versatile"
|
| 128 |
-
|
| 129 |
-
def test_get_cheap_model_unknown_returns_none(self):
|
| 130 |
-
"""Unknown provider returns None."""
|
| 131 |
-
assert get_cheap_model("unknown") is None
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
class TestMemoryExtractorInit:
|
| 135 |
-
"""Test extractor initialization."""
|
| 136 |
-
|
| 137 |
-
def test_auto_detects_provider_and_model(self, mock_openai_client):
|
| 138 |
-
"""Extractor auto-detects provider and selects cheap model."""
|
| 139 |
-
# Mock the module path
|
| 140 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 141 |
-
|
| 142 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 143 |
-
|
| 144 |
-
assert extractor.provider == "openai"
|
| 145 |
-
assert extractor.model == "gpt-4o-mini"
|
| 146 |
-
|
| 147 |
-
def test_explicit_model_overrides_auto(self, mock_openai_client):
|
| 148 |
-
"""Explicit model parameter overrides auto-detection."""
|
| 149 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 150 |
-
|
| 151 |
-
extractor = MemoryExtractor(mock_openai_client, model="gpt-4-turbo")
|
| 152 |
-
|
| 153 |
-
assert extractor.model == "gpt-4-turbo"
|
| 154 |
-
|
| 155 |
-
def test_unknown_provider_warns(self, mock_openai_client, caplog):
|
| 156 |
-
"""Unknown provider logs warning."""
|
| 157 |
-
mock_openai_client.__class__.__module__ = "unknown.provider"
|
| 158 |
-
|
| 159 |
-
with caplog.at_level("WARNING"):
|
| 160 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 161 |
-
|
| 162 |
-
assert extractor.model is None
|
| 163 |
-
assert "Could not detect cheap model" in caplog.text
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
class TestMemoryExtraction:
|
| 167 |
-
"""Test memory extraction from conversations."""
|
| 168 |
-
|
| 169 |
-
def test_extracts_preference(self, mock_openai_client):
|
| 170 |
-
"""Extracts preference from conversation."""
|
| 171 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 172 |
-
|
| 173 |
-
# Mock the LLM response
|
| 174 |
-
mock_openai_client.chat.completions.set_response(
|
| 175 |
-
'{"memories": [{"content": "Prefers Python", "category": "preference", "importance": 0.8}], "should_remember": true}'
|
| 176 |
-
)
|
| 177 |
-
|
| 178 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 179 |
-
memories = extractor.extract(
|
| 180 |
-
"I really prefer Python for data science",
|
| 181 |
-
"Great choice! Python is excellent for data science.",
|
| 182 |
-
)
|
| 183 |
-
|
| 184 |
-
assert len(memories) == 1
|
| 185 |
-
assert memories[0].content == "Prefers Python"
|
| 186 |
-
assert memories[0].category == "preference"
|
| 187 |
-
assert memories[0].importance == 0.8
|
| 188 |
-
|
| 189 |
-
def test_extracts_multiple_memories(self, mock_openai_client):
|
| 190 |
-
"""Extracts multiple memories from one conversation."""
|
| 191 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 192 |
-
|
| 193 |
-
mock_openai_client.chat.completions.set_response(
|
| 194 |
-
'{"memories": ['
|
| 195 |
-
'{"content": "Works at a startup", "category": "fact", "importance": 0.7},'
|
| 196 |
-
'{"content": "Building an AI agent", "category": "context", "importance": 0.6}'
|
| 197 |
-
'], "should_remember": true}'
|
| 198 |
-
)
|
| 199 |
-
|
| 200 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 201 |
-
memories = extractor.extract(
|
| 202 |
-
"I work at a startup building an AI agent",
|
| 203 |
-
"That sounds exciting!",
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
assert len(memories) == 2
|
| 207 |
-
assert memories[0].content == "Works at a startup"
|
| 208 |
-
assert memories[1].content == "Building an AI agent"
|
| 209 |
-
|
| 210 |
-
def test_skips_trivial_conversation(self, mock_openai_client):
|
| 211 |
-
"""Returns empty for trivial conversations."""
|
| 212 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 213 |
-
|
| 214 |
-
mock_openai_client.chat.completions.set_response(
|
| 215 |
-
'{"memories": [], "should_remember": false}'
|
| 216 |
-
)
|
| 217 |
-
|
| 218 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 219 |
-
memories = extractor.extract("Hello", "Hi there!")
|
| 220 |
-
|
| 221 |
-
assert len(memories) == 0
|
| 222 |
-
|
| 223 |
-
def test_handles_json_in_code_block(self, mock_openai_client):
|
| 224 |
-
"""Parses JSON wrapped in markdown code block."""
|
| 225 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 226 |
-
|
| 227 |
-
mock_openai_client.chat.completions.set_response(
|
| 228 |
-
'```json\n{"memories": [{"content": "Likes vim", "category": "preference"}], "should_remember": true}\n```'
|
| 229 |
-
)
|
| 230 |
-
|
| 231 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 232 |
-
memories = extractor.extract("I use vim", "Nice!")
|
| 233 |
-
|
| 234 |
-
assert len(memories) == 1
|
| 235 |
-
assert memories[0].content == "Likes vim"
|
| 236 |
-
|
| 237 |
-
def test_handles_malformed_json(self, mock_openai_client, caplog):
|
| 238 |
-
"""Gracefully handles malformed JSON."""
|
| 239 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 240 |
-
|
| 241 |
-
mock_openai_client.chat.completions.set_response("not valid json")
|
| 242 |
-
|
| 243 |
-
with caplog.at_level("WARNING"):
|
| 244 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 245 |
-
memories = extractor.extract("test", "test")
|
| 246 |
-
|
| 247 |
-
assert len(memories) == 0
|
| 248 |
-
assert "Failed to parse" in caplog.text
|
| 249 |
-
|
| 250 |
-
def test_no_extraction_without_model(self, mock_openai_client, caplog):
|
| 251 |
-
"""Skips extraction if no model configured."""
|
| 252 |
-
mock_openai_client.__class__.__module__ = "unknown.provider"
|
| 253 |
-
|
| 254 |
-
with caplog.at_level("WARNING"):
|
| 255 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 256 |
-
memories = extractor.extract("test", "test")
|
| 257 |
-
|
| 258 |
-
assert len(memories) == 0
|
| 259 |
-
assert "No extraction model" in caplog.text
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
class TestBatchExtraction:
|
| 263 |
-
"""Test batch extraction."""
|
| 264 |
-
|
| 265 |
-
def test_batch_extracts_for_multiple_users(self, mock_openai_client):
|
| 266 |
-
"""Batch extraction returns memories per user."""
|
| 267 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 268 |
-
|
| 269 |
-
mock_openai_client.chat.completions.set_response(
|
| 270 |
-
'{"alice": {"memories": [{"content": "Likes Python", "category": "preference"}], "should_remember": true},'
|
| 271 |
-
'"bob": {"memories": [{"content": "Likes Java", "category": "preference"}], "should_remember": true}}'
|
| 272 |
-
)
|
| 273 |
-
|
| 274 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 275 |
-
result = extractor.extract_batch(
|
| 276 |
-
[
|
| 277 |
-
("alice", "I like Python", "Great!"),
|
| 278 |
-
("bob", "I like Java", "Nice!"),
|
| 279 |
-
]
|
| 280 |
-
)
|
| 281 |
-
|
| 282 |
-
assert "alice" in result
|
| 283 |
-
assert "bob" in result
|
| 284 |
-
assert result["alice"][0].content == "Likes Python"
|
| 285 |
-
assert result["bob"][0].content == "Likes Java"
|
| 286 |
-
|
| 287 |
-
def test_batch_empty_input_returns_empty(self, mock_openai_client):
|
| 288 |
-
"""Empty batch returns empty dict."""
|
| 289 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 290 |
-
|
| 291 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 292 |
-
result = extractor.extract_batch([])
|
| 293 |
-
|
| 294 |
-
assert result == {}
|
| 295 |
-
|
| 296 |
-
def test_batch_handles_partial_results(self, mock_openai_client):
|
| 297 |
-
"""Batch handles some users with no memories."""
|
| 298 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 299 |
-
|
| 300 |
-
mock_openai_client.chat.completions.set_response(
|
| 301 |
-
'{"alice": {"memories": [{"content": "Fact", "category": "fact"}], "should_remember": true},'
|
| 302 |
-
'"bob": {"memories": [], "should_remember": false}}'
|
| 303 |
-
)
|
| 304 |
-
|
| 305 |
-
extractor = MemoryExtractor(mock_openai_client)
|
| 306 |
-
result = extractor.extract_batch(
|
| 307 |
-
[
|
| 308 |
-
("alice", "Important info", "Noted!"),
|
| 309 |
-
("bob", "Hello", "Hi!"),
|
| 310 |
-
]
|
| 311 |
-
)
|
| 312 |
-
|
| 313 |
-
assert "alice" in result
|
| 314 |
-
assert "bob" not in result # No memories to remember
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
class TestAnthropicProvider:
|
| 318 |
-
"""Test Anthropic-specific API handling."""
|
| 319 |
-
|
| 320 |
-
def test_anthropic_uses_messages_api(self, mock_anthropic_client):
|
| 321 |
-
"""Anthropic uses messages.create API."""
|
| 322 |
-
mock_anthropic_client.__class__.__module__ = "anthropic.resources"
|
| 323 |
-
|
| 324 |
-
mock_anthropic_client.messages.set_response(
|
| 325 |
-
'{"memories": [{"content": "Test", "category": "fact"}], "should_remember": true}'
|
| 326 |
-
)
|
| 327 |
-
|
| 328 |
-
extractor = MemoryExtractor(mock_anthropic_client)
|
| 329 |
-
memories = extractor.extract("test query", "test response")
|
| 330 |
-
|
| 331 |
-
assert len(memories) == 1
|
| 332 |
-
assert memories[0].content == "Test"
|
| 333 |
-
|
| 334 |
-
# Verify Anthropic API was called
|
| 335 |
-
assert len(mock_anthropic_client.messages.calls) == 1
|
| 336 |
-
call = mock_anthropic_client.messages.calls[0]
|
| 337 |
-
assert call["model"] == "claude-3-5-haiku-latest"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,804 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the hierarchical memory system.
|
| 2 |
+
|
| 3 |
+
Tests cover:
|
| 4 |
+
- Memory models (Memory, ScopeLevel, MemoryCategory)
|
| 5 |
+
- SQLite memory store
|
| 6 |
+
- HNSW vector index
|
| 7 |
+
- FTS5 text index
|
| 8 |
+
- LRU cache
|
| 9 |
+
- HierarchicalMemory orchestrator
|
| 10 |
+
- Memory bubbling
|
| 11 |
+
- Temporal versioning (supersession)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# CRITICAL: Must set TOKENIZERS_PARALLELISM before any imports that might
|
| 15 |
+
# trigger sentence_transformers/transformers loading. The Rust tokenizers
|
| 16 |
+
# use parallelism that conflicts with Python's forking model, causing
|
| 17 |
+
# deadlocks when combined with asyncio/pytest.
|
| 18 |
+
# See: https://github.com/huggingface/transformers/issues/5486
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 22 |
+
|
| 23 |
+
import asyncio
|
| 24 |
+
import tempfile
|
| 25 |
+
from datetime import datetime, timedelta
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pytest
|
| 30 |
+
|
| 31 |
+
from headroom.memory.adapters.cache import LRUMemoryCache
|
| 32 |
+
from headroom.memory.adapters.fts5 import FTS5TextIndex
|
| 33 |
+
from headroom.memory.adapters.sqlite import SQLiteMemoryStore
|
| 34 |
+
from headroom.memory.models import Memory, MemoryCategory, ScopeLevel
|
| 35 |
+
from headroom.memory.ports import MemoryFilter, TextFilter, VectorFilter
|
| 36 |
+
|
| 37 |
+
# =============================================================================
|
| 38 |
+
# Fixtures
|
| 39 |
+
# =============================================================================
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def temp_db_path():
|
| 44 |
+
"""Create a temporary database path."""
|
| 45 |
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
| 46 |
+
yield Path(f.name)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def sample_memory():
|
| 51 |
+
"""Create a sample memory for testing."""
|
| 52 |
+
return Memory(
|
| 53 |
+
content="User prefers Python over JavaScript",
|
| 54 |
+
user_id="alice",
|
| 55 |
+
session_id="session-123",
|
| 56 |
+
category=MemoryCategory.PREFERENCE,
|
| 57 |
+
importance=0.8,
|
| 58 |
+
entity_refs=["Python", "JavaScript"],
|
| 59 |
+
metadata={"source": "conversation"},
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.fixture
|
| 64 |
+
def sample_embedding():
|
| 65 |
+
"""Create a sample embedding vector."""
|
| 66 |
+
return np.random.randn(384).astype(np.float32)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =============================================================================
|
| 70 |
+
# Memory Model Tests
|
| 71 |
+
# =============================================================================
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TestMemoryModel:
|
| 75 |
+
"""Tests for the Memory dataclass."""
|
| 76 |
+
|
| 77 |
+
def test_memory_creation(self):
|
| 78 |
+
"""Test basic memory creation."""
|
| 79 |
+
memory = Memory(
|
| 80 |
+
content="Test content",
|
| 81 |
+
user_id="test-user",
|
| 82 |
+
)
|
| 83 |
+
assert memory.content == "Test content"
|
| 84 |
+
assert memory.user_id == "test-user"
|
| 85 |
+
assert memory.id is not None # Auto-generated UUID
|
| 86 |
+
assert memory.category == MemoryCategory.FACT # Default
|
| 87 |
+
assert memory.importance == 0.5 # Default
|
| 88 |
+
|
| 89 |
+
def test_scope_level_computation(self):
|
| 90 |
+
"""Test scope level is correctly computed from hierarchy fields."""
|
| 91 |
+
# USER level - only user_id
|
| 92 |
+
user_mem = Memory(content="test", user_id="alice")
|
| 93 |
+
assert user_mem.scope_level == ScopeLevel.USER
|
| 94 |
+
|
| 95 |
+
# SESSION level - user_id + session_id
|
| 96 |
+
session_mem = Memory(content="test", user_id="alice", session_id="sess-1")
|
| 97 |
+
assert session_mem.scope_level == ScopeLevel.SESSION
|
| 98 |
+
|
| 99 |
+
# AGENT level - user_id + session_id + agent_id
|
| 100 |
+
agent_mem = Memory(content="test", user_id="alice", session_id="sess-1", agent_id="agent-1")
|
| 101 |
+
assert agent_mem.scope_level == ScopeLevel.AGENT
|
| 102 |
+
|
| 103 |
+
# TURN level - all four
|
| 104 |
+
turn_mem = Memory(
|
| 105 |
+
content="test",
|
| 106 |
+
user_id="alice",
|
| 107 |
+
session_id="sess-1",
|
| 108 |
+
agent_id="agent-1",
|
| 109 |
+
turn_id="turn-1",
|
| 110 |
+
)
|
| 111 |
+
assert turn_mem.scope_level == ScopeLevel.TURN
|
| 112 |
+
|
| 113 |
+
def test_is_current_property(self):
|
| 114 |
+
"""Test is_current property for supersession detection."""
|
| 115 |
+
current = Memory(content="test", user_id="alice")
|
| 116 |
+
assert current.is_current is True
|
| 117 |
+
|
| 118 |
+
superseded = Memory(content="test", user_id="alice", valid_until=datetime.utcnow())
|
| 119 |
+
assert superseded.is_current is False
|
| 120 |
+
|
| 121 |
+
def test_memory_serialization(self, sample_embedding):
|
| 122 |
+
"""Test Memory to_dict and from_dict."""
|
| 123 |
+
memory = Memory(
|
| 124 |
+
content="Test content",
|
| 125 |
+
user_id="alice",
|
| 126 |
+
session_id="sess-1",
|
| 127 |
+
category=MemoryCategory.PREFERENCE,
|
| 128 |
+
importance=0.9,
|
| 129 |
+
entity_refs=["entity1"],
|
| 130 |
+
metadata={"key": "value"},
|
| 131 |
+
embedding=sample_embedding,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Serialize
|
| 135 |
+
data = memory.to_dict()
|
| 136 |
+
assert data["content"] == "Test content"
|
| 137 |
+
assert data["user_id"] == "alice"
|
| 138 |
+
assert data["category"] == "preference"
|
| 139 |
+
assert data["embedding"] is not None
|
| 140 |
+
|
| 141 |
+
# Deserialize
|
| 142 |
+
restored = Memory.from_dict(data)
|
| 143 |
+
assert restored.content == memory.content
|
| 144 |
+
assert restored.user_id == memory.user_id
|
| 145 |
+
assert restored.category == memory.category
|
| 146 |
+
assert restored.importance == memory.importance
|
| 147 |
+
assert np.allclose(restored.embedding, memory.embedding)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# =============================================================================
|
| 151 |
+
# SQLite Store Tests
|
| 152 |
+
# =============================================================================
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestSQLiteMemoryStore:
|
| 156 |
+
"""Tests for SQLiteMemoryStore."""
|
| 157 |
+
|
| 158 |
+
@pytest.fixture
|
| 159 |
+
def store(self, temp_db_path):
|
| 160 |
+
"""Create a SQLite store for testing."""
|
| 161 |
+
return SQLiteMemoryStore(temp_db_path)
|
| 162 |
+
|
| 163 |
+
@pytest.mark.asyncio
|
| 164 |
+
async def test_save_and_get(self, store, sample_memory):
|
| 165 |
+
"""Test saving and retrieving a memory."""
|
| 166 |
+
await store.save(sample_memory)
|
| 167 |
+
|
| 168 |
+
retrieved = await store.get(sample_memory.id)
|
| 169 |
+
assert retrieved is not None
|
| 170 |
+
assert retrieved.id == sample_memory.id
|
| 171 |
+
assert retrieved.content == sample_memory.content
|
| 172 |
+
assert retrieved.user_id == sample_memory.user_id
|
| 173 |
+
assert retrieved.category == sample_memory.category
|
| 174 |
+
|
| 175 |
+
@pytest.mark.asyncio
|
| 176 |
+
async def test_save_batch(self, store):
|
| 177 |
+
"""Test batch saving memories."""
|
| 178 |
+
memories = [Memory(content=f"Memory {i}", user_id="alice") for i in range(10)]
|
| 179 |
+
|
| 180 |
+
await store.save_batch(memories)
|
| 181 |
+
|
| 182 |
+
for memory in memories:
|
| 183 |
+
retrieved = await store.get(memory.id)
|
| 184 |
+
assert retrieved is not None
|
| 185 |
+
assert retrieved.content == memory.content
|
| 186 |
+
|
| 187 |
+
@pytest.mark.asyncio
|
| 188 |
+
async def test_delete(self, store, sample_memory):
|
| 189 |
+
"""Test deleting a memory."""
|
| 190 |
+
await store.save(sample_memory)
|
| 191 |
+
|
| 192 |
+
deleted = await store.delete(sample_memory.id)
|
| 193 |
+
assert deleted is True
|
| 194 |
+
|
| 195 |
+
retrieved = await store.get(sample_memory.id)
|
| 196 |
+
assert retrieved is None
|
| 197 |
+
|
| 198 |
+
@pytest.mark.asyncio
|
| 199 |
+
async def test_query_by_user(self, store):
|
| 200 |
+
"""Test querying memories by user_id."""
|
| 201 |
+
# Create memories for different users
|
| 202 |
+
alice_memories = [Memory(content=f"Alice {i}", user_id="alice") for i in range(5)]
|
| 203 |
+
bob_memories = [Memory(content=f"Bob {i}", user_id="bob") for i in range(3)]
|
| 204 |
+
|
| 205 |
+
await store.save_batch(alice_memories + bob_memories)
|
| 206 |
+
|
| 207 |
+
# Query Alice's memories
|
| 208 |
+
results = await store.query(MemoryFilter(user_id="alice"))
|
| 209 |
+
assert len(results) == 5
|
| 210 |
+
|
| 211 |
+
# Query Bob's memories
|
| 212 |
+
results = await store.query(MemoryFilter(user_id="bob"))
|
| 213 |
+
assert len(results) == 3
|
| 214 |
+
|
| 215 |
+
@pytest.mark.asyncio
|
| 216 |
+
async def test_query_by_category(self, store):
|
| 217 |
+
"""Test querying memories by category."""
|
| 218 |
+
memories = [
|
| 219 |
+
Memory(content="Pref 1", user_id="alice", category=MemoryCategory.PREFERENCE),
|
| 220 |
+
Memory(content="Pref 2", user_id="alice", category=MemoryCategory.PREFERENCE),
|
| 221 |
+
Memory(content="Fact 1", user_id="alice", category=MemoryCategory.FACT),
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
await store.save_batch(memories)
|
| 225 |
+
|
| 226 |
+
# Query preferences
|
| 227 |
+
results = await store.query(
|
| 228 |
+
MemoryFilter(user_id="alice", categories=[MemoryCategory.PREFERENCE])
|
| 229 |
+
)
|
| 230 |
+
assert len(results) == 2
|
| 231 |
+
|
| 232 |
+
@pytest.mark.asyncio
|
| 233 |
+
async def test_query_by_importance(self, store):
|
| 234 |
+
"""Test querying memories by importance range."""
|
| 235 |
+
memories = [
|
| 236 |
+
Memory(content="Low", user_id="alice", importance=0.3),
|
| 237 |
+
Memory(content="Medium", user_id="alice", importance=0.5),
|
| 238 |
+
Memory(content="High", user_id="alice", importance=0.9),
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
await store.save_batch(memories)
|
| 242 |
+
|
| 243 |
+
# Query high importance only
|
| 244 |
+
results = await store.query(MemoryFilter(user_id="alice", min_importance=0.8))
|
| 245 |
+
assert len(results) == 1
|
| 246 |
+
assert results[0].content == "High"
|
| 247 |
+
|
| 248 |
+
@pytest.mark.asyncio
|
| 249 |
+
async def test_query_by_scope_level(self, store):
|
| 250 |
+
"""Test querying by explicit scope level."""
|
| 251 |
+
memories = [
|
| 252 |
+
Memory(content="User level", user_id="alice"),
|
| 253 |
+
Memory(content="Session level", user_id="alice", session_id="sess-1"),
|
| 254 |
+
Memory(content="Agent level", user_id="alice", session_id="sess-1", agent_id="agent-1"),
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
await store.save_batch(memories)
|
| 258 |
+
|
| 259 |
+
# Query only USER level
|
| 260 |
+
results = await store.query(MemoryFilter(user_id="alice", scope_levels=[ScopeLevel.USER]))
|
| 261 |
+
assert len(results) == 1
|
| 262 |
+
assert results[0].content == "User level"
|
| 263 |
+
|
| 264 |
+
# Query SESSION level
|
| 265 |
+
results = await store.query(
|
| 266 |
+
MemoryFilter(user_id="alice", scope_levels=[ScopeLevel.SESSION])
|
| 267 |
+
)
|
| 268 |
+
assert len(results) == 1
|
| 269 |
+
assert results[0].content == "Session level"
|
| 270 |
+
|
| 271 |
+
@pytest.mark.asyncio
|
| 272 |
+
async def test_supersession(self, store):
|
| 273 |
+
"""Test memory supersession."""
|
| 274 |
+
original = Memory(
|
| 275 |
+
content="User prefers Python",
|
| 276 |
+
user_id="alice",
|
| 277 |
+
category=MemoryCategory.PREFERENCE,
|
| 278 |
+
)
|
| 279 |
+
await store.save(original)
|
| 280 |
+
|
| 281 |
+
# Supersede with new preference
|
| 282 |
+
new_memory = Memory(
|
| 283 |
+
content="User now prefers Rust",
|
| 284 |
+
user_id="alice",
|
| 285 |
+
category=MemoryCategory.PREFERENCE,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
superseded = await store.supersede(original.id, new_memory)
|
| 289 |
+
|
| 290 |
+
# New memory should be linked to old
|
| 291 |
+
assert superseded.supersedes == original.id
|
| 292 |
+
|
| 293 |
+
# Old memory should be marked as superseded
|
| 294 |
+
old_retrieved = await store.get(original.id)
|
| 295 |
+
assert old_retrieved.superseded_by == superseded.id
|
| 296 |
+
assert old_retrieved.valid_until is not None
|
| 297 |
+
assert old_retrieved.is_current is False
|
| 298 |
+
|
| 299 |
+
# New memory should be current
|
| 300 |
+
assert superseded.is_current is True
|
| 301 |
+
|
| 302 |
+
@pytest.mark.asyncio
|
| 303 |
+
async def test_get_history(self, store):
|
| 304 |
+
"""Test getting supersession chain history."""
|
| 305 |
+
# Create a chain: v1 -> v2 -> v3
|
| 306 |
+
v1 = Memory(content="Version 1", user_id="alice")
|
| 307 |
+
await store.save(v1)
|
| 308 |
+
|
| 309 |
+
v2 = Memory(content="Version 2", user_id="alice")
|
| 310 |
+
v2 = await store.supersede(v1.id, v2)
|
| 311 |
+
|
| 312 |
+
v3 = Memory(content="Version 3", user_id="alice")
|
| 313 |
+
v3 = await store.supersede(v2.id, v3)
|
| 314 |
+
|
| 315 |
+
# Get history from middle
|
| 316 |
+
history = await store.get_history(v2.id, include_future=True)
|
| 317 |
+
assert len(history) == 3
|
| 318 |
+
assert history[0].content == "Version 1"
|
| 319 |
+
assert history[1].content == "Version 2"
|
| 320 |
+
assert history[2].content == "Version 3"
|
| 321 |
+
|
| 322 |
+
@pytest.mark.asyncio
|
| 323 |
+
async def test_clear_scope(self, store):
|
| 324 |
+
"""Test clearing memories at a scope level."""
|
| 325 |
+
# Create memories at different scopes
|
| 326 |
+
memories = [
|
| 327 |
+
Memory(content="User 1", user_id="alice"),
|
| 328 |
+
Memory(content="User 2", user_id="alice"),
|
| 329 |
+
Memory(content="Session 1", user_id="alice", session_id="sess-1"),
|
| 330 |
+
Memory(content="Other user", user_id="bob"),
|
| 331 |
+
]
|
| 332 |
+
await store.save_batch(memories)
|
| 333 |
+
|
| 334 |
+
# Clear Alice's session
|
| 335 |
+
deleted = await store.clear_scope("alice", session_id="sess-1")
|
| 336 |
+
assert deleted == 1
|
| 337 |
+
|
| 338 |
+
# Alice's user-level memories should remain
|
| 339 |
+
remaining = await store.query(MemoryFilter(user_id="alice"))
|
| 340 |
+
assert len(remaining) == 2
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# =============================================================================
|
| 344 |
+
# LRU Cache Tests
|
| 345 |
+
# =============================================================================
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
class TestLRUMemoryCache:
|
| 349 |
+
"""Tests for LRUMemoryCache."""
|
| 350 |
+
|
| 351 |
+
@pytest.fixture
|
| 352 |
+
def cache(self):
|
| 353 |
+
"""Create a cache for testing."""
|
| 354 |
+
return LRUMemoryCache(max_size=5)
|
| 355 |
+
|
| 356 |
+
async def test_set_and_get(self, cache, sample_memory):
|
| 357 |
+
"""Test basic cache put and get."""
|
| 358 |
+
await cache.put(sample_memory)
|
| 359 |
+
|
| 360 |
+
retrieved = await cache.get(sample_memory.id)
|
| 361 |
+
assert retrieved is not None
|
| 362 |
+
assert retrieved.id == sample_memory.id
|
| 363 |
+
|
| 364 |
+
async def test_lru_eviction(self, cache):
|
| 365 |
+
"""Test LRU eviction when cache is full."""
|
| 366 |
+
# Fill cache with 5 memories
|
| 367 |
+
memories = [Memory(content=f"Mem {i}", user_id="alice") for i in range(5)]
|
| 368 |
+
for m in memories:
|
| 369 |
+
await cache.put(m)
|
| 370 |
+
|
| 371 |
+
assert cache.size == 5
|
| 372 |
+
|
| 373 |
+
# Add one more - should evict the first
|
| 374 |
+
new_mem = Memory(content="New", user_id="alice")
|
| 375 |
+
await cache.put(new_mem)
|
| 376 |
+
|
| 377 |
+
assert cache.size == 5
|
| 378 |
+
assert await cache.get(memories[0].id) is None # First was evicted
|
| 379 |
+
assert await cache.get(new_mem.id) is not None
|
| 380 |
+
|
| 381 |
+
async def test_access_updates_lru_order(self, cache):
|
| 382 |
+
"""Test that accessing a key moves it to end of LRU."""
|
| 383 |
+
memories = [Memory(content=f"Mem {i}", user_id="alice") for i in range(5)]
|
| 384 |
+
for m in memories:
|
| 385 |
+
await cache.put(m)
|
| 386 |
+
|
| 387 |
+
# Access the first memory (makes it most recently used)
|
| 388 |
+
await cache.get(memories[0].id)
|
| 389 |
+
|
| 390 |
+
# Add new memory - should evict second (now oldest)
|
| 391 |
+
new_mem = Memory(content="New", user_id="alice")
|
| 392 |
+
await cache.put(new_mem)
|
| 393 |
+
|
| 394 |
+
assert await cache.get(memories[0].id) is not None # Still present
|
| 395 |
+
assert await cache.get(memories[1].id) is None # Evicted
|
| 396 |
+
|
| 397 |
+
async def test_delete(self, cache, sample_memory):
|
| 398 |
+
"""Test deleting from cache."""
|
| 399 |
+
await cache.put(sample_memory)
|
| 400 |
+
assert cache.size == 1
|
| 401 |
+
|
| 402 |
+
deleted = await cache.invalidate(sample_memory.id)
|
| 403 |
+
assert deleted is True
|
| 404 |
+
assert cache.size == 0
|
| 405 |
+
assert await cache.get(sample_memory.id) is None
|
| 406 |
+
|
| 407 |
+
async def test_clear(self, cache):
|
| 408 |
+
"""Test clearing the cache."""
|
| 409 |
+
memories = [Memory(content=f"Mem {i}", user_id="alice") for i in range(3)]
|
| 410 |
+
for m in memories:
|
| 411 |
+
await cache.put(m)
|
| 412 |
+
|
| 413 |
+
await cache.clear()
|
| 414 |
+
assert cache.size == 0
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# =============================================================================
|
| 418 |
+
# FTS5 Text Index Tests
|
| 419 |
+
# =============================================================================
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
class TestFTS5TextIndex:
|
| 423 |
+
"""Tests for FTS5TextIndex."""
|
| 424 |
+
|
| 425 |
+
@pytest.fixture
|
| 426 |
+
def text_index(self, temp_db_path):
|
| 427 |
+
"""Create a FTS5 text index for testing."""
|
| 428 |
+
return FTS5TextIndex(temp_db_path)
|
| 429 |
+
|
| 430 |
+
def test_index_and_search(self, text_index):
|
| 431 |
+
"""Test indexing and searching text."""
|
| 432 |
+
# Index some memories
|
| 433 |
+
text_index.index("mem-1", "User prefers Python programming", {"user_id": "alice"})
|
| 434 |
+
text_index.index("mem-2", "JavaScript is also popular", {"user_id": "alice"})
|
| 435 |
+
text_index.index("mem-3", "Python is great for data science", {"user_id": "alice"})
|
| 436 |
+
|
| 437 |
+
# Search for Python
|
| 438 |
+
results = text_index.search("Python", k=10)
|
| 439 |
+
assert len(results) == 2
|
| 440 |
+
|
| 441 |
+
# Results should include memory IDs
|
| 442 |
+
result_ids = [r.memory_id for r in results]
|
| 443 |
+
assert "mem-1" in result_ids
|
| 444 |
+
assert "mem-3" in result_ids
|
| 445 |
+
|
| 446 |
+
def test_search_with_user_filter(self, text_index):
|
| 447 |
+
"""Test searching with user filter."""
|
| 448 |
+
text_index.index("mem-1", "Python programming", {"user_id": "alice"})
|
| 449 |
+
text_index.index("mem-2", "Python scripting", {"user_id": "bob"})
|
| 450 |
+
|
| 451 |
+
# Search only Alice's memories
|
| 452 |
+
filter = TextFilter(user_id="alice")
|
| 453 |
+
results = text_index.search("Python", k=10, filter=filter)
|
| 454 |
+
|
| 455 |
+
assert len(results) == 1
|
| 456 |
+
assert results[0].memory_id == "mem-1"
|
| 457 |
+
|
| 458 |
+
def test_search_with_category_filter(self, text_index):
|
| 459 |
+
"""Test searching with category filter."""
|
| 460 |
+
text_index.index("mem-1", "Prefers Python", {"user_id": "alice", "category": "preference"})
|
| 461 |
+
text_index.index("mem-2", "Python is installed", {"user_id": "alice", "category": "fact"})
|
| 462 |
+
|
| 463 |
+
# Search only preferences
|
| 464 |
+
filter = TextFilter(categories=[MemoryCategory.PREFERENCE])
|
| 465 |
+
results = text_index.search("Python", k=10, filter=filter)
|
| 466 |
+
|
| 467 |
+
assert len(results) == 1
|
| 468 |
+
assert results[0].memory_id == "mem-1"
|
| 469 |
+
|
| 470 |
+
def test_delete(self, text_index):
|
| 471 |
+
"""Test deleting from text index."""
|
| 472 |
+
text_index.index("mem-1", "Test content", {"user_id": "alice"})
|
| 473 |
+
|
| 474 |
+
deleted = text_index.delete("mem-1")
|
| 475 |
+
assert deleted is True
|
| 476 |
+
|
| 477 |
+
results = text_index.search("Test", k=10)
|
| 478 |
+
assert len(results) == 0
|
| 479 |
+
|
| 480 |
+
def test_batch_index(self, text_index):
|
| 481 |
+
"""Test batch indexing."""
|
| 482 |
+
memory_ids = ["mem-1", "mem-2", "mem-3"]
|
| 483 |
+
texts = ["Python code", "JavaScript code", "Rust code"]
|
| 484 |
+
metadata = [{"user_id": "alice"} for _ in range(3)]
|
| 485 |
+
|
| 486 |
+
text_index.index_batch(memory_ids, texts, metadata)
|
| 487 |
+
|
| 488 |
+
assert text_index.count() == 3
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# =============================================================================
|
| 492 |
+
# Memory Config Tests
|
| 493 |
+
# =============================================================================
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
class TestMemoryConfig:
|
| 497 |
+
"""Tests for MemoryConfig validation."""
|
| 498 |
+
|
| 499 |
+
def test_default_config(self):
|
| 500 |
+
"""Test default configuration."""
|
| 501 |
+
from headroom.memory.config import MemoryConfig
|
| 502 |
+
|
| 503 |
+
config = MemoryConfig()
|
| 504 |
+
assert config.vector_dimension == 384
|
| 505 |
+
assert config.cache_enabled is True
|
| 506 |
+
assert config.auto_bubble is True
|
| 507 |
+
|
| 508 |
+
def test_invalid_dimension(self):
|
| 509 |
+
"""Test that invalid dimension raises error."""
|
| 510 |
+
from headroom.memory.config import MemoryConfig
|
| 511 |
+
|
| 512 |
+
with pytest.raises(ValueError):
|
| 513 |
+
MemoryConfig(vector_dimension=0)
|
| 514 |
+
|
| 515 |
+
def test_openai_requires_api_key(self):
|
| 516 |
+
"""Test that OpenAI backend requires API key."""
|
| 517 |
+
from headroom.memory.config import EmbedderBackend, MemoryConfig
|
| 518 |
+
|
| 519 |
+
with pytest.raises(ValueError, match="openai_api_key"):
|
| 520 |
+
MemoryConfig(embedder_backend=EmbedderBackend.OPENAI)
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
# =============================================================================
|
| 524 |
+
# Integration Tests
|
| 525 |
+
# =============================================================================
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
class TestIntegration:
|
| 529 |
+
"""Integration tests that test multiple components together."""
|
| 530 |
+
|
| 531 |
+
@pytest.mark.asyncio
|
| 532 |
+
async def test_store_with_embeddings(self, temp_db_path, sample_embedding):
|
| 533 |
+
"""Test storing and retrieving memories with embeddings."""
|
| 534 |
+
store = SQLiteMemoryStore(temp_db_path)
|
| 535 |
+
|
| 536 |
+
memory = Memory(
|
| 537 |
+
content="Test content",
|
| 538 |
+
user_id="alice",
|
| 539 |
+
embedding=sample_embedding,
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
await store.save(memory)
|
| 543 |
+
|
| 544 |
+
retrieved = await store.get(memory.id)
|
| 545 |
+
assert retrieved.embedding is not None
|
| 546 |
+
assert np.allclose(retrieved.embedding, sample_embedding)
|
| 547 |
+
|
| 548 |
+
@pytest.mark.asyncio
|
| 549 |
+
async def test_temporal_query(self, temp_db_path):
|
| 550 |
+
"""Test point-in-time temporal queries."""
|
| 551 |
+
store = SQLiteMemoryStore(temp_db_path)
|
| 552 |
+
|
| 553 |
+
# Create a supersession chain
|
| 554 |
+
original = Memory(content="Original preference", user_id="alice")
|
| 555 |
+
await store.save(original)
|
| 556 |
+
|
| 557 |
+
# Capture time after original was created (valid_from is set at Memory creation)
|
| 558 |
+
time_when_original_valid = original.valid_from + timedelta(milliseconds=1)
|
| 559 |
+
|
| 560 |
+
# Wait a bit for time difference
|
| 561 |
+
await asyncio.sleep(0.01)
|
| 562 |
+
|
| 563 |
+
# Supersede
|
| 564 |
+
new_memory = Memory(content="New preference", user_id="alice")
|
| 565 |
+
supersede_time = datetime.utcnow()
|
| 566 |
+
await store.supersede(original.id, new_memory, supersede_time)
|
| 567 |
+
|
| 568 |
+
# Query at a point when original was valid (after its valid_from, before supersession)
|
| 569 |
+
# The past_time must be >= original.valid_from and < supersede_time
|
| 570 |
+
results = await store.query(
|
| 571 |
+
MemoryFilter(
|
| 572 |
+
user_id="alice", valid_at=time_when_original_valid, include_superseded=True
|
| 573 |
+
)
|
| 574 |
+
)
|
| 575 |
+
assert len(results) == 1
|
| 576 |
+
assert results[0].content == "Original preference"
|
| 577 |
+
|
| 578 |
+
# Query current - should return new
|
| 579 |
+
results = await store.query(MemoryFilter(user_id="alice"))
|
| 580 |
+
assert len(results) == 1
|
| 581 |
+
assert results[0].content == "New preference"
|
| 582 |
+
|
| 583 |
+
@pytest.mark.asyncio
|
| 584 |
+
async def test_hierarchical_scope_query(self, temp_db_path):
|
| 585 |
+
"""Test hierarchical scope filtering."""
|
| 586 |
+
store = SQLiteMemoryStore(temp_db_path)
|
| 587 |
+
|
| 588 |
+
# Create memories at different scopes
|
| 589 |
+
user_mem = Memory(content="User pref", user_id="alice")
|
| 590 |
+
session_mem = Memory(content="Session context", user_id="alice", session_id="sess-1")
|
| 591 |
+
agent_mem = Memory(
|
| 592 |
+
content="Agent decision",
|
| 593 |
+
user_id="alice",
|
| 594 |
+
session_id="sess-1",
|
| 595 |
+
agent_id="agent-1",
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
await store.save_batch([user_mem, session_mem, agent_mem])
|
| 599 |
+
|
| 600 |
+
# Query user scope only - should get just user_mem
|
| 601 |
+
user_only = await store.query(MemoryFilter(user_id="alice", scope_levels=[ScopeLevel.USER]))
|
| 602 |
+
assert len(user_only) == 1
|
| 603 |
+
assert user_only[0].content == "User pref"
|
| 604 |
+
|
| 605 |
+
# Query all scopes for this user
|
| 606 |
+
all_memories = await store.query(MemoryFilter(user_id="alice"))
|
| 607 |
+
assert len(all_memories) == 3
|
| 608 |
+
|
| 609 |
+
# Query specific session
|
| 610 |
+
session_memories = await store.query(MemoryFilter(user_id="alice", session_id="sess-1"))
|
| 611 |
+
assert len(session_memories) == 2 # session and agent level
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# =============================================================================
|
| 615 |
+
# HNSW Vector Index Tests
|
| 616 |
+
# =============================================================================
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
class TestHNSWVectorIndex:
|
| 620 |
+
"""Tests for HNSWVectorIndex."""
|
| 621 |
+
|
| 622 |
+
@pytest.fixture
|
| 623 |
+
def vector_index(self, temp_db_path):
|
| 624 |
+
"""Create an HNSW vector index for testing."""
|
| 625 |
+
from headroom.memory.adapters.hnsw import HNSWVectorIndex
|
| 626 |
+
|
| 627 |
+
return HNSWVectorIndex(dimension=384, save_path=temp_db_path.with_suffix(".hnsw"))
|
| 628 |
+
|
| 629 |
+
@pytest.mark.asyncio
|
| 630 |
+
async def test_index_and_search(self, vector_index):
|
| 631 |
+
"""Test indexing and searching vectors."""
|
| 632 |
+
|
| 633 |
+
# Create memories with random embeddings
|
| 634 |
+
np.random.seed(42)
|
| 635 |
+
memories = []
|
| 636 |
+
for i in range(10):
|
| 637 |
+
embedding = np.random.randn(384).astype(np.float32)
|
| 638 |
+
memory = Memory(
|
| 639 |
+
content=f"Test content {i}",
|
| 640 |
+
user_id="alice",
|
| 641 |
+
embedding=embedding,
|
| 642 |
+
)
|
| 643 |
+
memories.append(memory)
|
| 644 |
+
|
| 645 |
+
# Index all memories
|
| 646 |
+
for memory in memories:
|
| 647 |
+
await vector_index.index(memory)
|
| 648 |
+
|
| 649 |
+
# Search with first memory's embedding - should find itself as most similar
|
| 650 |
+
filter = VectorFilter(
|
| 651 |
+
query_vector=memories[0].embedding,
|
| 652 |
+
top_k=3,
|
| 653 |
+
user_id="alice",
|
| 654 |
+
)
|
| 655 |
+
results = await vector_index.search(filter)
|
| 656 |
+
assert len(results) == 3
|
| 657 |
+
assert results[0].memory.id == memories[0].id
|
| 658 |
+
assert results[0].similarity > 0.99 # Should be very close to 1.0
|
| 659 |
+
|
| 660 |
+
@pytest.mark.asyncio
|
| 661 |
+
async def test_batch_index(self, vector_index):
|
| 662 |
+
"""Test batch indexing."""
|
| 663 |
+
|
| 664 |
+
np.random.seed(42)
|
| 665 |
+
memories = []
|
| 666 |
+
for i in range(100):
|
| 667 |
+
embedding = np.random.randn(384).astype(np.float32)
|
| 668 |
+
memory = Memory(
|
| 669 |
+
content=f"Test content {i}",
|
| 670 |
+
user_id="alice",
|
| 671 |
+
embedding=embedding,
|
| 672 |
+
)
|
| 673 |
+
memories.append(memory)
|
| 674 |
+
|
| 675 |
+
count = await vector_index.index_batch(memories)
|
| 676 |
+
|
| 677 |
+
# Verify count
|
| 678 |
+
assert count == 100
|
| 679 |
+
assert vector_index.size == 100
|
| 680 |
+
|
| 681 |
+
# Search should work
|
| 682 |
+
filter = VectorFilter(
|
| 683 |
+
query_vector=memories[50].embedding,
|
| 684 |
+
top_k=5,
|
| 685 |
+
user_id="alice",
|
| 686 |
+
)
|
| 687 |
+
results = await vector_index.search(filter)
|
| 688 |
+
assert len(results) == 5
|
| 689 |
+
assert results[0].memory.id == memories[50].id
|
| 690 |
+
|
| 691 |
+
@pytest.mark.asyncio
|
| 692 |
+
async def test_remove(self, vector_index):
|
| 693 |
+
"""Test removing from index."""
|
| 694 |
+
np.random.seed(42)
|
| 695 |
+
embedding = np.random.randn(384).astype(np.float32)
|
| 696 |
+
memory = Memory(
|
| 697 |
+
content="Test content",
|
| 698 |
+
user_id="alice",
|
| 699 |
+
embedding=embedding,
|
| 700 |
+
)
|
| 701 |
+
await vector_index.index(memory)
|
| 702 |
+
|
| 703 |
+
# HNSW doesn't support true deletion, but marks as deleted
|
| 704 |
+
removed = await vector_index.remove(memory.id)
|
| 705 |
+
assert removed is True
|
| 706 |
+
|
| 707 |
+
@pytest.mark.asyncio
|
| 708 |
+
async def test_persistence(self, temp_db_path):
|
| 709 |
+
"""Test that index persists to disk."""
|
| 710 |
+
from headroom.memory.adapters.hnsw import HNSWVectorIndex
|
| 711 |
+
|
| 712 |
+
save_path = temp_db_path.with_suffix(".hnsw")
|
| 713 |
+
np.random.seed(42)
|
| 714 |
+
embedding = np.random.randn(384).astype(np.float32)
|
| 715 |
+
memory = Memory(
|
| 716 |
+
content="Test content",
|
| 717 |
+
user_id="alice",
|
| 718 |
+
embedding=embedding,
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
# Create and populate index
|
| 722 |
+
index1 = HNSWVectorIndex(dimension=384, save_path=save_path)
|
| 723 |
+
await index1.index(memory)
|
| 724 |
+
index1.save_index(save_path)
|
| 725 |
+
|
| 726 |
+
# Create new index and load from same path
|
| 727 |
+
index2 = HNSWVectorIndex(dimension=384, save_path=save_path)
|
| 728 |
+
index2.load_index(save_path)
|
| 729 |
+
assert index2.size == 1
|
| 730 |
+
|
| 731 |
+
filter = VectorFilter(
|
| 732 |
+
query_vector=embedding,
|
| 733 |
+
top_k=1,
|
| 734 |
+
user_id="alice",
|
| 735 |
+
)
|
| 736 |
+
results = await index2.search(filter)
|
| 737 |
+
assert results[0].memory.id == memory.id
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
# =============================================================================
|
| 741 |
+
# LocalEmbedder Tests
|
| 742 |
+
# =============================================================================
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
class TestLocalEmbedder:
|
| 746 |
+
"""Tests for LocalEmbedder (sentence-transformers)."""
|
| 747 |
+
|
| 748 |
+
@pytest.fixture
|
| 749 |
+
def embedder(self):
|
| 750 |
+
"""Create a local embedder for testing."""
|
| 751 |
+
from headroom.memory.adapters.embedders import LocalEmbedder
|
| 752 |
+
|
| 753 |
+
return LocalEmbedder()
|
| 754 |
+
|
| 755 |
+
@pytest.mark.asyncio
|
| 756 |
+
async def test_embed_single(self, embedder):
|
| 757 |
+
"""Test embedding a single text."""
|
| 758 |
+
text = "User prefers Python programming"
|
| 759 |
+
embedding = await embedder.embed(text)
|
| 760 |
+
|
| 761 |
+
assert embedding is not None
|
| 762 |
+
assert embedding.shape == (384,)
|
| 763 |
+
assert embedding.dtype == np.float32
|
| 764 |
+
|
| 765 |
+
@pytest.mark.asyncio
|
| 766 |
+
async def test_embed_batch(self, embedder):
|
| 767 |
+
"""Test embedding multiple texts."""
|
| 768 |
+
texts = [
|
| 769 |
+
"Python programming",
|
| 770 |
+
"JavaScript development",
|
| 771 |
+
"Rust systems programming",
|
| 772 |
+
]
|
| 773 |
+
embeddings = await embedder.embed_batch(texts)
|
| 774 |
+
|
| 775 |
+
assert len(embeddings) == 3
|
| 776 |
+
for emb in embeddings:
|
| 777 |
+
assert emb.shape == (384,)
|
| 778 |
+
|
| 779 |
+
@pytest.mark.asyncio
|
| 780 |
+
async def test_similar_texts_have_high_similarity(self, embedder):
|
| 781 |
+
"""Test that semantically similar texts have similar embeddings."""
|
| 782 |
+
text1 = "The user prefers Python for data analysis"
|
| 783 |
+
text2 = "Python is the user's preferred language for data science"
|
| 784 |
+
text3 = "The weather is sunny today"
|
| 785 |
+
|
| 786 |
+
emb1 = await embedder.embed(text1)
|
| 787 |
+
emb2 = await embedder.embed(text2)
|
| 788 |
+
emb3 = await embedder.embed(text3)
|
| 789 |
+
|
| 790 |
+
# Cosine similarity
|
| 791 |
+
def cosine_sim(a, b):
|
| 792 |
+
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
|
| 793 |
+
|
| 794 |
+
# Similar texts should have high similarity
|
| 795 |
+
sim_related = cosine_sim(emb1, emb2)
|
| 796 |
+
sim_unrelated = cosine_sim(emb1, emb3)
|
| 797 |
+
|
| 798 |
+
assert sim_related > 0.7 # Related texts
|
| 799 |
+
assert sim_unrelated < 0.5 # Unrelated texts
|
| 800 |
+
assert sim_related > sim_unrelated
|
| 801 |
+
|
| 802 |
+
def test_dimension_property(self, embedder):
|
| 803 |
+
"""Test that dimension property returns correct value."""
|
| 804 |
+
assert embedder.dimension == 384
|
|
@@ -1,196 +0,0 @@
|
|
| 1 |
-
"""Tests for inline memory extraction (Letta-style)."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
from headroom.memory.inline_extractor import (
|
| 6 |
-
MEMORY_INSTRUCTION,
|
| 7 |
-
MEMORY_INSTRUCTION_SHORT,
|
| 8 |
-
ParsedResponse,
|
| 9 |
-
inject_memory_instruction,
|
| 10 |
-
parse_response_with_memory,
|
| 11 |
-
)
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class TestParseResponseWithMemory:
|
| 15 |
-
"""Test response parsing to extract memories."""
|
| 16 |
-
|
| 17 |
-
def test_extracts_single_memory(self):
|
| 18 |
-
"""Parse response with one memory."""
|
| 19 |
-
response = """Great choice! Python is excellent for backend development.
|
| 20 |
-
|
| 21 |
-
<memory>{"memories": [{"content": "User prefers Python", "category": "preference"}]}</memory>"""
|
| 22 |
-
|
| 23 |
-
parsed = parse_response_with_memory(response)
|
| 24 |
-
|
| 25 |
-
assert (
|
| 26 |
-
parsed.content.strip() == "Great choice! Python is excellent for backend development."
|
| 27 |
-
)
|
| 28 |
-
assert len(parsed.memories) == 1
|
| 29 |
-
assert parsed.memories[0]["content"] == "User prefers Python"
|
| 30 |
-
assert parsed.memories[0]["category"] == "preference"
|
| 31 |
-
assert parsed.raw == response
|
| 32 |
-
|
| 33 |
-
def test_extracts_multiple_memories(self):
|
| 34 |
-
"""Parse response with multiple memories."""
|
| 35 |
-
response = """That's interesting background!
|
| 36 |
-
|
| 37 |
-
<memory>{"memories": [
|
| 38 |
-
{"content": "Works at fintech startup", "category": "fact"},
|
| 39 |
-
{"content": "Uses PostgreSQL", "category": "preference"}
|
| 40 |
-
]}</memory>"""
|
| 41 |
-
|
| 42 |
-
parsed = parse_response_with_memory(response)
|
| 43 |
-
|
| 44 |
-
assert len(parsed.memories) == 2
|
| 45 |
-
assert parsed.memories[0]["content"] == "Works at fintech startup"
|
| 46 |
-
assert parsed.memories[1]["content"] == "Uses PostgreSQL"
|
| 47 |
-
|
| 48 |
-
def test_handles_empty_memories(self):
|
| 49 |
-
"""Parse response with no memories."""
|
| 50 |
-
response = """Hello! How can I help?
|
| 51 |
-
|
| 52 |
-
<memory>{"memories": []}</memory>"""
|
| 53 |
-
|
| 54 |
-
parsed = parse_response_with_memory(response)
|
| 55 |
-
|
| 56 |
-
assert "Hello! How can I help?" in parsed.content
|
| 57 |
-
assert len(parsed.memories) == 0
|
| 58 |
-
|
| 59 |
-
def test_handles_no_memory_block(self):
|
| 60 |
-
"""Parse response without memory block."""
|
| 61 |
-
response = "Just a normal response without memory."
|
| 62 |
-
|
| 63 |
-
parsed = parse_response_with_memory(response)
|
| 64 |
-
|
| 65 |
-
assert parsed.content == response
|
| 66 |
-
assert len(parsed.memories) == 0
|
| 67 |
-
|
| 68 |
-
def test_handles_malformed_json(self):
|
| 69 |
-
"""Parse response with invalid JSON in memory block."""
|
| 70 |
-
response = """Some response.
|
| 71 |
-
|
| 72 |
-
<memory>this is not valid json</memory>"""
|
| 73 |
-
|
| 74 |
-
parsed = parse_response_with_memory(response)
|
| 75 |
-
|
| 76 |
-
assert "Some response" in parsed.content
|
| 77 |
-
assert len(parsed.memories) == 0 # Gracefully handles error
|
| 78 |
-
|
| 79 |
-
def test_case_insensitive_tags(self):
|
| 80 |
-
"""Memory tags should be case-insensitive."""
|
| 81 |
-
response = """Response here.
|
| 82 |
-
|
| 83 |
-
<MEMORY>{"memories": [{"content": "Test", "category": "fact"}]}</MEMORY>"""
|
| 84 |
-
|
| 85 |
-
parsed = parse_response_with_memory(response)
|
| 86 |
-
|
| 87 |
-
assert len(parsed.memories) == 1
|
| 88 |
-
|
| 89 |
-
def test_memory_block_in_middle(self):
|
| 90 |
-
"""Memory block can appear anywhere in response."""
|
| 91 |
-
response = """First part.
|
| 92 |
-
|
| 93 |
-
<memory>{"memories": [{"content": "Test", "category": "fact"}]}</memory>
|
| 94 |
-
|
| 95 |
-
More content after."""
|
| 96 |
-
|
| 97 |
-
parsed = parse_response_with_memory(response)
|
| 98 |
-
|
| 99 |
-
assert len(parsed.memories) == 1
|
| 100 |
-
assert "First part" in parsed.content
|
| 101 |
-
assert "More content after" in parsed.content
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
class TestInjectMemoryInstruction:
|
| 105 |
-
"""Test injection of memory instruction into messages."""
|
| 106 |
-
|
| 107 |
-
def test_appends_to_existing_system_prompt(self):
|
| 108 |
-
"""Instruction appended to existing system message."""
|
| 109 |
-
messages = [
|
| 110 |
-
{"role": "system", "content": "You are helpful."},
|
| 111 |
-
{"role": "user", "content": "Hello"},
|
| 112 |
-
]
|
| 113 |
-
|
| 114 |
-
result = inject_memory_instruction(messages, short=True)
|
| 115 |
-
|
| 116 |
-
assert len(result) == 2
|
| 117 |
-
assert result[0]["role"] == "system"
|
| 118 |
-
assert "You are helpful." in result[0]["content"]
|
| 119 |
-
assert "memory" in result[0]["content"].lower()
|
| 120 |
-
|
| 121 |
-
def test_creates_system_prompt_if_missing(self):
|
| 122 |
-
"""Creates system message if none exists."""
|
| 123 |
-
messages = [
|
| 124 |
-
{"role": "user", "content": "Hello"},
|
| 125 |
-
]
|
| 126 |
-
|
| 127 |
-
result = inject_memory_instruction(messages, short=True)
|
| 128 |
-
|
| 129 |
-
assert len(result) == 2
|
| 130 |
-
assert result[0]["role"] == "system"
|
| 131 |
-
assert "memory" in result[0]["content"].lower()
|
| 132 |
-
|
| 133 |
-
def test_does_not_modify_original(self):
|
| 134 |
-
"""Original messages list is not modified."""
|
| 135 |
-
messages = [
|
| 136 |
-
{"role": "system", "content": "Original prompt."},
|
| 137 |
-
{"role": "user", "content": "Hello"},
|
| 138 |
-
]
|
| 139 |
-
original_content = messages[0]["content"]
|
| 140 |
-
|
| 141 |
-
inject_memory_instruction(messages, short=True)
|
| 142 |
-
|
| 143 |
-
assert messages[0]["content"] == original_content
|
| 144 |
-
|
| 145 |
-
def test_short_vs_long_instruction(self):
|
| 146 |
-
"""Short instruction is shorter than full instruction."""
|
| 147 |
-
messages = [{"role": "user", "content": "Hello"}]
|
| 148 |
-
|
| 149 |
-
short = inject_memory_instruction(messages, short=True)
|
| 150 |
-
long = inject_memory_instruction(messages, short=False)
|
| 151 |
-
|
| 152 |
-
assert len(short[0]["content"]) < len(long[0]["content"])
|
| 153 |
-
|
| 154 |
-
def test_instruction_contains_required_format(self):
|
| 155 |
-
"""Instruction explains the memory format."""
|
| 156 |
-
messages = [{"role": "user", "content": "Hello"}]
|
| 157 |
-
|
| 158 |
-
result = inject_memory_instruction(messages, short=False)
|
| 159 |
-
content = result[0]["content"]
|
| 160 |
-
|
| 161 |
-
assert "<memory>" in content
|
| 162 |
-
assert "memories" in content
|
| 163 |
-
assert "category" in content
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
class TestParsedResponse:
|
| 167 |
-
"""Test ParsedResponse dataclass."""
|
| 168 |
-
|
| 169 |
-
def test_dataclass_fields(self):
|
| 170 |
-
"""ParsedResponse has expected fields."""
|
| 171 |
-
parsed = ParsedResponse(
|
| 172 |
-
content="Hello",
|
| 173 |
-
memories=[{"content": "Test", "category": "fact"}],
|
| 174 |
-
raw="Hello\n<memory>...</memory>",
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
assert parsed.content == "Hello"
|
| 178 |
-
assert len(parsed.memories) == 1
|
| 179 |
-
assert parsed.raw == "Hello\n<memory>...</memory>"
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
class TestMemoryInstructions:
|
| 183 |
-
"""Test memory instruction prompts."""
|
| 184 |
-
|
| 185 |
-
def test_short_instruction_contains_essentials(self):
|
| 186 |
-
"""Short instruction has minimum required info."""
|
| 187 |
-
assert "<memory>" in MEMORY_INSTRUCTION_SHORT
|
| 188 |
-
assert "memories" in MEMORY_INSTRUCTION_SHORT
|
| 189 |
-
assert "category" in MEMORY_INSTRUCTION_SHORT
|
| 190 |
-
|
| 191 |
-
def test_full_instruction_more_detailed(self):
|
| 192 |
-
"""Full instruction has categories explained."""
|
| 193 |
-
assert "preference" in MEMORY_INSTRUCTION
|
| 194 |
-
assert "fact" in MEMORY_INSTRUCTION
|
| 195 |
-
assert "context" in MEMORY_INSTRUCTION
|
| 196 |
-
assert "Greetings" in MEMORY_INSTRUCTION or "greeting" in MEMORY_INSTRUCTION.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,278 +0,0 @@
|
|
| 1 |
-
"""Tests for SQLite memory store.
|
| 2 |
-
|
| 3 |
-
100% REAL SQLite - no mocks! These tests use actual SQLite
|
| 4 |
-
databases in temp directories for realistic testing.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
from headroom.memory.store import Memory, PendingExtraction, SQLiteMemoryStore
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class TestMemorySaveAndSearch:
|
| 13 |
-
"""Test basic save and search operations."""
|
| 14 |
-
|
| 15 |
-
def test_save_and_search_finds_match(self, temp_db):
|
| 16 |
-
"""Real SQLite, real FTS5, real queries."""
|
| 17 |
-
store = SQLiteMemoryStore(temp_db)
|
| 18 |
-
|
| 19 |
-
store.save("alice", Memory(content="User prefers Python", category="preference"))
|
| 20 |
-
|
| 21 |
-
results = store.search("alice", "python", top_k=5)
|
| 22 |
-
|
| 23 |
-
assert len(results) == 1
|
| 24 |
-
assert "Python" in results[0].content
|
| 25 |
-
|
| 26 |
-
def test_search_no_results_for_unrelated_query(self, temp_db):
|
| 27 |
-
"""Search returns empty when no matches."""
|
| 28 |
-
store = SQLiteMemoryStore(temp_db)
|
| 29 |
-
|
| 30 |
-
store.save("alice", Memory(content="User prefers Python"))
|
| 31 |
-
|
| 32 |
-
results = store.search("alice", "javascript", top_k=5)
|
| 33 |
-
|
| 34 |
-
assert len(results) == 0
|
| 35 |
-
|
| 36 |
-
def test_search_respects_top_k_limit(self, temp_db):
|
| 37 |
-
"""Search returns at most top_k results."""
|
| 38 |
-
store = SQLiteMemoryStore(temp_db)
|
| 39 |
-
|
| 40 |
-
for i in range(10):
|
| 41 |
-
store.save("alice", Memory(content=f"Python fact number {i}"))
|
| 42 |
-
|
| 43 |
-
results = store.search("alice", "python", top_k=3)
|
| 44 |
-
|
| 45 |
-
assert len(results) == 3
|
| 46 |
-
|
| 47 |
-
def test_save_preserves_all_fields(self, temp_db):
|
| 48 |
-
"""All memory fields are preserved through save/search."""
|
| 49 |
-
store = SQLiteMemoryStore(temp_db)
|
| 50 |
-
|
| 51 |
-
original = Memory(
|
| 52 |
-
content="User prefers vim",
|
| 53 |
-
category="preference",
|
| 54 |
-
importance=0.9,
|
| 55 |
-
metadata={"source": "chat"},
|
| 56 |
-
)
|
| 57 |
-
store.save("alice", original)
|
| 58 |
-
|
| 59 |
-
results = store.search("alice", "vim", top_k=1)
|
| 60 |
-
|
| 61 |
-
assert len(results) == 1
|
| 62 |
-
retrieved = results[0]
|
| 63 |
-
assert retrieved.content == original.content
|
| 64 |
-
assert retrieved.category == original.category
|
| 65 |
-
assert retrieved.importance == original.importance
|
| 66 |
-
assert retrieved.metadata == original.metadata
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class TestUserIsolation:
|
| 70 |
-
"""Test that memories are isolated by user_id."""
|
| 71 |
-
|
| 72 |
-
def test_users_have_separate_memories(self, temp_db):
|
| 73 |
-
"""Memories are isolated by user_id."""
|
| 74 |
-
store = SQLiteMemoryStore(temp_db)
|
| 75 |
-
|
| 76 |
-
store.save("alice", Memory(content="Likes Python programming"))
|
| 77 |
-
store.save("bob", Memory(content="Likes JavaScript programming"))
|
| 78 |
-
|
| 79 |
-
# Search for content that's actually in the memories
|
| 80 |
-
alice_results = store.search("alice", "Python", top_k=10)
|
| 81 |
-
bob_results = store.search("bob", "JavaScript", top_k=10)
|
| 82 |
-
|
| 83 |
-
# Each user should only see their own memories
|
| 84 |
-
assert len(alice_results) == 1
|
| 85 |
-
assert "Python" in alice_results[0].content
|
| 86 |
-
|
| 87 |
-
assert len(bob_results) == 1
|
| 88 |
-
assert "JavaScript" in bob_results[0].content
|
| 89 |
-
|
| 90 |
-
def test_get_all_returns_only_user_memories(self, temp_db):
|
| 91 |
-
"""get_all only returns memories for the specified user."""
|
| 92 |
-
store = SQLiteMemoryStore(temp_db)
|
| 93 |
-
|
| 94 |
-
store.save("alice", Memory(content="Alice memory 1"))
|
| 95 |
-
store.save("alice", Memory(content="Alice memory 2"))
|
| 96 |
-
store.save("bob", Memory(content="Bob memory"))
|
| 97 |
-
|
| 98 |
-
alice_memories = store.get_all("alice")
|
| 99 |
-
bob_memories = store.get_all("bob")
|
| 100 |
-
|
| 101 |
-
assert len(alice_memories) == 2
|
| 102 |
-
assert len(bob_memories) == 1
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
class TestMemoryDeletion:
|
| 106 |
-
"""Test deletion operations."""
|
| 107 |
-
|
| 108 |
-
def test_delete_specific_memory(self, temp_db):
|
| 109 |
-
"""Delete removes a specific memory."""
|
| 110 |
-
store = SQLiteMemoryStore(temp_db)
|
| 111 |
-
|
| 112 |
-
mem = Memory(content="To be deleted")
|
| 113 |
-
store.save("alice", mem)
|
| 114 |
-
|
| 115 |
-
result = store.delete("alice", mem.id)
|
| 116 |
-
|
| 117 |
-
assert result is True
|
| 118 |
-
assert len(store.get_all("alice")) == 0
|
| 119 |
-
|
| 120 |
-
def test_delete_nonexistent_returns_false(self, temp_db):
|
| 121 |
-
"""Delete returns False for nonexistent memory."""
|
| 122 |
-
store = SQLiteMemoryStore(temp_db)
|
| 123 |
-
|
| 124 |
-
result = store.delete("alice", "nonexistent-id")
|
| 125 |
-
|
| 126 |
-
assert result is False
|
| 127 |
-
|
| 128 |
-
def test_clear_removes_all_user_memories(self, temp_db):
|
| 129 |
-
"""Clear removes all memories for a user."""
|
| 130 |
-
store = SQLiteMemoryStore(temp_db)
|
| 131 |
-
|
| 132 |
-
for i in range(5):
|
| 133 |
-
store.save("alice", Memory(content=f"Memory {i}"))
|
| 134 |
-
store.save("bob", Memory(content="Bob's memory"))
|
| 135 |
-
|
| 136 |
-
count = store.clear("alice")
|
| 137 |
-
|
| 138 |
-
assert count == 5
|
| 139 |
-
assert len(store.get_all("alice")) == 0
|
| 140 |
-
assert len(store.get_all("bob")) == 1 # Bob's memory untouched
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
class TestMemoryStats:
|
| 144 |
-
"""Test statistics operations."""
|
| 145 |
-
|
| 146 |
-
def test_stats_counts_memories(self, temp_db):
|
| 147 |
-
"""Stats returns correct count."""
|
| 148 |
-
store = SQLiteMemoryStore(temp_db)
|
| 149 |
-
|
| 150 |
-
store.save("alice", Memory(content="Mem 1", category="preference"))
|
| 151 |
-
store.save("alice", Memory(content="Mem 2", category="fact"))
|
| 152 |
-
store.save("alice", Memory(content="Mem 3", category="fact"))
|
| 153 |
-
|
| 154 |
-
stats = store.stats("alice")
|
| 155 |
-
|
| 156 |
-
assert stats["total"] == 3
|
| 157 |
-
assert stats["categories"]["preference"] == 1
|
| 158 |
-
assert stats["categories"]["fact"] == 2
|
| 159 |
-
|
| 160 |
-
def test_stats_empty_user(self, temp_db):
|
| 161 |
-
"""Stats for user with no memories."""
|
| 162 |
-
store = SQLiteMemoryStore(temp_db)
|
| 163 |
-
|
| 164 |
-
stats = store.stats("alice")
|
| 165 |
-
|
| 166 |
-
assert stats["total"] == 0
|
| 167 |
-
assert stats["categories"] == {}
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
class TestPendingExtractions:
|
| 171 |
-
"""Test pending extraction queue for crash recovery."""
|
| 172 |
-
|
| 173 |
-
def test_queue_and_retrieve_pending(self, temp_db):
|
| 174 |
-
"""Queue and retrieve pending extractions."""
|
| 175 |
-
store = SQLiteMemoryStore(temp_db)
|
| 176 |
-
|
| 177 |
-
pending = PendingExtraction(
|
| 178 |
-
user_id="alice",
|
| 179 |
-
query="What's your favorite language?",
|
| 180 |
-
response="I prefer Python for data science.",
|
| 181 |
-
)
|
| 182 |
-
store.queue_extraction(pending)
|
| 183 |
-
|
| 184 |
-
retrieved = store.get_pending_extractions(limit=10)
|
| 185 |
-
|
| 186 |
-
assert len(retrieved) == 1
|
| 187 |
-
assert retrieved[0].user_id == "alice"
|
| 188 |
-
assert retrieved[0].query == pending.query
|
| 189 |
-
assert retrieved[0].response == pending.response
|
| 190 |
-
assert retrieved[0].status == "pending"
|
| 191 |
-
|
| 192 |
-
def test_update_extraction_status(self, temp_db):
|
| 193 |
-
"""Update status of pending extraction."""
|
| 194 |
-
store = SQLiteMemoryStore(temp_db)
|
| 195 |
-
|
| 196 |
-
pending = PendingExtraction(user_id="alice", query="Q", response="R")
|
| 197 |
-
store.queue_extraction(pending)
|
| 198 |
-
|
| 199 |
-
store.update_extraction_status(pending.id, "processing")
|
| 200 |
-
|
| 201 |
-
# Should not appear in pending list anymore
|
| 202 |
-
pending_list = store.get_pending_extractions(status="pending")
|
| 203 |
-
processing_list = store.get_pending_extractions(status="processing")
|
| 204 |
-
|
| 205 |
-
assert len(pending_list) == 0
|
| 206 |
-
assert len(processing_list) == 1
|
| 207 |
-
|
| 208 |
-
def test_delete_extraction(self, temp_db):
|
| 209 |
-
"""Delete completed extraction."""
|
| 210 |
-
store = SQLiteMemoryStore(temp_db)
|
| 211 |
-
|
| 212 |
-
pending = PendingExtraction(user_id="alice", query="Q", response="R")
|
| 213 |
-
store.queue_extraction(pending)
|
| 214 |
-
|
| 215 |
-
store.delete_extraction(pending.id)
|
| 216 |
-
|
| 217 |
-
assert len(store.get_pending_extractions(limit=10)) == 0
|
| 218 |
-
|
| 219 |
-
def test_pending_fifo_order(self, temp_db):
|
| 220 |
-
"""Pending extractions returned in FIFO order."""
|
| 221 |
-
store = SQLiteMemoryStore(temp_db)
|
| 222 |
-
|
| 223 |
-
for i in range(5):
|
| 224 |
-
store.queue_extraction(
|
| 225 |
-
PendingExtraction(user_id="alice", query=f"Q{i}", response=f"R{i}")
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
-
retrieved = store.get_pending_extractions(limit=3)
|
| 229 |
-
|
| 230 |
-
assert len(retrieved) == 3
|
| 231 |
-
assert retrieved[0].query == "Q0"
|
| 232 |
-
assert retrieved[1].query == "Q1"
|
| 233 |
-
assert retrieved[2].query == "Q2"
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
class TestFTS5Features:
|
| 237 |
-
"""Test FTS5 full-text search features."""
|
| 238 |
-
|
| 239 |
-
def test_phrase_search(self, temp_db):
|
| 240 |
-
"""FTS5 supports phrase search with _raw: prefix."""
|
| 241 |
-
store = SQLiteMemoryStore(temp_db)
|
| 242 |
-
|
| 243 |
-
store.save("alice", Memory(content="User prefers dark mode"))
|
| 244 |
-
store.save("alice", Memory(content="User is in dark times"))
|
| 245 |
-
|
| 246 |
-
# Exact phrase match using raw FTS5 syntax
|
| 247 |
-
results = store.search("alice", '_raw:"dark mode"', top_k=5)
|
| 248 |
-
|
| 249 |
-
assert len(results) == 1
|
| 250 |
-
assert "dark mode" in results[0].content
|
| 251 |
-
|
| 252 |
-
def test_prefix_search(self, temp_db):
|
| 253 |
-
"""FTS5 supports prefix search with _raw: prefix."""
|
| 254 |
-
store = SQLiteMemoryStore(temp_db)
|
| 255 |
-
|
| 256 |
-
store.save("alice", Memory(content="User loves Python programming"))
|
| 257 |
-
store.save("alice", Memory(content="User loves JavaScript"))
|
| 258 |
-
|
| 259 |
-
# Prefix search with * using raw FTS5 syntax
|
| 260 |
-
results = store.search("alice", "_raw:Pyth*", top_k=5)
|
| 261 |
-
|
| 262 |
-
assert len(results) == 1
|
| 263 |
-
assert "Python" in results[0].content
|
| 264 |
-
|
| 265 |
-
def test_boolean_and(self, temp_db):
|
| 266 |
-
"""FTS5 supports boolean AND with _raw: prefix."""
|
| 267 |
-
store = SQLiteMemoryStore(temp_db)
|
| 268 |
-
|
| 269 |
-
store.save("alice", Memory(content="User prefers Python"))
|
| 270 |
-
store.save("alice", Memory(content="User prefers dark mode"))
|
| 271 |
-
store.save("alice", Memory(content="User prefers Python and dark mode"))
|
| 272 |
-
|
| 273 |
-
# Boolean AND using raw FTS5 syntax
|
| 274 |
-
results = store.search("alice", "_raw:Python AND dark", top_k=5)
|
| 275 |
-
|
| 276 |
-
assert len(results) == 1
|
| 277 |
-
assert "Python" in results[0].content
|
| 278 |
-
assert "dark" in results[0].content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,375 +0,0 @@
|
|
| 1 |
-
"""Tests for memory wrapper integration.
|
| 2 |
-
|
| 3 |
-
Tests the full with_memory() flow with mocked LLM clients.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from __future__ import annotations
|
| 7 |
-
|
| 8 |
-
import time
|
| 9 |
-
|
| 10 |
-
from headroom.memory.store import Memory
|
| 11 |
-
from headroom.memory.wrapper import with_memory
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class TestWithMemoryBasic:
|
| 15 |
-
"""Test basic with_memory() functionality."""
|
| 16 |
-
|
| 17 |
-
def test_one_line_integration(self, temp_db, mock_openai_client, mock_extractor):
|
| 18 |
-
"""One-line integration works."""
|
| 19 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 20 |
-
|
| 21 |
-
client = with_memory(
|
| 22 |
-
mock_openai_client,
|
| 23 |
-
user_id="alice",
|
| 24 |
-
db_path=temp_db,
|
| 25 |
-
_extractor=mock_extractor,
|
| 26 |
-
)
|
| 27 |
-
|
| 28 |
-
response = client.chat.completions.create(
|
| 29 |
-
model="gpt-4o",
|
| 30 |
-
messages=[{"role": "user", "content": "Hello"}],
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
assert response.choices[0].message.content == "Hello!"
|
| 34 |
-
|
| 35 |
-
def test_forwards_all_kwargs(self, temp_db, mock_openai_client, mock_extractor):
|
| 36 |
-
"""All kwargs are forwarded to underlying client."""
|
| 37 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 38 |
-
|
| 39 |
-
client = with_memory(
|
| 40 |
-
mock_openai_client,
|
| 41 |
-
user_id="alice",
|
| 42 |
-
db_path=temp_db,
|
| 43 |
-
_extractor=mock_extractor,
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
client.chat.completions.create(
|
| 47 |
-
model="gpt-4o",
|
| 48 |
-
messages=[{"role": "user", "content": "test"}],
|
| 49 |
-
temperature=0.5,
|
| 50 |
-
max_tokens=100,
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
call = mock_openai_client.chat.completions.calls[0]
|
| 54 |
-
assert call["model"] == "gpt-4o"
|
| 55 |
-
assert call["temperature"] == 0.5
|
| 56 |
-
assert call["max_tokens"] == 100
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class TestMemoryInjection:
|
| 60 |
-
"""Test memory injection into messages."""
|
| 61 |
-
|
| 62 |
-
def test_injects_memory_into_user_message(self, temp_db, mock_openai_client, mock_extractor):
|
| 63 |
-
"""Memory is injected into user message, not system prompt."""
|
| 64 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 65 |
-
|
| 66 |
-
# Pre-populate memory with content that will match the query
|
| 67 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 68 |
-
|
| 69 |
-
store = SQLiteMemoryStore(temp_db)
|
| 70 |
-
store.save("alice", Memory(content="User prefers Python for coding", category="preference"))
|
| 71 |
-
|
| 72 |
-
client = with_memory(
|
| 73 |
-
mock_openai_client,
|
| 74 |
-
user_id="alice",
|
| 75 |
-
db_path=temp_db,
|
| 76 |
-
_extractor=mock_extractor,
|
| 77 |
-
_store=store,
|
| 78 |
-
)
|
| 79 |
-
|
| 80 |
-
# Use a query that will match the memory content
|
| 81 |
-
client.chat.completions.create(
|
| 82 |
-
model="gpt-4o",
|
| 83 |
-
messages=[
|
| 84 |
-
{"role": "system", "content": "You are helpful."},
|
| 85 |
-
{"role": "user", "content": "What Python framework?"},
|
| 86 |
-
],
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
# Check what was sent to the "API"
|
| 90 |
-
call = mock_openai_client.chat.completions.calls[0]
|
| 91 |
-
messages = call["messages"]
|
| 92 |
-
|
| 93 |
-
# System prompt should be UNCHANGED (for caching)
|
| 94 |
-
assert messages[0]["content"] == "You are helpful."
|
| 95 |
-
|
| 96 |
-
# User message should have context prepended
|
| 97 |
-
assert "<context>" in messages[1]["content"]
|
| 98 |
-
assert "Python" in messages[1]["content"]
|
| 99 |
-
assert "What Python framework?" in messages[1]["content"]
|
| 100 |
-
|
| 101 |
-
def test_preserves_system_prompt_exactly(self, temp_db, mock_openai_client, mock_extractor):
|
| 102 |
-
"""System prompt is preserved exactly for caching."""
|
| 103 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 104 |
-
|
| 105 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 106 |
-
|
| 107 |
-
store = SQLiteMemoryStore(temp_db)
|
| 108 |
-
store.save("alice", Memory(content="Some memory"))
|
| 109 |
-
|
| 110 |
-
client = with_memory(
|
| 111 |
-
mock_openai_client,
|
| 112 |
-
user_id="alice",
|
| 113 |
-
db_path=temp_db,
|
| 114 |
-
_extractor=mock_extractor,
|
| 115 |
-
_store=store,
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
original_system = "You are a helpful assistant. Always be concise."
|
| 119 |
-
|
| 120 |
-
client.chat.completions.create(
|
| 121 |
-
model="gpt-4o",
|
| 122 |
-
messages=[
|
| 123 |
-
{"role": "system", "content": original_system},
|
| 124 |
-
{"role": "user", "content": "test"},
|
| 125 |
-
],
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
call = mock_openai_client.chat.completions.calls[0]
|
| 129 |
-
assert call["messages"][0]["content"] == original_system
|
| 130 |
-
|
| 131 |
-
def test_no_injection_when_no_memories(self, temp_db, mock_openai_client, mock_extractor):
|
| 132 |
-
"""No injection when user has no memories."""
|
| 133 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 134 |
-
|
| 135 |
-
client = with_memory(
|
| 136 |
-
mock_openai_client,
|
| 137 |
-
user_id="alice",
|
| 138 |
-
db_path=temp_db,
|
| 139 |
-
_extractor=mock_extractor,
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
client.chat.completions.create(
|
| 143 |
-
model="gpt-4o",
|
| 144 |
-
messages=[{"role": "user", "content": "Hello"}],
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
call = mock_openai_client.chat.completions.calls[0]
|
| 148 |
-
# Message should be unchanged
|
| 149 |
-
assert call["messages"][0]["content"] == "Hello"
|
| 150 |
-
assert "<context>" not in call["messages"][0]["content"]
|
| 151 |
-
|
| 152 |
-
def test_respects_top_k(self, temp_db, mock_openai_client, mock_extractor):
|
| 153 |
-
"""Only top_k memories are injected."""
|
| 154 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 155 |
-
|
| 156 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 157 |
-
|
| 158 |
-
store = SQLiteMemoryStore(temp_db)
|
| 159 |
-
for i in range(10):
|
| 160 |
-
store.save("alice", Memory(content=f"Python fact {i}"))
|
| 161 |
-
|
| 162 |
-
client = with_memory(
|
| 163 |
-
mock_openai_client,
|
| 164 |
-
user_id="alice",
|
| 165 |
-
db_path=temp_db,
|
| 166 |
-
_extractor=mock_extractor,
|
| 167 |
-
_store=store,
|
| 168 |
-
top_k=3,
|
| 169 |
-
)
|
| 170 |
-
|
| 171 |
-
client.chat.completions.create(
|
| 172 |
-
model="gpt-4o",
|
| 173 |
-
messages=[{"role": "user", "content": "Python question"}],
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
call = mock_openai_client.chat.completions.calls[0]
|
| 177 |
-
content = call["messages"][0]["content"]
|
| 178 |
-
|
| 179 |
-
# Should have exactly 3 memories (top_k=3)
|
| 180 |
-
assert content.count("Python fact") == 3
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
class TestBackgroundExtraction:
|
| 184 |
-
"""Test background memory extraction."""
|
| 185 |
-
|
| 186 |
-
def test_queues_for_extraction(self, temp_db, mock_openai_client, mock_extractor):
|
| 187 |
-
"""Conversation is queued for extraction after response."""
|
| 188 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 189 |
-
mock_openai_client.chat.completions.set_response("I'll remember that!")
|
| 190 |
-
|
| 191 |
-
client = with_memory(
|
| 192 |
-
mock_openai_client,
|
| 193 |
-
user_id="alice",
|
| 194 |
-
db_path=temp_db,
|
| 195 |
-
_extractor=mock_extractor,
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
client.chat.completions.create(
|
| 199 |
-
model="gpt-4o",
|
| 200 |
-
messages=[{"role": "user", "content": "I prefer Python"}],
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
# Wait for background worker
|
| 204 |
-
time.sleep(0.1)
|
| 205 |
-
|
| 206 |
-
# Check extraction was scheduled
|
| 207 |
-
# The mock extractor records batch calls
|
| 208 |
-
assert len(mock_extractor.batch_calls) >= 0 # May not have processed yet
|
| 209 |
-
|
| 210 |
-
def test_extracts_from_original_message(self, temp_db, mock_openai_client, mock_extractor):
|
| 211 |
-
"""Extraction uses original message (without injected context)."""
|
| 212 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 213 |
-
|
| 214 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 215 |
-
|
| 216 |
-
store = SQLiteMemoryStore(temp_db)
|
| 217 |
-
store.save("alice", Memory(content="Existing memory"))
|
| 218 |
-
|
| 219 |
-
mock_extractor.set_batch_response({"alice": [Memory(content="New fact", category="fact")]})
|
| 220 |
-
|
| 221 |
-
client = with_memory(
|
| 222 |
-
mock_openai_client,
|
| 223 |
-
user_id="alice",
|
| 224 |
-
db_path=temp_db,
|
| 225 |
-
_extractor=mock_extractor,
|
| 226 |
-
_store=store,
|
| 227 |
-
)
|
| 228 |
-
|
| 229 |
-
client.chat.completions.create(
|
| 230 |
-
model="gpt-4o",
|
| 231 |
-
messages=[{"role": "user", "content": "I like vim"}],
|
| 232 |
-
)
|
| 233 |
-
|
| 234 |
-
# Wait for background worker to process
|
| 235 |
-
time.sleep(1.5)
|
| 236 |
-
|
| 237 |
-
# The extractor should have been called with original message
|
| 238 |
-
# (not the one with <context> injected)
|
| 239 |
-
if mock_extractor.batch_calls:
|
| 240 |
-
batch = mock_extractor.batch_calls[0]
|
| 241 |
-
_, query, _ = batch[0]
|
| 242 |
-
assert "<context>" not in query
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
class TestMemoryAPI:
|
| 246 |
-
"""Test direct memory API access."""
|
| 247 |
-
|
| 248 |
-
def test_memory_search(self, temp_db, mock_openai_client, mock_extractor):
|
| 249 |
-
"""client.memory.search() works."""
|
| 250 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 251 |
-
|
| 252 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 253 |
-
|
| 254 |
-
store = SQLiteMemoryStore(temp_db)
|
| 255 |
-
store.save("alice", Memory(content="Likes Python"))
|
| 256 |
-
|
| 257 |
-
client = with_memory(
|
| 258 |
-
mock_openai_client,
|
| 259 |
-
user_id="alice",
|
| 260 |
-
db_path=temp_db,
|
| 261 |
-
_extractor=mock_extractor,
|
| 262 |
-
_store=store,
|
| 263 |
-
)
|
| 264 |
-
|
| 265 |
-
results = client.memory.search("Python")
|
| 266 |
-
|
| 267 |
-
assert len(results) == 1
|
| 268 |
-
assert "Python" in results[0].content
|
| 269 |
-
|
| 270 |
-
def test_memory_add(self, temp_db, mock_openai_client, mock_extractor):
|
| 271 |
-
"""client.memory.add() works."""
|
| 272 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 273 |
-
|
| 274 |
-
client = with_memory(
|
| 275 |
-
mock_openai_client,
|
| 276 |
-
user_id="alice",
|
| 277 |
-
db_path=temp_db,
|
| 278 |
-
_extractor=mock_extractor,
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
-
memory = client.memory.add("User prefers dark mode", category="preference")
|
| 282 |
-
|
| 283 |
-
assert memory.content == "User prefers dark mode"
|
| 284 |
-
assert memory.category == "preference"
|
| 285 |
-
|
| 286 |
-
# Verify it was saved
|
| 287 |
-
all_memories = client.memory.get_all()
|
| 288 |
-
assert len(all_memories) == 1
|
| 289 |
-
|
| 290 |
-
def test_memory_clear(self, temp_db, mock_openai_client, mock_extractor):
|
| 291 |
-
"""client.memory.clear() works."""
|
| 292 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 293 |
-
|
| 294 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 295 |
-
|
| 296 |
-
store = SQLiteMemoryStore(temp_db)
|
| 297 |
-
store.save("alice", Memory(content="Memory 1"))
|
| 298 |
-
store.save("alice", Memory(content="Memory 2"))
|
| 299 |
-
|
| 300 |
-
client = with_memory(
|
| 301 |
-
mock_openai_client,
|
| 302 |
-
user_id="alice",
|
| 303 |
-
db_path=temp_db,
|
| 304 |
-
_extractor=mock_extractor,
|
| 305 |
-
_store=store,
|
| 306 |
-
)
|
| 307 |
-
|
| 308 |
-
count = client.memory.clear()
|
| 309 |
-
|
| 310 |
-
assert count == 2
|
| 311 |
-
assert len(client.memory.get_all()) == 0
|
| 312 |
-
|
| 313 |
-
def test_memory_stats(self, temp_db, mock_openai_client, mock_extractor):
|
| 314 |
-
"""client.memory.stats() works."""
|
| 315 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 316 |
-
|
| 317 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 318 |
-
|
| 319 |
-
store = SQLiteMemoryStore(temp_db)
|
| 320 |
-
store.save("alice", Memory(content="Pref", category="preference"))
|
| 321 |
-
store.save("alice", Memory(content="Fact 1", category="fact"))
|
| 322 |
-
store.save("alice", Memory(content="Fact 2", category="fact"))
|
| 323 |
-
|
| 324 |
-
client = with_memory(
|
| 325 |
-
mock_openai_client,
|
| 326 |
-
user_id="alice",
|
| 327 |
-
db_path=temp_db,
|
| 328 |
-
_extractor=mock_extractor,
|
| 329 |
-
_store=store,
|
| 330 |
-
)
|
| 331 |
-
|
| 332 |
-
stats = client.memory.stats()
|
| 333 |
-
|
| 334 |
-
assert stats["total"] == 3
|
| 335 |
-
assert stats["categories"]["preference"] == 1
|
| 336 |
-
assert stats["categories"]["fact"] == 2
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
class TestMultiUser:
|
| 340 |
-
"""Test multi-user isolation."""
|
| 341 |
-
|
| 342 |
-
def test_users_have_separate_memories(self, temp_db, mock_openai_client, mock_extractor):
|
| 343 |
-
"""Different users have isolated memories."""
|
| 344 |
-
mock_openai_client.__class__.__module__ = "openai.resources"
|
| 345 |
-
|
| 346 |
-
from headroom.memory.store import SQLiteMemoryStore
|
| 347 |
-
|
| 348 |
-
store = SQLiteMemoryStore(temp_db)
|
| 349 |
-
|
| 350 |
-
# Create two wrapped clients for different users
|
| 351 |
-
alice_client = with_memory(
|
| 352 |
-
mock_openai_client,
|
| 353 |
-
user_id="alice",
|
| 354 |
-
db_path=temp_db,
|
| 355 |
-
_extractor=mock_extractor,
|
| 356 |
-
_store=store,
|
| 357 |
-
)
|
| 358 |
-
|
| 359 |
-
bob_client = with_memory(
|
| 360 |
-
mock_openai_client,
|
| 361 |
-
user_id="bob",
|
| 362 |
-
db_path=temp_db,
|
| 363 |
-
_extractor=mock_extractor,
|
| 364 |
-
_store=store,
|
| 365 |
-
)
|
| 366 |
-
|
| 367 |
-
# Add memories for each user
|
| 368 |
-
alice_client.memory.add("Alice's preference")
|
| 369 |
-
bob_client.memory.add("Bob's preference")
|
| 370 |
-
|
| 371 |
-
# Each should only see their own
|
| 372 |
-
assert len(alice_client.memory.get_all()) == 1
|
| 373 |
-
assert len(bob_client.memory.get_all()) == 1
|
| 374 |
-
assert "Alice" in alice_client.memory.get_all()[0].content
|
| 375 |
-
assert "Bob" in bob_client.memory.get_all()[0].content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -914,7 +914,7 @@ wheels = [
|
|
| 914 |
|
| 915 |
[[package]]
|
| 916 |
name = "headroom-ai"
|
| 917 |
-
version = "0.
|
| 918 |
source = { editable = "." }
|
| 919 |
dependencies = [
|
| 920 |
{ name = "litellm" },
|
|
@@ -2080,7 +2080,7 @@ name = "nvidia-cudnn-cu12"
|
|
| 2080 |
version = "9.10.2.21"
|
| 2081 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2082 |
dependencies = [
|
| 2083 |
-
{ name = "nvidia-cublas-cu12" },
|
| 2084 |
]
|
| 2085 |
wheels = [
|
| 2086 |
{ url = "https://pypi.netflix.net/packages/18753584630/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878 },
|
|
@@ -2092,7 +2092,7 @@ name = "nvidia-cufft-cu12"
|
|
| 2092 |
version = "11.3.3.83"
|
| 2093 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2094 |
dependencies = [
|
| 2095 |
-
{ name = "nvidia-nvjitlink-cu12" },
|
| 2096 |
]
|
| 2097 |
wheels = [
|
| 2098 |
{ url = "https://pypi.netflix.net/packages/18511525802/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211 },
|
|
@@ -2122,9 +2122,9 @@ name = "nvidia-cusolver-cu12"
|
|
| 2122 |
version = "11.7.3.90"
|
| 2123 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2124 |
dependencies = [
|
| 2125 |
-
{ name = "nvidia-cublas-cu12" },
|
| 2126 |
-
{ name = "nvidia-cusparse-cu12" },
|
| 2127 |
-
{ name = "nvidia-nvjitlink-cu12" },
|
| 2128 |
]
|
| 2129 |
wheels = [
|
| 2130 |
{ url = "https://pypi.netflix.net/packages/18511526071/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841 },
|
|
@@ -2136,7 +2136,7 @@ name = "nvidia-cusparse-cu12"
|
|
| 2136 |
version = "12.5.8.93"
|
| 2137 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2138 |
dependencies = [
|
| 2139 |
-
{ name = "nvidia-nvjitlink-cu12" },
|
| 2140 |
]
|
| 2141 |
wheels = [
|
| 2142 |
{ url = "https://pypi.netflix.net/packages/18511526166/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129 },
|
|
|
|
| 914 |
|
| 915 |
[[package]]
|
| 916 |
name = "headroom-ai"
|
| 917 |
+
version = "0.3.0"
|
| 918 |
source = { editable = "." }
|
| 919 |
dependencies = [
|
| 920 |
{ name = "litellm" },
|
|
|
|
| 2080 |
version = "9.10.2.21"
|
| 2081 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2082 |
dependencies = [
|
| 2083 |
+
{ name = "nvidia-cublas-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2084 |
]
|
| 2085 |
wheels = [
|
| 2086 |
{ url = "https://pypi.netflix.net/packages/18753584630/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878 },
|
|
|
|
| 2092 |
version = "11.3.3.83"
|
| 2093 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2094 |
dependencies = [
|
| 2095 |
+
{ name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2096 |
]
|
| 2097 |
wheels = [
|
| 2098 |
{ url = "https://pypi.netflix.net/packages/18511525802/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211 },
|
|
|
|
| 2122 |
version = "11.7.3.90"
|
| 2123 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2124 |
dependencies = [
|
| 2125 |
+
{ name = "nvidia-cublas-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2126 |
+
{ name = "nvidia-cusparse-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2127 |
+
{ name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2128 |
]
|
| 2129 |
wheels = [
|
| 2130 |
{ url = "https://pypi.netflix.net/packages/18511526071/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841 },
|
|
|
|
| 2136 |
version = "12.5.8.93"
|
| 2137 |
source = { registry = "https://pypi.netflix.net/simple" }
|
| 2138 |
dependencies = [
|
| 2139 |
+
{ name = "nvidia-nvjitlink-cu12", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
| 2140 |
]
|
| 2141 |
wheels = [
|
| 2142 |
{ url = "https://pypi.netflix.net/packages/18511526166/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129 },
|