runtime-memory 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,908 @@
1
+ """Embedding providers for Runtime Memory.
2
+
3
+ Provides vector embeddings with:
4
+ - Abstract provider interface
5
+ - Local embedding (sentence-transformers)
6
+ - API embedding (OpenAI-compatible)
7
+ - Caching layer
8
+ - Batch processing
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import time
18
+ from abc import ABC, abstractmethod
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, ClassVar
22
+
23
+ import numpy as np
24
+
25
+ from runtime_memory.core.logging import get_logger
26
+
27
+ if TYPE_CHECKING:
28
+ from numpy.typing import NDArray
29
+
30
+ logger = get_logger(__name__)
31
+
32
+ # Type alias for embeddings
33
+ Embedding = list[float]
34
+
35
+
36
+ @dataclass
37
+ class EmbeddingConfig:
38
+ """Configuration for embedding providers."""
39
+
40
+ # Model settings
41
+ model_name: str = "all-MiniLM-L6-v2"
42
+ dimensions: int | None = None # None = use model default
43
+
44
+ # Cache settings
45
+ cache_enabled: bool = True
46
+ cache_dir: Path | None = None
47
+ cache_ttl_seconds: int = 86400 * 30 # 30 days
48
+
49
+ # Batch settings
50
+ batch_size: int = 32
51
+
52
+ # API settings (for APIEmbeddingProvider)
53
+ api_base_url: str | None = None
54
+ api_key_env_var: str = "EMBEDDING_API_KEY"
55
+ api_timeout: float = 30.0
56
+ api_max_retries: int = 3
57
+
58
+
59
+ @dataclass
60
+ class EmbeddingResult:
61
+ """Result from an embedding operation."""
62
+
63
+ embedding: Embedding
64
+ model: str
65
+ dimensions: int
66
+ cached: bool = False
67
+ latency_ms: float = 0.0
68
+
69
+
70
+ @dataclass
71
+ class BatchEmbeddingResult:
72
+ """Result from a batch embedding operation."""
73
+
74
+ embeddings: list[Embedding]
75
+ model: str
76
+ dimensions: int
77
+ cached_count: int = 0
78
+ computed_count: int = 0
79
+ total_latency_ms: float = 0.0
80
+
81
+
82
+ class EmbeddingError(Exception):
83
+ """Base exception for embedding errors."""
84
+
85
+ pass
86
+
87
+
88
+ class ModelNotFoundError(EmbeddingError):
89
+ """Raised when embedding model is not found."""
90
+
91
+ pass
92
+
93
+
94
+ class APIError(EmbeddingError):
95
+ """Raised when API call fails."""
96
+
97
+ pass
98
+
99
+
100
+ class EmbeddingCache:
101
+ """File-based cache for embeddings."""
102
+
103
+ def __init__(
104
+ self,
105
+ cache_dir: Path | None = None,
106
+ ttl_seconds: int = 86400 * 30,
107
+ ) -> None:
108
+ """Initialize the cache.
109
+
110
+ Args:
111
+ cache_dir: Directory for cache files. Defaults to ~/.cache/runtime-memory/embeddings
112
+ ttl_seconds: Time-to-live for cache entries in seconds.
113
+ """
114
+ if cache_dir is None:
115
+ cache_dir = Path.home() / ".cache" / "runtime-memory" / "embeddings"
116
+ self.cache_dir = cache_dir
117
+ self.ttl_seconds = ttl_seconds
118
+ self._memory_cache: dict[str, tuple[Embedding, float]] = {}
119
+
120
+ def _get_cache_key(self, text: str, model: str) -> str:
121
+ """Generate a cache key for text and model."""
122
+ content = f"{model}:{text}"
123
+ return hashlib.sha256(content.encode()).hexdigest()
124
+
125
+ def _get_cache_path(self, key: str) -> Path:
126
+ """Get the file path for a cache key."""
127
+ # Use first 2 chars as subdirectory to avoid too many files in one dir
128
+ subdir = key[:2]
129
+ return self.cache_dir / subdir / f"{key}.json"
130
+
131
+ def get(self, text: str, model: str) -> Embedding | None:
132
+ """Get an embedding from cache.
133
+
134
+ Args:
135
+ text: The text that was embedded.
136
+ model: The model used for embedding.
137
+
138
+ Returns:
139
+ The cached embedding, or None if not found or expired.
140
+ """
141
+ key = self._get_cache_key(text, model)
142
+
143
+ # Check memory cache first
144
+ if key in self._memory_cache:
145
+ embedding, timestamp = self._memory_cache[key]
146
+ if time.time() - timestamp < self.ttl_seconds:
147
+ return embedding
148
+ del self._memory_cache[key]
149
+
150
+ # Check file cache
151
+ cache_path = self._get_cache_path(key)
152
+ if not cache_path.exists():
153
+ return None
154
+
155
+ try:
156
+ data = json.loads(cache_path.read_text())
157
+ timestamp = data.get("timestamp", 0)
158
+ if time.time() - timestamp >= self.ttl_seconds:
159
+ cache_path.unlink(missing_ok=True)
160
+ return None
161
+
162
+ cached_embedding: Embedding = data["embedding"]
163
+ # Store in memory cache for faster subsequent access
164
+ self._memory_cache[key] = (cached_embedding, timestamp)
165
+ return cached_embedding
166
+ except (json.JSONDecodeError, KeyError, OSError):
167
+ return None
168
+
169
+ def set(self, text: str, model: str, embedding: Embedding) -> None:
170
+ """Store an embedding in cache.
171
+
172
+ Args:
173
+ text: The text that was embedded.
174
+ model: The model used for embedding.
175
+ embedding: The embedding to cache.
176
+ """
177
+ key = self._get_cache_key(text, model)
178
+ timestamp = time.time()
179
+
180
+ # Store in memory cache
181
+ self._memory_cache[key] = (embedding, timestamp)
182
+
183
+ # Store in file cache
184
+ cache_path = self._get_cache_path(key)
185
+ try:
186
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
187
+ cache_path.write_text(
188
+ json.dumps(
189
+ {
190
+ "embedding": embedding,
191
+ "model": model,
192
+ "timestamp": timestamp,
193
+ }
194
+ )
195
+ )
196
+ except OSError as e:
197
+ logger.warning(f"Failed to write embedding cache: {e}")
198
+
199
+ def get_many(
200
+ self, texts: list[str], model: str
201
+ ) -> tuple[dict[int, Embedding], list[int]]:
202
+ """Get multiple embeddings from cache.
203
+
204
+ Args:
205
+ texts: List of texts to look up.
206
+ model: The model used for embedding.
207
+
208
+ Returns:
209
+ Tuple of (cached embeddings by index, list of uncached indices).
210
+ """
211
+ cached: dict[int, Embedding] = {}
212
+ uncached: list[int] = []
213
+
214
+ for i, text in enumerate(texts):
215
+ embedding = self.get(text, model)
216
+ if embedding is not None:
217
+ cached[i] = embedding
218
+ else:
219
+ uncached.append(i)
220
+
221
+ return cached, uncached
222
+
223
+ def set_many(
224
+ self, texts: list[str], model: str, embeddings: list[Embedding]
225
+ ) -> None:
226
+ """Store multiple embeddings in cache.
227
+
228
+ Args:
229
+ texts: List of texts that were embedded.
230
+ model: The model used for embedding.
231
+ embeddings: The embeddings to cache.
232
+ """
233
+ for text, embedding in zip(texts, embeddings, strict=True):
234
+ self.set(text, model, embedding)
235
+
236
+ def clear(self) -> int:
237
+ """Clear all cached embeddings.
238
+
239
+ Returns:
240
+ Number of entries cleared.
241
+ """
242
+ self._memory_cache.clear()
243
+ count = 0
244
+ if self.cache_dir.exists():
245
+ for cache_file in self.cache_dir.rglob("*.json"):
246
+ cache_file.unlink(missing_ok=True)
247
+ count += 1
248
+ return count
249
+
250
+ def clear_expired(self) -> int:
251
+ """Clear expired cache entries.
252
+
253
+ Returns:
254
+ Number of entries cleared.
255
+ """
256
+ now = time.time()
257
+ count = 0
258
+
259
+ # Clear expired memory cache entries
260
+ expired_keys = [
261
+ key
262
+ for key, (_, timestamp) in self._memory_cache.items()
263
+ if now - timestamp >= self.ttl_seconds
264
+ ]
265
+ for key in expired_keys:
266
+ del self._memory_cache[key]
267
+ count += 1
268
+
269
+ # Clear expired file cache entries
270
+ if self.cache_dir.exists():
271
+ for cache_file in self.cache_dir.rglob("*.json"):
272
+ try:
273
+ data = json.loads(cache_file.read_text())
274
+ if now - data.get("timestamp", 0) >= self.ttl_seconds:
275
+ cache_file.unlink(missing_ok=True)
276
+ count += 1
277
+ except (json.JSONDecodeError, OSError):
278
+ cache_file.unlink(missing_ok=True)
279
+ count += 1
280
+
281
+ return count
282
+
283
+
284
+ class EmbeddingProvider(ABC):
285
+ """Abstract base class for embedding providers."""
286
+
287
+ def __init__(self, config: EmbeddingConfig | None = None) -> None:
288
+ """Initialize the provider.
289
+
290
+ Args:
291
+ config: Configuration for the provider.
292
+ """
293
+ self.config = config or EmbeddingConfig()
294
+ self._cache: EmbeddingCache | None = None
295
+
296
+ if self.config.cache_enabled:
297
+ self._cache = EmbeddingCache(
298
+ cache_dir=self.config.cache_dir,
299
+ ttl_seconds=self.config.cache_ttl_seconds,
300
+ )
301
+
302
+ @property
303
+ def available(self) -> bool:
304
+ """Whether this provider can actually produce embeddings.
305
+
306
+ Providers that need an optional dependency report False when it is
307
+ missing, which lets retrieval fall back to keyword matching instead of
308
+ raising.
309
+ """
310
+ return True
311
+
312
+ @property
313
+ @abstractmethod
314
+ def model_name(self) -> str:
315
+ """Get the model name."""
316
+ ...
317
+
318
+ @property
319
+ @abstractmethod
320
+ def dimensions(self) -> int:
321
+ """Get the embedding dimensions."""
322
+ ...
323
+
324
+ @abstractmethod
325
+ async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
326
+ """Embed multiple texts (internal implementation).
327
+
328
+ Args:
329
+ texts: Texts to embed.
330
+
331
+ Returns:
332
+ List of embeddings.
333
+ """
334
+ ...
335
+
336
+ async def embed(self, text: str) -> EmbeddingResult:
337
+ """Embed a single text.
338
+
339
+ Args:
340
+ text: Text to embed.
341
+
342
+ Returns:
343
+ Embedding result.
344
+ """
345
+ start_time = time.time()
346
+
347
+ # Check cache
348
+ if self._cache:
349
+ cached = self._cache.get(text, self.model_name)
350
+ if cached is not None:
351
+ return EmbeddingResult(
352
+ embedding=cached,
353
+ model=self.model_name,
354
+ dimensions=len(cached),
355
+ cached=True,
356
+ latency_ms=(time.time() - start_time) * 1000,
357
+ )
358
+
359
+ # Compute embedding
360
+ embeddings = await self._embed_texts([text])
361
+ embedding = embeddings[0]
362
+
363
+ # Apply dimension reduction if configured
364
+ if self.config.dimensions and len(embedding) > self.config.dimensions:
365
+ embedding = embedding[: self.config.dimensions]
366
+
367
+ # Cache result
368
+ if self._cache:
369
+ self._cache.set(text, self.model_name, embedding)
370
+
371
+ return EmbeddingResult(
372
+ embedding=embedding,
373
+ model=self.model_name,
374
+ dimensions=len(embedding),
375
+ cached=False,
376
+ latency_ms=(time.time() - start_time) * 1000,
377
+ )
378
+
379
+ async def embed_many(self, texts: list[str]) -> BatchEmbeddingResult:
380
+ """Embed multiple texts with batching and caching.
381
+
382
+ Args:
383
+ texts: Texts to embed.
384
+
385
+ Returns:
386
+ Batch embedding result.
387
+ """
388
+ if not texts:
389
+ return BatchEmbeddingResult(
390
+ embeddings=[],
391
+ model=self.model_name,
392
+ dimensions=self.dimensions,
393
+ )
394
+
395
+ start_time = time.time()
396
+ result_embeddings: list[Embedding | None] = [None] * len(texts)
397
+ cached_count = 0
398
+
399
+ # Check cache for all texts
400
+ if self._cache:
401
+ cached, uncached_indices = self._cache.get_many(texts, self.model_name)
402
+ for idx, embedding in cached.items():
403
+ result_embeddings[idx] = embedding
404
+ cached_count += 1
405
+ texts_to_embed = [texts[i] for i in uncached_indices]
406
+ indices_to_embed = uncached_indices
407
+ else:
408
+ texts_to_embed = texts
409
+ indices_to_embed = list(range(len(texts)))
410
+
411
+ # Embed uncached texts in batches
412
+ computed_count = 0
413
+ for batch_start in range(0, len(texts_to_embed), self.config.batch_size):
414
+ batch_end = min(batch_start + self.config.batch_size, len(texts_to_embed))
415
+ batch_texts = texts_to_embed[batch_start:batch_end]
416
+ batch_indices = indices_to_embed[batch_start:batch_end]
417
+
418
+ batch_embeddings = await self._embed_texts(batch_texts)
419
+
420
+ for i, emb in enumerate(batch_embeddings):
421
+ # Apply dimension reduction if configured
422
+ final_emb = emb
423
+ if self.config.dimensions and len(emb) > self.config.dimensions:
424
+ final_emb = emb[: self.config.dimensions]
425
+
426
+ idx = batch_indices[i]
427
+ result_embeddings[idx] = final_emb
428
+ computed_count += 1
429
+
430
+ # Cache computed embeddings
431
+ if self._cache:
432
+ self._cache.set_many(batch_texts, self.model_name, batch_embeddings)
433
+
434
+ # Ensure all embeddings are present
435
+ final_embeddings: list[Embedding] = []
436
+ for result_emb in result_embeddings:
437
+ if result_emb is None:
438
+ raise EmbeddingError("Missing embedding in result")
439
+ final_embeddings.append(result_emb)
440
+
441
+ return BatchEmbeddingResult(
442
+ embeddings=final_embeddings,
443
+ model=self.model_name,
444
+ dimensions=self.dimensions,
445
+ cached_count=cached_count,
446
+ computed_count=computed_count,
447
+ total_latency_ms=(time.time() - start_time) * 1000,
448
+ )
449
+
450
+ def cosine_similarity(
451
+ self, embedding1: Embedding, embedding2: Embedding
452
+ ) -> float:
453
+ """Compute cosine similarity between two embeddings.
454
+
455
+ Args:
456
+ embedding1: First embedding.
457
+ embedding2: Second embedding.
458
+
459
+ Returns:
460
+ Cosine similarity score (-1 to 1).
461
+ """
462
+ arr1: NDArray[np.floating[Any]] = np.array(embedding1)
463
+ arr2: NDArray[np.floating[Any]] = np.array(embedding2)
464
+
465
+ norm1 = np.linalg.norm(arr1)
466
+ norm2 = np.linalg.norm(arr2)
467
+
468
+ if norm1 == 0 or norm2 == 0:
469
+ return 0.0
470
+
471
+ return float(np.dot(arr1, arr2) / (norm1 * norm2))
472
+
473
+ def find_most_similar(
474
+ self,
475
+ query_embedding: Embedding,
476
+ embeddings: list[Embedding],
477
+ top_k: int = 5,
478
+ ) -> list[tuple[int, float]]:
479
+ """Find the most similar embeddings to a query.
480
+
481
+ Args:
482
+ query_embedding: The query embedding.
483
+ embeddings: List of embeddings to search.
484
+ top_k: Number of top results to return.
485
+
486
+ Returns:
487
+ List of (index, similarity_score) tuples, sorted by score descending.
488
+ """
489
+ if not embeddings:
490
+ return []
491
+
492
+ query_arr: NDArray[np.floating[Any]] = np.array(query_embedding)
493
+ embeddings_arr: NDArray[np.floating[Any]] = np.array(embeddings)
494
+
495
+ # Compute all similarities at once
496
+ query_norm = np.linalg.norm(query_arr)
497
+ if query_norm == 0:
498
+ return [(i, 0.0) for i in range(min(top_k, len(embeddings)))]
499
+
500
+ embeddings_norms = np.linalg.norm(embeddings_arr, axis=1)
501
+ # Avoid division by zero
502
+ embeddings_norms = np.where(embeddings_norms == 0, 1, embeddings_norms)
503
+
504
+ similarities = np.dot(embeddings_arr, query_arr) / (embeddings_norms * query_norm)
505
+
506
+ # Get top-k indices
507
+ if top_k >= len(similarities):
508
+ top_indices = np.argsort(similarities)[::-1]
509
+ else:
510
+ top_indices = np.argpartition(similarities, -top_k)[-top_k:]
511
+ top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]
512
+
513
+ return [(int(idx), float(similarities[idx])) for idx in top_indices]
514
+
515
+ def clear_cache(self) -> int:
516
+ """Clear the embedding cache.
517
+
518
+ Returns:
519
+ Number of entries cleared.
520
+ """
521
+ if self._cache:
522
+ return self._cache.clear()
523
+ return 0
524
+
525
+
526
+ class LocalEmbeddingProvider(EmbeddingProvider):
527
+ """Embedding provider using sentence-transformers (local models)."""
528
+
529
+ def __init__(self, config: EmbeddingConfig | None = None) -> None:
530
+ """Initialize the local provider.
531
+
532
+ Args:
533
+ config: Configuration for the provider.
534
+ """
535
+ super().__init__(config)
536
+ self._model: Any = None
537
+ self._model_name: str = self.config.model_name
538
+ self._dimensions: int | None = None
539
+
540
+ def _load_model(self) -> Any:
541
+ """Load the sentence-transformers model."""
542
+ if self._model is not None:
543
+ return self._model
544
+
545
+ try:
546
+ from sentence_transformers import SentenceTransformer # noqa: PLC0415
547
+ except ImportError as e:
548
+ raise ModelNotFoundError(
549
+ "sentence-transformers not installed. "
550
+ "Install with: pip install 'runtime-memory[phase1]'"
551
+ ) from e
552
+
553
+ try:
554
+ self._model = SentenceTransformer(self._model_name)
555
+ # Get model dimensions
556
+ self._dimensions = self._model.get_sentence_embedding_dimension()
557
+ logger.info(
558
+ f"Loaded model {self._model_name} with {self._dimensions} dimensions"
559
+ )
560
+ return self._model
561
+ except Exception as e:
562
+ raise ModelNotFoundError(
563
+ f"Failed to load model {self._model_name}: {e}"
564
+ ) from e
565
+
566
+ @property
567
+ def model_name(self) -> str:
568
+ """Get the model name."""
569
+ return self._model_name
570
+
571
+ @property
572
+ def dimensions(self) -> int:
573
+ """Get the embedding dimensions."""
574
+ if self._dimensions is None:
575
+ self._load_model()
576
+ return self._dimensions or 384 # Default for MiniLM
577
+
578
+ async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
579
+ """Embed multiple texts using sentence-transformers.
580
+
581
+ Args:
582
+ texts: Texts to embed.
583
+
584
+ Returns:
585
+ List of embeddings.
586
+ """
587
+ import asyncio # noqa: PLC0415
588
+
589
+ model = self._load_model()
590
+
591
+ # Run in thread pool to avoid blocking
592
+ loop = asyncio.get_event_loop()
593
+ embeddings = await loop.run_in_executor(
594
+ None,
595
+ lambda: model.encode(
596
+ texts,
597
+ convert_to_numpy=True,
598
+ show_progress_bar=False,
599
+ ),
600
+ )
601
+
602
+ return [emb.tolist() for emb in embeddings]
603
+
604
+
605
+ class APIEmbeddingProvider(EmbeddingProvider):
606
+ """Embedding provider using API (OpenAI-compatible)."""
607
+
608
+ # Known model dimensions
609
+ MODEL_DIMENSIONS: ClassVar[dict[str, int]] = {
610
+ "text-embedding-3-small": 1536,
611
+ "text-embedding-3-large": 3072,
612
+ "text-embedding-ada-002": 1536,
613
+ "voyage-large-2": 1024,
614
+ "voyage-code-2": 1536,
615
+ }
616
+
617
+ # Default API endpoints
618
+ DEFAULT_API_URLS: ClassVar[dict[str, str]] = {
619
+ "openai": "https://api.openai.com/v1/embeddings",
620
+ "voyage": "https://api.voyageai.com/v1/embeddings",
621
+ }
622
+
623
+ def __init__(
624
+ self,
625
+ config: EmbeddingConfig | None = None,
626
+ api_provider: str = "openai",
627
+ ) -> None:
628
+ """Initialize the API provider.
629
+
630
+ Args:
631
+ config: Configuration for the provider.
632
+ api_provider: API provider name (openai, voyage).
633
+ """
634
+ super().__init__(config)
635
+ self._api_provider = api_provider
636
+
637
+ # Set default model based on provider
638
+ if self.config.model_name == "all-MiniLM-L6-v2": # Default local model
639
+ if api_provider == "openai":
640
+ self.config.model_name = "text-embedding-3-small"
641
+ elif api_provider == "voyage":
642
+ self.config.model_name = "voyage-large-2"
643
+
644
+ # Set API URL
645
+ if not self.config.api_base_url:
646
+ self.config.api_base_url = self.DEFAULT_API_URLS.get(
647
+ api_provider, self.DEFAULT_API_URLS["openai"]
648
+ )
649
+
650
+ def _get_api_key(self) -> str:
651
+ """Get the API key from environment."""
652
+ # Try provider-specific env var first
653
+ provider_env_vars = {
654
+ "openai": "OPENAI_API_KEY",
655
+ "voyage": "VOYAGE_API_KEY",
656
+ }
657
+ env_var = provider_env_vars.get(self._api_provider, self.config.api_key_env_var)
658
+
659
+ api_key = os.environ.get(env_var) or os.environ.get(self.config.api_key_env_var)
660
+ if not api_key:
661
+ raise APIError(
662
+ f"API key not found. Set {env_var} or {self.config.api_key_env_var} "
663
+ "environment variable."
664
+ )
665
+ return api_key
666
+
667
+ @property
668
+ def model_name(self) -> str:
669
+ """Get the model name."""
670
+ return self.config.model_name
671
+
672
+ @property
673
+ def dimensions(self) -> int:
674
+ """Get the embedding dimensions."""
675
+ if self.config.dimensions:
676
+ return self.config.dimensions
677
+ return self.MODEL_DIMENSIONS.get(self.config.model_name, 1536)
678
+
679
+ async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
680
+ """Embed multiple texts using API.
681
+
682
+ Args:
683
+ texts: Texts to embed.
684
+
685
+ Returns:
686
+ List of embeddings.
687
+ """
688
+ import httpx # noqa: PLC0415
689
+
690
+ api_key = self._get_api_key()
691
+
692
+ headers = {
693
+ "Authorization": f"Bearer {api_key}",
694
+ "Content-Type": "application/json",
695
+ }
696
+
697
+ payload: dict[str, Any] = {
698
+ "input": texts,
699
+ "model": self.config.model_name,
700
+ }
701
+
702
+ # Add dimensions parameter if supported and configured
703
+ if self.config.dimensions and self.config.model_name.startswith("text-embedding-3"):
704
+ payload["dimensions"] = self.config.dimensions
705
+
706
+ last_error: Exception | None = None
707
+ for attempt in range(self.config.api_max_retries):
708
+ try:
709
+ async with httpx.AsyncClient(timeout=self.config.api_timeout) as client:
710
+ response = await client.post(
711
+ self.config.api_base_url or "",
712
+ headers=headers,
713
+ json=payload,
714
+ )
715
+
716
+ if response.status_code == 429:
717
+ # Rate limited, wait and retry
718
+ wait_time = min(2**attempt, 32)
719
+ logger.warning(f"Rate limited, waiting {wait_time}s before retry")
720
+ import asyncio # noqa: PLC0415
721
+
722
+ await asyncio.sleep(wait_time)
723
+ continue
724
+
725
+ response.raise_for_status()
726
+ data = response.json()
727
+
728
+ # Extract embeddings (sorted by index)
729
+ embedding_data = sorted(data["data"], key=lambda x: x["index"])
730
+ return [item["embedding"] for item in embedding_data]
731
+
732
+ except httpx.HTTPStatusError as e:
733
+ last_error = APIError(f"API request failed: {e.response.status_code} - {e.response.text}")
734
+ except httpx.RequestError as e:
735
+ last_error = APIError(f"API request failed: {e}")
736
+ except (KeyError, json.JSONDecodeError) as e:
737
+ last_error = APIError(f"Invalid API response: {e}")
738
+
739
+ if attempt < self.config.api_max_retries - 1:
740
+ import asyncio # noqa: PLC0415
741
+
742
+ await asyncio.sleep(2**attempt)
743
+
744
+ raise last_error or APIError("API request failed after retries")
745
+
746
+
747
+ class MockEmbeddingProvider(EmbeddingProvider):
748
+ """Mock embedding provider for testing."""
749
+
750
+ def __init__(
751
+ self,
752
+ config: EmbeddingConfig | None = None,
753
+ fixed_embedding: Embedding | None = None,
754
+ ) -> None:
755
+ """Initialize the mock provider.
756
+
757
+ Args:
758
+ config: Configuration for the provider.
759
+ fixed_embedding: Fixed embedding to return (for deterministic tests).
760
+ """
761
+ # Disable cache for mock provider by default
762
+ if config is None:
763
+ config = EmbeddingConfig(cache_enabled=False)
764
+ super().__init__(config)
765
+ self._fixed_embedding = fixed_embedding
766
+ self._call_count = 0
767
+ self._embedded_texts: list[str] = []
768
+
769
+ @property
770
+ def model_name(self) -> str:
771
+ """Get the model name."""
772
+ return "mock-embedding-model"
773
+
774
+ @property
775
+ def dimensions(self) -> int:
776
+ """Get the embedding dimensions."""
777
+ return self.config.dimensions or 384
778
+
779
+ @property
780
+ def call_count(self) -> int:
781
+ """Get the number of embedding calls made."""
782
+ return self._call_count
783
+
784
+ @property
785
+ def embedded_texts(self) -> list[str]:
786
+ """Get all texts that were embedded."""
787
+ return self._embedded_texts
788
+
789
+ def reset(self) -> None:
790
+ """Reset call tracking."""
791
+ self._call_count = 0
792
+ self._embedded_texts = []
793
+
794
+ async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
795
+ """Generate mock embeddings.
796
+
797
+ Args:
798
+ texts: Texts to embed.
799
+
800
+ Returns:
801
+ List of mock embeddings.
802
+ """
803
+ self._call_count += 1
804
+ self._embedded_texts.extend(texts)
805
+
806
+ embeddings: list[Embedding] = []
807
+ for text in texts:
808
+ if self._fixed_embedding:
809
+ embeddings.append(self._fixed_embedding.copy())
810
+ else:
811
+ # Generate deterministic embedding based on text hash
812
+ # MD5 is used only for creating deterministic seeds, not for security
813
+ text_hash = hashlib.md5(text.encode(), usedforsecurity=False).hexdigest() # noqa: S324
814
+ seed = int(text_hash[:8], 16)
815
+ rng = np.random.default_rng(seed)
816
+ embedding = rng.random(self.dimensions).tolist()
817
+ # Normalize
818
+ norm = sum(x**2 for x in embedding) ** 0.5
819
+ embeddings.append([x / norm for x in embedding])
820
+
821
+ return embeddings
822
+
823
+
824
+ class NullEmbeddingProvider(EmbeddingProvider):
825
+ """Fallback provider used when no embedding backend is installed.
826
+
827
+ Returns an empty embedding for every text. Nothing is written to the vector
828
+ index, so retrieval scores memories with BM25 keyword matching alone, which
829
+ needs no third-party packages. This keeps a core ``pip install
830
+ runtime-memory`` usable; install the ``embedding`` extra to turn semantic
831
+ search back on.
832
+ """
833
+
834
+ def __init__(self, config: EmbeddingConfig | None = None) -> None:
835
+ """Initialize the null provider.
836
+
837
+ Args:
838
+ config: Configuration for the provider.
839
+ """
840
+ if config is None:
841
+ config = EmbeddingConfig(cache_enabled=False)
842
+ super().__init__(config)
843
+
844
+ @property
845
+ def available(self) -> bool:
846
+ """Null provider never produces usable embeddings."""
847
+ return False
848
+
849
+ @property
850
+ def model_name(self) -> str:
851
+ """Get the model name."""
852
+ return "null-embedding-provider"
853
+
854
+ @property
855
+ def dimensions(self) -> int:
856
+ """Get the embedding dimensions."""
857
+ return 0
858
+
859
+ async def _embed_texts(self, texts: list[str]) -> list[Embedding]:
860
+ """Return an empty embedding per text.
861
+
862
+ Args:
863
+ texts: Texts to embed.
864
+
865
+ Returns:
866
+ One empty embedding for each input text.
867
+ """
868
+ return [[] for _ in texts]
869
+
870
+
871
+ def get_embedding_provider(
872
+ provider_type: str = "local",
873
+ config: EmbeddingConfig | None = None,
874
+ **kwargs: Any,
875
+ ) -> EmbeddingProvider:
876
+ """Factory function to create embedding providers.
877
+
878
+ Args:
879
+ provider_type: Type of provider ("local", "openai", "voyage", "mock",
880
+ "null").
881
+ config: Configuration for the provider.
882
+ **kwargs: Additional arguments passed to the provider.
883
+
884
+ Returns:
885
+ Embedding provider instance.
886
+ """
887
+ if provider_type == "local":
888
+ if importlib.util.find_spec("sentence_transformers") is None:
889
+ # Info, not a warning: keyword retrieval is a supported way to run,
890
+ # and the CLI builds a provider for every command, so a warning here
891
+ # would print on each one. `mem check` reports the same state.
892
+ logger.info(
893
+ "sentence-transformers is not installed, so semantic search is "
894
+ "disabled and retrieval will use keyword matching only. "
895
+ "Install with: pip install 'runtime-memory[embedding]'"
896
+ )
897
+ return NullEmbeddingProvider(config)
898
+ return LocalEmbeddingProvider(config)
899
+ elif provider_type == "openai":
900
+ return APIEmbeddingProvider(config, api_provider="openai")
901
+ elif provider_type == "voyage":
902
+ return APIEmbeddingProvider(config, api_provider="voyage")
903
+ elif provider_type == "mock":
904
+ return MockEmbeddingProvider(config, **kwargs)
905
+ elif provider_type == "null":
906
+ return NullEmbeddingProvider(config)
907
+ else:
908
+ raise ValueError(f"Unknown provider type: {provider_type}")