chopratejas commited on
Commit
04a98a0
·
1 Parent(s): 339885c

Replace legacy memory system with HierarchicalMemory

Browse files

Major 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 CHANGED
@@ -113,8 +113,16 @@ from .exceptions import (
113
  ValidationError,
114
  )
115
 
116
- # Memory module - simple, LLM-driven memory
117
- from .memory import Memory, SQLiteMemoryStore, with_memory
 
 
 
 
 
 
 
 
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.2.15"
139
 
140
  __all__ = [
141
  # Main client
@@ -205,8 +213,12 @@ __all__ = [
205
  "count_tokens_text",
206
  "count_tokens_messages",
207
  "generate_report",
208
- # Memory - simple, LLM-driven memory
209
- "with_memory",
210
  "Memory",
211
- "SQLiteMemoryStore",
 
 
 
 
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
  ]
headroom/memory/__init__.py CHANGED
@@ -1,37 +1,108 @@
1
- """Headroom Memory - Simple, LLM-driven memory for AI applications.
2
 
3
- Two approaches available:
 
 
 
 
 
 
 
 
 
4
 
5
- 1. Background extraction (original):
6
- from headroom import with_memory
7
  client = with_memory(OpenAI(), user_id="alice")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- 2. Zero-latency inline extraction (recommended):
10
- from headroom.memory import with_fast_memory
11
- client = with_fast_memory(OpenAI(), user_id="alice")
 
 
 
 
 
12
  """
13
 
14
- from headroom.memory.fast_store import FastMemoryStore, MemoryChunk
15
- from headroom.memory.fast_wrapper import with_fast_memory
16
- from headroom.memory.inline_extractor import (
17
- InlineMemoryWrapper,
18
- inject_memory_instruction,
19
- parse_response_with_memory,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  )
21
- from headroom.memory.store import Memory, SQLiteMemoryStore
22
- from headroom.memory.wrapper import with_memory
 
23
 
24
  __all__ = [
25
- # Original approach (background extraction)
26
  "with_memory",
 
 
 
 
27
  "Memory",
28
- "SQLiteMemoryStore",
29
- # Fast approach (inline extraction - recommended)
30
- "with_fast_memory",
31
- "FastMemoryStore",
32
- "MemoryChunk",
33
- # Low-level inline extraction
34
- "InlineMemoryWrapper",
35
- "inject_memory_instruction",
36
- "parse_response_with_memory",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  ]
headroom/memory/adapters/__init__.py ADDED
@@ -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
+ ]
headroom/memory/adapters/cache.py ADDED
@@ -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
+ }
headroom/memory/adapters/embedders.py ADDED
@@ -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()
headroom/memory/adapters/fts5.py ADDED
@@ -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
headroom/memory/adapters/hnsw.py ADDED
@@ -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)
headroom/memory/adapters/sqlite.py ADDED
@@ -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)
headroom/memory/config.py ADDED
@@ -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)
headroom/memory/core.py ADDED
@@ -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
headroom/memory/extractor.py DELETED
@@ -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 {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/memory/factory.py ADDED
@@ -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]
headroom/memory/fast_store.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/memory/fast_wrapper.py DELETED
@@ -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
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/memory/models.py ADDED
@@ -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
+ )
headroom/memory/ports.py ADDED
@@ -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
+ ...
headroom/memory/store.py DELETED
@@ -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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/memory/worker.py DELETED
@@ -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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headroom/memory/wrapper.py CHANGED
@@ -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.extractor import MemoryExtractor
15
- from headroom.memory.store import Memory, SQLiteMemoryStore
16
- from headroom.memory.worker import ExtractionWorker
 
 
 
 
 
 
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. AFTER: Queue conversation for background memory extraction
 
25
 
26
- The system prompt is left unchanged to preserve prompt caching.
27
 
28
  Usage:
29
- client = MemoryWrapper(OpenAI(), user_id="alice")
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
- _extractor: Any = None, # For testing - inject mock
41
- _store: SQLiteMemoryStore | None = None, # For testing
 
 
 
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
- _extractor: Override extractor (for testing)
52
- _store: Override store (for testing)
 
 
 
