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,819 @@
1
+ """Hybrid retrieval system for Runtime Memory.
2
+
3
+ Provides intelligent memory retrieval with:
4
+ - BM25 text search
5
+ - Vector similarity search
6
+ - Hybrid scoring combining multiple signals
7
+ - Recency decay
8
+ - Frequency boosting
9
+ - Category routing
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from typing import ClassVar
20
+
21
+ from runtime_memory.core.embeddings import EmbeddingProvider # noqa: TC001
22
+ from runtime_memory.core.logging import get_logger
23
+ from runtime_memory.core.models import Memory, MemoryCategory, SearchResult
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ # Type alias for embeddings (list of floats)
28
+ EmbeddingVector = list[float]
29
+
30
+
31
+ @dataclass
32
+ class RetrievalConfig:
33
+ """Configuration for the retrieval system."""
34
+
35
+ # Scoring weights (should sum to 1.0 for normalized scoring)
36
+ semantic_weight: float = 0.5
37
+ recency_weight: float = 0.25
38
+ frequency_weight: float = 0.15
39
+ outcome_weight: float = 0.1
40
+
41
+ # BM25 parameters
42
+ bm25_k1: float = 1.5 # Term frequency saturation
43
+ bm25_b: float = 0.75 # Length normalization
44
+
45
+ # Vector search parameters
46
+ vector_weight: float = 0.6 # Weight of vector vs BM25 in semantic score
47
+ min_vector_similarity: float = 0.3 # Minimum similarity threshold
48
+
49
+ # Recency decay parameters
50
+ recency_half_life_days: float = 30.0 # Half-life for recency decay
51
+
52
+ # Frequency boosting parameters
53
+ frequency_log_base: float = 2.0 # Log base for frequency scaling
54
+ max_frequency_boost: float = 2.0 # Maximum frequency boost
55
+
56
+ # Category boosting
57
+ category_boosts: dict[MemoryCategory, float] = field(default_factory=dict)
58
+
59
+ # Result settings
60
+ default_limit: int = 10
61
+ max_limit: int = 100
62
+ dedup_threshold: float = 0.95 # Similarity threshold for deduplication
63
+
64
+ def __post_init__(self) -> None:
65
+ """Set default category boosts if not provided."""
66
+ if not self.category_boosts:
67
+ self.category_boosts = {
68
+ MemoryCategory.GOTCHA: 1.3, # Boost gotchas (important warnings)
69
+ MemoryCategory.TROUBLESHOOTING: 1.2, # Boost troubleshooting
70
+ MemoryCategory.DECISION: 1.1, # Slight boost for decisions
71
+ MemoryCategory.WORKAROUND: 1.1, # Boost workarounds
72
+ MemoryCategory.PREFERENCE: 1.0,
73
+ MemoryCategory.PATTERN: 1.0,
74
+ MemoryCategory.ARCHITECTURE: 1.0,
75
+ MemoryCategory.CONVENTION: 0.9,
76
+ MemoryCategory.COMMAND: 0.9,
77
+ }
78
+
79
+
80
+ class BM25Index:
81
+ """BM25 index for text search.
82
+
83
+ Implements the Okapi BM25 ranking function for text retrieval.
84
+ """
85
+
86
+ def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
87
+ """Initialize BM25 index.
88
+
89
+ Args:
90
+ k1: Term frequency saturation parameter.
91
+ b: Length normalization parameter.
92
+ """
93
+ self.k1 = k1
94
+ self.b = b
95
+
96
+ # Document data
97
+ self._docs: dict[str, list[str]] = {} # doc_id -> tokens
98
+ self._doc_lengths: dict[str, int] = {}
99
+ self._avg_doc_length: float = 0.0
100
+
101
+ # Term statistics
102
+ self._doc_freqs: Counter[str] = Counter() # term -> document frequency
103
+ self._term_freqs: dict[str, Counter[str]] = {} # doc_id -> term -> frequency
104
+
105
+ def _tokenize(self, text: str) -> list[str]:
106
+ """Tokenize text into lowercase words.
107
+
108
+ Args:
109
+ text: Text to tokenize.
110
+
111
+ Returns:
112
+ List of tokens.
113
+ """
114
+ # Simple tokenization: lowercase, split on non-alphanumeric
115
+ text = text.lower()
116
+ tokens = re.findall(r"\b\w+\b", text)
117
+ return tokens
118
+
119
+ def add_document(self, doc_id: str, text: str) -> None:
120
+ """Add a document to the index.
121
+
122
+ Args:
123
+ doc_id: Document identifier.
124
+ text: Document text.
125
+ """
126
+ tokens = self._tokenize(text)
127
+ self._docs[doc_id] = tokens
128
+ self._doc_lengths[doc_id] = len(tokens)
129
+
130
+ # Update term frequencies
131
+ term_freq: Counter[str] = Counter(tokens)
132
+ self._term_freqs[doc_id] = term_freq
133
+
134
+ # Update document frequencies (count each term once per doc)
135
+ for term in set(tokens):
136
+ self._doc_freqs[term] += 1
137
+
138
+ # Update average document length
139
+ self._avg_doc_length = sum(self._doc_lengths.values()) / len(self._doc_lengths)
140
+
141
+ def remove_document(self, doc_id: str) -> None:
142
+ """Remove a document from the index.
143
+
144
+ Args:
145
+ doc_id: Document identifier.
146
+ """
147
+ if doc_id not in self._docs:
148
+ return
149
+
150
+ # Update document frequencies
151
+ for term in set(self._docs[doc_id]):
152
+ self._doc_freqs[term] -= 1
153
+ if self._doc_freqs[term] <= 0:
154
+ del self._doc_freqs[term]
155
+
156
+ # Remove document data
157
+ del self._docs[doc_id]
158
+ del self._doc_lengths[doc_id]
159
+ del self._term_freqs[doc_id]
160
+
161
+ # Update average document length
162
+ if self._doc_lengths:
163
+ self._avg_doc_length = sum(self._doc_lengths.values()) / len(self._doc_lengths)
164
+ else:
165
+ self._avg_doc_length = 0.0
166
+
167
+ def clear(self) -> None:
168
+ """Clear all documents from the index."""
169
+ self._docs.clear()
170
+ self._doc_lengths.clear()
171
+ self._term_freqs.clear()
172
+ self._doc_freqs.clear()
173
+ self._avg_doc_length = 0.0
174
+
175
+ def search(self, query: str, top_k: int = 10) -> list[tuple[str, float]]:
176
+ """Search for documents matching query.
177
+
178
+ Args:
179
+ query: Search query.
180
+ top_k: Number of top results to return.
181
+
182
+ Returns:
183
+ List of (doc_id, score) tuples sorted by score descending.
184
+ """
185
+ if not self._docs:
186
+ return []
187
+
188
+ query_tokens = self._tokenize(query)
189
+ if not query_tokens:
190
+ return []
191
+
192
+ n_docs = len(self._docs)
193
+ scores: dict[str, float] = {}
194
+
195
+ for doc_id in self._docs:
196
+ score = 0.0
197
+ doc_length = self._doc_lengths[doc_id]
198
+ term_freqs = self._term_freqs[doc_id]
199
+
200
+ for term in query_tokens:
201
+ if term not in self._doc_freqs:
202
+ continue
203
+
204
+ # Document frequency
205
+ df = self._doc_freqs[term]
206
+
207
+ # Inverse document frequency
208
+ idf = math.log((n_docs - df + 0.5) / (df + 0.5) + 1.0)
209
+
210
+ # Term frequency in document
211
+ tf = term_freqs.get(term, 0)
212
+
213
+ # BM25 score component
214
+ length_norm = 1 - self.b + self.b * (doc_length / self._avg_doc_length)
215
+ tf_component = (tf * (self.k1 + 1)) / (tf + self.k1 * length_norm)
216
+ score += idf * tf_component
217
+
218
+ if score > 0:
219
+ scores[doc_id] = score
220
+
221
+ # Sort by score descending and return top_k
222
+ sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
223
+ return sorted_results[:top_k]
224
+
225
+ def score_document(self, doc_id: str, query: str) -> float:
226
+ """Score a specific document against a query.
227
+
228
+ Args:
229
+ doc_id: Document identifier.
230
+ query: Search query.
231
+
232
+ Returns:
233
+ BM25 score for the document.
234
+ """
235
+ if doc_id not in self._docs or not self._docs:
236
+ return 0.0
237
+
238
+ query_tokens = self._tokenize(query)
239
+ if not query_tokens:
240
+ return 0.0
241
+
242
+ n_docs = len(self._docs)
243
+ score = 0.0
244
+ doc_length = self._doc_lengths[doc_id]
245
+ term_freqs = self._term_freqs[doc_id]
246
+
247
+ for term in query_tokens:
248
+ if term not in self._doc_freqs:
249
+ continue
250
+
251
+ df = self._doc_freqs[term]
252
+ idf = math.log((n_docs - df + 0.5) / (df + 0.5) + 1.0)
253
+ tf = term_freqs.get(term, 0)
254
+ length_norm = 1 - self.b + self.b * (doc_length / self._avg_doc_length)
255
+ tf_component = (tf * (self.k1 + 1)) / (tf + self.k1 * length_norm)
256
+ score += idf * tf_component
257
+
258
+ return score
259
+
260
+ @property
261
+ def document_count(self) -> int:
262
+ """Get the number of documents in the index."""
263
+ return len(self._docs)
264
+
265
+
266
+ class HybridRetriever:
267
+ """Hybrid retrieval system combining BM25 and vector search.
268
+
269
+ Implements the scoring formula:
270
+ final_score = (semantic_weight * semantic_score +
271
+ recency_weight * recency_score +
272
+ frequency_weight * frequency_score +
273
+ outcome_weight * outcome_score) * category_boost
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ embedding_provider: EmbeddingProvider,
279
+ config: RetrievalConfig | None = None,
280
+ ) -> None:
281
+ """Initialize the hybrid retriever.
282
+
283
+ Args:
284
+ embedding_provider: Provider for vector embeddings.
285
+ config: Retrieval configuration.
286
+ """
287
+ self.embedding_provider = embedding_provider
288
+ self.config = config or RetrievalConfig()
289
+
290
+ # BM25 index
291
+ self._bm25 = BM25Index(
292
+ k1=self.config.bm25_k1,
293
+ b=self.config.bm25_b,
294
+ )
295
+
296
+ # Memory storage for retrieval
297
+ self._memories: dict[str, Memory] = {}
298
+ self._embeddings: dict[str, EmbeddingVector] = {}
299
+
300
+ def add_memory(self, memory: Memory, embedding: EmbeddingVector | None = None) -> None:
301
+ """Add a memory to the retrieval index.
302
+
303
+ Args:
304
+ memory: Memory to add.
305
+ embedding: Pre-computed embedding (optional).
306
+ """
307
+ self._memories[memory.id] = memory
308
+ self._bm25.add_document(memory.id, self._get_searchable_text(memory))
309
+
310
+ if embedding:
311
+ self._embeddings[memory.id] = embedding
312
+ elif memory.embedding:
313
+ self._embeddings[memory.id] = memory.embedding
314
+
315
+ def remove_memory(self, memory_id: str) -> None:
316
+ """Remove a memory from the retrieval index.
317
+
318
+ Args:
319
+ memory_id: ID of memory to remove.
320
+ """
321
+ self._bm25.remove_document(memory_id)
322
+ self._memories.pop(memory_id, None)
323
+ self._embeddings.pop(memory_id, None)
324
+
325
+ def update_memory(self, memory: Memory, embedding: EmbeddingVector | None = None) -> None:
326
+ """Update a memory in the retrieval index.
327
+
328
+ Args:
329
+ memory: Updated memory.
330
+ embedding: Updated embedding (optional).
331
+ """
332
+ self.remove_memory(memory.id)
333
+ self.add_memory(memory, embedding)
334
+
335
+ def clear(self) -> None:
336
+ """Clear all memories from the index."""
337
+ self._bm25.clear()
338
+ self._memories.clear()
339
+ self._embeddings.clear()
340
+
341
+ def _get_searchable_text(self, memory: Memory) -> str:
342
+ """Get searchable text from a memory.
343
+
344
+ Args:
345
+ memory: Memory to extract text from.
346
+
347
+ Returns:
348
+ Concatenated searchable text.
349
+ """
350
+ parts = [memory.content]
351
+ if memory.tags:
352
+ parts.extend(memory.tags)
353
+ if memory.entities:
354
+ parts.extend(memory.entities)
355
+ return " ".join(parts)
356
+
357
+ async def search(
358
+ self,
359
+ query: str,
360
+ limit: int | None = None,
361
+ category: MemoryCategory | None = None,
362
+ project: str | None = None,
363
+ include_archived: bool = False,
364
+ min_score: float = 0.0,
365
+ ) -> list[SearchResult]:
366
+ """Search for relevant memories.
367
+
368
+ Args:
369
+ query: Search query text.
370
+ limit: Maximum number of results.
371
+ category: Filter by category.
372
+ project: Filter by project.
373
+ include_archived: Whether to include archived memories.
374
+ min_score: Minimum score threshold.
375
+
376
+ Returns:
377
+ List of search results sorted by relevance.
378
+ """
379
+ if not self._memories:
380
+ return []
381
+
382
+ limit = min(limit or self.config.default_limit, self.config.max_limit)
383
+
384
+ # Get query embedding
385
+ query_result = await self.embedding_provider.embed(query)
386
+ query_embedding = query_result.embedding
387
+
388
+ # Get candidate memories with filters
389
+ candidates = self._get_candidates(
390
+ category=category,
391
+ project=project,
392
+ include_archived=include_archived,
393
+ )
394
+
395
+ if not candidates:
396
+ return []
397
+
398
+ # Score all candidates
399
+ results: list[SearchResult] = []
400
+ for memory in candidates:
401
+ result = self._score_memory(memory, query, query_embedding)
402
+ if result.score >= min_score:
403
+ results.append(result)
404
+
405
+ # Sort by score descending
406
+ results.sort(key=lambda r: r.score, reverse=True)
407
+
408
+ # Deduplicate similar results
409
+ results = self._deduplicate(results)
410
+
411
+ return results[:limit]
412
+
413
+ def _get_candidates(
414
+ self,
415
+ category: MemoryCategory | None = None,
416
+ project: str | None = None,
417
+ include_archived: bool = False,
418
+ ) -> list[Memory]:
419
+ """Get candidate memories for search.
420
+
421
+ Args:
422
+ category: Filter by category.
423
+ project: Filter by project.
424
+ include_archived: Whether to include archived memories.
425
+
426
+ Returns:
427
+ List of candidate memories.
428
+ """
429
+ candidates = []
430
+ for memory in self._memories.values():
431
+ # Apply filters
432
+ if not include_archived and memory.archived:
433
+ continue
434
+ if category and memory.category != category:
435
+ continue
436
+ if project and memory.project != project:
437
+ continue
438
+ candidates.append(memory)
439
+ return candidates
440
+
441
+ def _score_memory(
442
+ self,
443
+ memory: Memory,
444
+ query: str,
445
+ query_embedding: EmbeddingVector,
446
+ ) -> SearchResult:
447
+ """Score a memory against a query.
448
+
449
+ Args:
450
+ memory: Memory to score.
451
+ query: Search query text.
452
+ query_embedding: Query embedding vector.
453
+
454
+ Returns:
455
+ SearchResult with scoring breakdown.
456
+ """
457
+ # Calculate semantic score (BM25 + vector)
458
+ semantic_score = self._calculate_semantic_score(
459
+ memory, query, query_embedding
460
+ )
461
+
462
+ # Calculate recency score
463
+ recency_score = self._calculate_recency_score(memory)
464
+
465
+ # Calculate frequency score
466
+ frequency_score = self._calculate_frequency_score(memory)
467
+
468
+ # Get outcome score (already in -1 to 1 range, normalize to 0-1)
469
+ outcome_score = (memory.outcome_score + 1.0) / 2.0
470
+
471
+ # Get category boost
472
+ category_boost = self.config.category_boosts.get(memory.category, 1.0)
473
+
474
+ # Calculate final weighted score
475
+ final_score = (
476
+ self.config.semantic_weight * semantic_score
477
+ + self.config.recency_weight * recency_score
478
+ + self.config.frequency_weight * frequency_score
479
+ + self.config.outcome_weight * outcome_score
480
+ ) * category_boost
481
+
482
+ return SearchResult(
483
+ memory=memory,
484
+ score=final_score,
485
+ semantic_score=semantic_score,
486
+ recency_score=recency_score,
487
+ frequency_score=frequency_score,
488
+ category_boost=category_boost,
489
+ )
490
+
491
+ def _calculate_semantic_score(
492
+ self,
493
+ memory: Memory,
494
+ query: str,
495
+ query_embedding: EmbeddingVector,
496
+ ) -> float:
497
+ """Calculate semantic relevance score.
498
+
499
+ Combines BM25 text matching with vector similarity.
500
+
501
+ Args:
502
+ memory: Memory to score.
503
+ query: Search query text.
504
+ query_embedding: Query embedding vector.
505
+
506
+ Returns:
507
+ Semantic score between 0 and 1.
508
+ """
509
+ # BM25 score (normalize to 0-1 range approximately)
510
+ bm25_score = self._bm25.score_document(memory.id, query)
511
+ # Normalize BM25 score using sigmoid-like function
512
+ normalized_bm25 = bm25_score / (bm25_score + 1.0) if bm25_score > 0 else 0.0
513
+
514
+ # Without both a query vector and a stored vector there is nothing to
515
+ # compare, so score on keyword matching alone. This is the path taken
516
+ # when no embedding backend is installed, and also when a memory
517
+ # predates the current provider.
518
+ if not query_embedding or memory.id not in self._embeddings:
519
+ return normalized_bm25
520
+
521
+ # Vector similarity score
522
+ memory_embedding = self._embeddings[memory.id]
523
+ similarity = self.embedding_provider.cosine_similarity(
524
+ query_embedding, memory_embedding
525
+ )
526
+ # Convert from [-1, 1] to [0, 1] and apply threshold
527
+ vector_score = max(0, (similarity + 1) / 2)
528
+ if similarity < self.config.min_vector_similarity:
529
+ vector_score *= 0.5 # Penalize low similarity
530
+
531
+ # Combine BM25 and vector scores
532
+ vector_weight = self.config.vector_weight
533
+ bm25_weight = 1.0 - vector_weight
534
+
535
+ return bm25_weight * normalized_bm25 + vector_weight * vector_score
536
+
537
+ def _calculate_recency_score(self, memory: Memory) -> float:
538
+ """Calculate recency score with exponential decay.
539
+
540
+ Uses half-life decay: score = 0.5 ^ (days_old / half_life)
541
+
542
+ Args:
543
+ memory: Memory to score.
544
+
545
+ Returns:
546
+ Recency score between 0 and 1.
547
+ """
548
+ now = datetime.now(UTC)
549
+ age = now - memory.updated_at
550
+ days_old = age.total_seconds() / 86400 # Convert to days
551
+
552
+ half_life = self.config.recency_half_life_days
553
+ decay = math.pow(0.5, days_old / half_life)
554
+
555
+ return decay
556
+
557
+ def _calculate_frequency_score(self, memory: Memory) -> float:
558
+ """Calculate frequency boost score.
559
+
560
+ Uses logarithmic scaling: score = log(1 + use_count) / log(1 + max_count)
561
+
562
+ Args:
563
+ memory: Memory to score.
564
+
565
+ Returns:
566
+ Frequency score between 0 and 1.
567
+ """
568
+ if memory.use_count == 0:
569
+ return 0.0
570
+
571
+ # Logarithmic scaling capped at max boost
572
+ log_base = self.config.frequency_log_base
573
+ score = math.log(1 + memory.use_count, log_base)
574
+
575
+ # Normalize to 0-1 range with max boost consideration
576
+ # Assume use_count of ~100 gives max score
577
+ max_expected = math.log(1 + 100, log_base)
578
+ normalized = min(score / max_expected, 1.0)
579
+
580
+ return normalized
581
+
582
+ def _deduplicate(self, results: list[SearchResult]) -> list[SearchResult]:
583
+ """Remove near-duplicate results.
584
+
585
+ Args:
586
+ results: List of search results.
587
+
588
+ Returns:
589
+ Deduplicated list.
590
+ """
591
+ if len(results) <= 1:
592
+ return results
593
+
594
+ threshold = self.config.dedup_threshold
595
+ deduplicated: list[SearchResult] = []
596
+
597
+ for result in results:
598
+ is_duplicate = False
599
+
600
+ # Check against already selected results
601
+ if result.memory.id in self._embeddings:
602
+ result_embedding = self._embeddings[result.memory.id]
603
+
604
+ for kept in deduplicated:
605
+ if kept.memory.id in self._embeddings:
606
+ kept_embedding = self._embeddings[kept.memory.id]
607
+ similarity = self.embedding_provider.cosine_similarity(
608
+ result_embedding, kept_embedding
609
+ )
610
+ if similarity >= threshold:
611
+ is_duplicate = True
612
+ break
613
+
614
+ if not is_duplicate:
615
+ deduplicated.append(result)
616
+
617
+ return deduplicated
618
+
619
+ async def search_by_category(
620
+ self,
621
+ query: str,
622
+ categories: list[MemoryCategory],
623
+ limit_per_category: int = 3,
624
+ ) -> dict[MemoryCategory, list[SearchResult]]:
625
+ """Search across specific categories.
626
+
627
+ Args:
628
+ query: Search query.
629
+ categories: Categories to search.
630
+ limit_per_category: Max results per category.
631
+
632
+ Returns:
633
+ Dictionary mapping categories to results.
634
+ """
635
+ results: dict[MemoryCategory, list[SearchResult]] = {}
636
+
637
+ for category in categories:
638
+ category_results = await self.search(
639
+ query=query,
640
+ limit=limit_per_category,
641
+ category=category,
642
+ )
643
+ results[category] = category_results
644
+
645
+ return results
646
+
647
+ async def get_context_memories(
648
+ self,
649
+ query: str,
650
+ project: str | None = None,
651
+ max_memories: int = 10,
652
+ category_distribution: dict[MemoryCategory, int] | None = None,
653
+ ) -> list[SearchResult]:
654
+ """Get memories for context injection.
655
+
656
+ Retrieves a balanced set of memories across categories.
657
+
658
+ Args:
659
+ query: Context query (e.g., task description).
660
+ project: Project filter.
661
+ max_memories: Maximum total memories.
662
+ category_distribution: Optional category -> count mapping.
663
+
664
+ Returns:
665
+ List of context-relevant memories.
666
+ """
667
+ if category_distribution is None:
668
+ # Default distribution prioritizing important categories
669
+ category_distribution = {
670
+ MemoryCategory.GOTCHA: 2,
671
+ MemoryCategory.TROUBLESHOOTING: 2,
672
+ MemoryCategory.DECISION: 2,
673
+ MemoryCategory.PATTERN: 2,
674
+ MemoryCategory.PREFERENCE: 1,
675
+ MemoryCategory.ARCHITECTURE: 1,
676
+ }
677
+
678
+ all_results: list[SearchResult] = []
679
+
680
+ for category, count in category_distribution.items():
681
+ if count <= 0:
682
+ continue
683
+
684
+ results = await self.search(
685
+ query=query,
686
+ limit=count,
687
+ category=category,
688
+ project=project,
689
+ )
690
+ all_results.extend(results)
691
+
692
+ # Sort by score and limit
693
+ all_results.sort(key=lambda r: r.score, reverse=True)
694
+ return all_results[:max_memories]
695
+
696
+ @property
697
+ def memory_count(self) -> int:
698
+ """Get the number of indexed memories."""
699
+ return len(self._memories)
700
+
701
+ @property
702
+ def indexed_with_embeddings(self) -> int:
703
+ """Get the number of memories with embeddings."""
704
+ return len(self._embeddings)
705
+
706
+
707
+ class CategoryRouter:
708
+ """Routes queries to relevant categories based on content analysis."""
709
+
710
+ # Keywords that suggest specific categories
711
+ CATEGORY_KEYWORDS: ClassVar[dict[MemoryCategory, set[str]]] = {
712
+ MemoryCategory.GOTCHA: {
713
+ "gotcha", "watch out", "careful", "warning", "trap", "pitfall",
714
+ "don't", "avoid", "never", "beware", "caution",
715
+ },
716
+ MemoryCategory.TROUBLESHOOTING: {
717
+ "error", "exception", "bug", "fix", "crash", "fail", "issue",
718
+ "traceback", "stack", "debug", "broken", "troubleshoot",
719
+ },
720
+ MemoryCategory.DECISION: {
721
+ "decided", "chose", "decision", "why", "because", "rationale",
722
+ "trade-off", "tradeoff", "alternative", "option",
723
+ },
724
+ MemoryCategory.PREFERENCE: {
725
+ "prefer", "like", "want", "style", "always",
726
+ "usually", "habit", "favorite",
727
+ },
728
+ MemoryCategory.PATTERN: {
729
+ "pattern", "approach", "method", "technique", "way to",
730
+ "how to", "best practice", "idiom",
731
+ },
732
+ MemoryCategory.ARCHITECTURE: {
733
+ "architecture", "design", "structure", "component", "module",
734
+ "layer", "system", "interface", "api",
735
+ },
736
+ MemoryCategory.CONVENTION: {
737
+ "convention", "standard", "naming", "format", "rule",
738
+ "guideline", "code style",
739
+ },
740
+ MemoryCategory.COMMAND: {
741
+ "command", "cli", "terminal", "shell", "npm", "script",
742
+ "run", "execute", "install",
743
+ },
744
+ MemoryCategory.WORKAROUND: {
745
+ "workaround", "hack", "temporary", "quick fix", "bypass",
746
+ "monkey patch", "until",
747
+ },
748
+ }
749
+
750
+ def __init__(self) -> None:
751
+ """Initialize the category router."""
752
+ # Build reverse lookup for efficiency
753
+ self._keyword_to_categories: dict[str, list[MemoryCategory]] = {}
754
+ for category, keywords in self.CATEGORY_KEYWORDS.items():
755
+ for keyword in keywords:
756
+ if keyword not in self._keyword_to_categories:
757
+ self._keyword_to_categories[keyword] = []
758
+ self._keyword_to_categories[keyword].append(category)
759
+
760
+ def route_query(
761
+ self,
762
+ query: str,
763
+ top_k: int = 3,
764
+ ) -> list[tuple[MemoryCategory, float]]:
765
+ """Route a query to likely relevant categories.
766
+
767
+ Args:
768
+ query: Search query.
769
+ top_k: Number of top categories to return.
770
+
771
+ Returns:
772
+ List of (category, confidence) tuples.
773
+ """
774
+ query_lower = query.lower()
775
+ category_scores: Counter[MemoryCategory] = Counter()
776
+
777
+ # Check for keyword matches
778
+ for keyword, categories in self._keyword_to_categories.items():
779
+ if keyword in query_lower:
780
+ for category in categories:
781
+ category_scores[category] += 1
782
+
783
+ if not category_scores:
784
+ # No matches, return general categories
785
+ return [
786
+ (MemoryCategory.PATTERN, 0.5),
787
+ (MemoryCategory.DECISION, 0.5),
788
+ (MemoryCategory.GOTCHA, 0.5),
789
+ ]
790
+
791
+ # Normalize scores
792
+ max_score = max(category_scores.values())
793
+ results = [
794
+ (cat, score / max_score)
795
+ for cat, score in category_scores.most_common(top_k)
796
+ ]
797
+
798
+ return results
799
+
800
+ def get_boost_for_query(
801
+ self,
802
+ query: str,
803
+ category: MemoryCategory,
804
+ ) -> float:
805
+ """Get category boost based on query relevance.
806
+
807
+ Args:
808
+ query: Search query.
809
+ category: Category to check.
810
+
811
+ Returns:
812
+ Boost factor (1.0 = no boost).
813
+ """
814
+ routed = self.route_query(query, top_k=9)
815
+ for cat, confidence in routed:
816
+ if cat == category:
817
+ # Scale confidence to boost (1.0 to 1.5)
818
+ return 1.0 + (confidence * 0.5)
819
+ return 1.0