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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- 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
|