53
  """
54
  self._client = client
55
  self._user_id = user_id
 
 
56
  self._top_k = top_k
57
-
58
- # Initialize store
59
- self._store = _store or SQLiteMemoryStore(db_path)
60
-
61
- # Initialize extractor
62
- self._extractor = _extractor or MemoryExtractor(client, model=extraction_model)
63
-
64
- # Initialize background worker with shorter wait for responsiveness
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._worker.start()
71
 
72
  # Create wrapped chat interface
73
  self.chat = _WrappedChat(self)
74
 
75
- def flush_extractions(self, timeout: float = 60.0) -> bool:
76
- """Force immediate processing of all queued extractions.
77
-
78
- Useful for testing or when you need to ensure memories are saved.
79
-
80
- Args:
81
- timeout: Max time to wait in seconds
82
-
83
- Returns:
84
- True if all extractions completed, False if timed out
85
- """
86
- return self._worker.flush(timeout=timeout)
87
 
88
  @property
89
  def memory(self) -> _MemoryAPI:
90
  """Direct access to memory operations."""
91
- return _MemoryAPI(self._store, self._user_id)
 
 
 
 
 
 
 
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
- # Find the last user message
 
 
 
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
- memories = self._store.search(
117
- self._user_id,
118
- str(user_content),
119
- top_k=self._top_k,
120
- )
 
 
 
 
 
 
 
121
 
122
  if not memories:
123
  return messages
124
 
125
- # Build context block
126
  context_lines = ["<context>"]
127
- for mem in memories:
128
- context_lines.append(f"- {mem.content}")
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 _queue_extraction(self, query: str, response: str) -> None:
143
- """Queue conversation for background memory extraction.
144
 
145
  Args:
146
- query: User's message
147
- response: Assistant's response
148
  """
149
- self._worker.schedule(self._user_id, query, response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  class _WrappedChat:
@@ -158,66 +221,77 @@ class _WrappedChat:
158
 
159
 
160
  class _WrappedCompletions:
161
- """Wrapped completions that add memory to requests."""
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
- This intercepts the request to:
170
- 1. Inject relevant memories into user message
171
- 2. Forward to the real client
172
- 3. Queue response for background extraction
 
 
 
 
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
- # 2. Forward to real client
183
  response = self._wrapper._client.chat.completions.create(**kwargs)
184
 
185
- # 3. Queue for extraction (non-blocking)
186
- self._extract_and_queue(messages, response)
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
- if not user_query:
200
- return
 
 
201
 
202
- # Get assistant response
203
- try:
204
- assistant_response = response.choices[0].message.content
205
- except (AttributeError, IndexError):
206
- return
207
 
208
- if assistant_response:
209
- self._wrapper._queue_extraction(user_query, assistant_response)
210
 
211
 
212
  class _MemoryAPI:
213
  """Direct API for memory operations."""
214
 
215
- def __init__(self, store: SQLiteMemoryStore, user_id: str):
216
- self._store = store
 
 
 
 
 
 
217
  self._user_id = user_id
 
 
 
 
 
 
 
 
 
 
218
 
219
  def search(self, query: str, top_k: int = 5) -> list[Memory]:
220
- """Search memories.
221
 
222
  Args:
223
  query: Search query
@@ -226,67 +300,113 @@ class _MemoryAPI:
226
  Returns:
227
  Matching memories
228
  """
229
- return self._store.search(self._user_id, query, top_k)
 
 
 
 
 
 
 
 
 
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
- memory = Memory(
248
- content=content,
249
- category=category, # type: ignore
250
- importance=importance,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  )
252
- self._store.save(self._user_id, memory)
253
- return memory
254
 
255
  def get_all(self) -> list[Memory]:
256
  """Get all memories for this user."""
257
- return self._store.get_all(self._user_id)
258
 
259
- def delete(self, memory_id: str) -> bool:
260
- """Delete a specific memory."""
261
- return self._store.delete(self._user_id, memory_id)
262
 
263
  def clear(self) -> int:
264
  """Clear all memories for this user."""
265
- return self._store.clear(self._user_id)
 
266
 
267
  def stats(self) -> dict:
268
  """Get memory statistics."""
269
- return self._store.stats(self._user_id)
 
 
 
 
 
 
 
 
 
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
- One-line integration for adding persistent memory to any LLM client.
 
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 in background
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
  )
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "headroom-ai"
7
- version = "0.2.15"
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"
tests/conftest.py CHANGED
@@ -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
tests/test_memory/conftest.py CHANGED
@@ -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
- import tempfile
11
- from pathlib import Path
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
- return [
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"
 
 
 
 
tests/test_memory/test_extractor.py DELETED
@@ -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"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_memory/test_hierarchical.py ADDED
@@ -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
tests/test_memory/test_inline_extractor.py DELETED
@@ -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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_memory/test_store.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_memory/test_wrapper.py DELETED
@@ -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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
uv.lock CHANGED
@@ -914,7 +914,7 @@ wheels = [
914
 
915
  [[package]]
916
  name = "headroom-ai"
917
- version = "0.2.15"
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 },