spatial-memory-mcp 1.6.1__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.

Potentially problematic release.


This version of spatial-memory-mcp might be problematic. Click here for more details.

Files changed (54) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +270 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +878 -0
  5. spatial_memory/config.py +728 -0
  6. spatial_memory/core/__init__.py +118 -0
  7. spatial_memory/core/cache.py +317 -0
  8. spatial_memory/core/circuit_breaker.py +297 -0
  9. spatial_memory/core/connection_pool.py +220 -0
  10. spatial_memory/core/consolidation_strategies.py +402 -0
  11. spatial_memory/core/database.py +3069 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +575 -0
  14. spatial_memory/core/db_migrations.py +584 -0
  15. spatial_memory/core/db_search.py +509 -0
  16. spatial_memory/core/db_versioning.py +177 -0
  17. spatial_memory/core/embeddings.py +557 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +702 -0
  20. spatial_memory/core/filesystem.py +178 -0
  21. spatial_memory/core/health.py +289 -0
  22. spatial_memory/core/helpers.py +79 -0
  23. spatial_memory/core/import_security.py +432 -0
  24. spatial_memory/core/lifecycle_ops.py +1067 -0
  25. spatial_memory/core/logging.py +194 -0
  26. spatial_memory/core/metrics.py +192 -0
  27. spatial_memory/core/models.py +628 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +497 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +426 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +403 -0
  35. spatial_memory/factory.py +407 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +631 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1141 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/export_import.py +1023 -0
  43. spatial_memory/services/lifecycle.py +1120 -0
  44. spatial_memory/services/memory.py +412 -0
  45. spatial_memory/services/spatial.py +1147 -0
  46. spatial_memory/services/utility.py +409 -0
  47. spatial_memory/tools/__init__.py +5 -0
  48. spatial_memory/tools/definitions.py +695 -0
  49. spatial_memory/verify.py +140 -0
  50. spatial_memory_mcp-1.6.1.dist-info/METADATA +499 -0
  51. spatial_memory_mcp-1.6.1.dist-info/RECORD +54 -0
  52. spatial_memory_mcp-1.6.1.dist-info/WHEEL +4 -0
  53. spatial_memory_mcp-1.6.1.dist-info/entry_points.txt +2 -0
  54. spatial_memory_mcp-1.6.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1120 @@
1
+ """Lifecycle service for memory management operations.
2
+
3
+ This service provides the application layer for memory lifecycle operations:
4
+ - decay: Apply time/access-based importance reduction
5
+ - reinforce: Boost memory importance
6
+ - extract: Auto-extract memories from text
7
+ - consolidate: Merge similar/duplicate memories
8
+
9
+ These operations address the "Context Window Pollution" problem by providing
10
+ utility-based memory management with cognitive-like dynamics.
11
+
12
+ The service uses dependency injection for repository and embedding services.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, Any, Literal
20
+
21
+ import numpy as np
22
+
23
+ from spatial_memory.core.errors import (
24
+ ConsolidationError,
25
+ DecayError,
26
+ ExtractionError,
27
+ ReinforcementError,
28
+ ValidationError,
29
+ )
30
+ from spatial_memory.core.consolidation_strategies import (
31
+ ConsolidationAction,
32
+ get_strategy,
33
+ )
34
+ from spatial_memory.core.lifecycle_ops import (
35
+ apply_decay,
36
+ calculate_decay_factor,
37
+ calculate_reinforcement,
38
+ combined_similarity,
39
+ extract_candidates,
40
+ find_duplicate_groups,
41
+ jaccard_similarity,
42
+ )
43
+ from spatial_memory.core.models import (
44
+ ConsolidateResult,
45
+ ConsolidationGroup,
46
+ DecayedMemory,
47
+ DecayResult,
48
+ ExtractedMemory,
49
+ ExtractResult,
50
+ Memory,
51
+ MemorySource,
52
+ ReinforcedMemory,
53
+ ReinforceResult,
54
+ )
55
+
56
+ # Alias for backward compatibility
57
+ ConsolidationGroupResult = ConsolidationGroup
58
+ from spatial_memory.core.utils import to_naive_utc, utc_now, utc_now_naive
59
+ from spatial_memory.core.validation import validate_namespace
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ if TYPE_CHECKING:
64
+ from spatial_memory.ports.repositories import (
65
+ EmbeddingServiceProtocol,
66
+ MemoryRepositoryProtocol,
67
+ )
68
+
69
+
70
+ # =============================================================================
71
+ # Configuration
72
+ # =============================================================================
73
+
74
+
75
+ @dataclass
76
+ class LifecycleConfig:
77
+ """Configuration for lifecycle operations.
78
+
79
+ Attributes:
80
+ decay_default_half_life_days: Default half-life for exponential decay.
81
+ decay_default_function: Default decay function type.
82
+ decay_min_importance_floor: Minimum importance after decay.
83
+ decay_batch_size: Batch size for decay updates.
84
+ reinforce_default_boost: Default boost amount.
85
+ reinforce_max_importance: Maximum importance after reinforcement.
86
+ extract_max_text_length: Maximum text length for extraction.
87
+ extract_max_candidates: Maximum candidates per extraction.
88
+ extract_default_importance: Default importance for extracted memories.
89
+ extract_default_namespace: Default namespace for extracted memories.
90
+ consolidate_min_threshold: Minimum similarity threshold.
91
+ consolidate_content_weight: Weight of content overlap vs vector similarity.
92
+ consolidate_max_batch: Maximum memories per consolidation pass.
93
+ """
94
+
95
+ # Decay settings
96
+ decay_default_half_life_days: float = 30.0
97
+ decay_default_function: str = "exponential" # exponential, linear, step
98
+ decay_min_importance_floor: float = 0.1
99
+ decay_batch_size: int = 500
100
+
101
+ # Reinforce settings
102
+ reinforce_default_boost: float = 0.1
103
+ reinforce_max_importance: float = 1.0
104
+
105
+ # Extract settings
106
+ extract_max_text_length: int = 50000
107
+ extract_max_candidates: int = 20
108
+ extract_default_importance: float = 0.4
109
+ extract_default_namespace: str = "extracted"
110
+
111
+ # Consolidate settings
112
+ consolidate_min_threshold: float = 0.7
113
+ consolidate_content_weight: float = 0.3
114
+ consolidate_max_batch: int = 1000
115
+ consolidate_chunk_size: int = 200 # Process in smaller chunks for memory efficiency
116
+
117
+
118
+ # =============================================================================
119
+ # Service Implementation
120
+ # =============================================================================
121
+
122
+
123
+ class LifecycleService:
124
+ """Service for memory lifecycle management.
125
+
126
+ Uses Clean Architecture - depends on protocol interfaces, not implementations.
127
+ Implements cognitive-like memory dynamics: decay, reinforcement, extraction,
128
+ and consolidation.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ repository: MemoryRepositoryProtocol,
134
+ embeddings: EmbeddingServiceProtocol,
135
+ config: LifecycleConfig | None = None,
136
+ ) -> None:
137
+ """Initialize the lifecycle service.
138
+
139
+ Args:
140
+ repository: Repository for memory storage.
141
+ embeddings: Service for generating embeddings.
142
+ config: Optional configuration (uses defaults if not provided).
143
+ """
144
+ self._repo = repository
145
+ self._embeddings = embeddings
146
+ self._config = config or LifecycleConfig()
147
+
148
+ def decay(
149
+ self,
150
+ namespace: str | None = None,
151
+ decay_function: Literal["exponential", "linear", "step"] = "exponential",
152
+ half_life_days: float | None = None,
153
+ min_importance: float | None = None,
154
+ access_weight: float = 0.3,
155
+ dry_run: bool = True,
156
+ ) -> DecayResult:
157
+ """Apply time and access-based decay to memory importance scores.
158
+
159
+ Implements the "forgetting curve" - memories not accessed become less
160
+ important over time. More accesses and higher base importance slow decay.
161
+
162
+ Args:
163
+ namespace: Namespace to decay (all if not specified).
164
+ decay_function: Decay curve shape ("exponential", "linear", "step").
165
+ half_life_days: Days until importance halves (default from config).
166
+ min_importance: Minimum importance floor (default from config).
167
+ access_weight: Weight of access count in decay calculation (0-1).
168
+ dry_run: Preview changes without applying (default True).
169
+
170
+ Returns:
171
+ DecayResult with decay statistics and affected memories.
172
+
173
+ Raises:
174
+ DecayError: If decay calculation or application fails.
175
+ ValidationError: If input validation fails.
176
+ """
177
+ # Validate inputs
178
+ if namespace is not None:
179
+ namespace = validate_namespace(namespace)
180
+
181
+ if decay_function not in ("exponential", "linear", "step"):
182
+ raise ValidationError(
183
+ f"Invalid decay function: {decay_function}. "
184
+ "Must be 'exponential', 'linear', or 'step'."
185
+ )
186
+
187
+ if not 0.0 <= access_weight <= 1.0:
188
+ raise ValidationError("access_weight must be between 0.0 and 1.0")
189
+
190
+ # Use config defaults
191
+ effective_half_life = (
192
+ half_life_days
193
+ if half_life_days is not None
194
+ else self._config.decay_default_half_life_days
195
+ )
196
+ effective_min_importance = (
197
+ min_importance
198
+ if min_importance is not None
199
+ else self._config.decay_min_importance_floor
200
+ )
201
+
202
+ if effective_half_life < 1.0:
203
+ raise ValidationError("half_life_days must be at least 1.0")
204
+ if not 0.0 <= effective_min_importance <= 0.5:
205
+ raise ValidationError("min_importance must be between 0.0 and 0.5")
206
+
207
+ try:
208
+ # Fetch all memories for decay calculation
209
+ all_memories = self._repo.get_all(
210
+ namespace=namespace,
211
+ limit=self._config.decay_batch_size * 10, # Allow multiple batches
212
+ )
213
+
214
+ if not all_memories:
215
+ logger.info("No memories found for decay")
216
+ return DecayResult(
217
+ memories_analyzed=0,
218
+ memories_decayed=0,
219
+ avg_decay_factor=1.0,
220
+ decayed_memories=[],
221
+ dry_run=dry_run,
222
+ )
223
+
224
+ # Use naive UTC to match LanceDB storage format (avoids timezone mismatch errors)
225
+ now = utc_now_naive()
226
+ decayed_memories: list[DecayedMemory] = []
227
+ total_decay_factor = 0.0
228
+ memories_to_update: list[tuple[str, float]] = []
229
+
230
+ for memory, _ in all_memories:
231
+ # Normalize last_accessed to naive UTC (handle both aware and naive timestamps)
232
+ last_accessed = to_naive_utc(memory.last_accessed)
233
+
234
+ # Calculate days since last access
235
+ days_since_access = (now - last_accessed).total_seconds() / 86400
236
+
237
+ # Calculate decay factor
238
+ decay_factor = calculate_decay_factor(
239
+ days_since_access=days_since_access,
240
+ access_count=memory.access_count,
241
+ base_importance=memory.importance,
242
+ decay_function=decay_function,
243
+ half_life_days=effective_half_life,
244
+ access_weight=access_weight,
245
+ )
246
+
247
+ # Apply decay to get new importance
248
+ new_importance = apply_decay(
249
+ current_importance=memory.importance,
250
+ decay_factor=decay_factor,
251
+ min_importance=effective_min_importance,
252
+ )
253
+
254
+ # Track if importance actually changed
255
+ if abs(new_importance - memory.importance) > 0.001:
256
+ decayed_memories.append(
257
+ DecayedMemory(
258
+ id=memory.id,
259
+ content_preview=memory.content[:100] + "..."
260
+ if len(memory.content) > 100
261
+ else memory.content,
262
+ old_importance=memory.importance,
263
+ new_importance=new_importance,
264
+ decay_factor=decay_factor,
265
+ days_since_access=int(days_since_access),
266
+ access_count=memory.access_count,
267
+ )
268
+ )
269
+ memories_to_update.append((memory.id, new_importance))
270
+
271
+ total_decay_factor += decay_factor
272
+
273
+ avg_decay = (
274
+ total_decay_factor / len(all_memories) if all_memories else 1.0
275
+ )
276
+
277
+ # Apply updates if not dry run - use batch update for efficiency
278
+ failed_updates: list[str] = []
279
+ if not dry_run and memories_to_update:
280
+ logger.info(f"Applying decay to {len(memories_to_update)} memories")
281
+ # Convert to batch update format: list of (memory_id, updates_dict)
282
+ batch_updates = [
283
+ (memory_id, {"importance": new_importance})
284
+ for memory_id, new_importance in memories_to_update
285
+ ]
286
+ try:
287
+ success_count, failed_ids = self._repo.update_batch(batch_updates)
288
+ failed_updates = failed_ids
289
+ logger.debug(
290
+ f"Batch decay update: {success_count} succeeded, "
291
+ f"{len(failed_ids)} failed"
292
+ )
293
+ except Exception as e:
294
+ logger.warning(f"Batch decay update failed: {e}")
295
+ # Fall back to individual updates on batch failure
296
+ for memory_id, new_importance in memories_to_update:
297
+ try:
298
+ self._repo.update(memory_id, {"importance": new_importance})
299
+ except Exception as update_err:
300
+ logger.warning(f"Failed to update {memory_id}: {update_err}")
301
+ failed_updates.append(memory_id)
302
+
303
+ return DecayResult(
304
+ memories_analyzed=len(all_memories),
305
+ memories_decayed=len(decayed_memories),
306
+ avg_decay_factor=avg_decay,
307
+ decayed_memories=decayed_memories,
308
+ dry_run=dry_run,
309
+ failed_updates=failed_updates,
310
+ )
311
+
312
+ except (ValidationError, DecayError):
313
+ raise
314
+ except Exception as e:
315
+ raise DecayError(f"Decay operation failed: {e}") from e
316
+
317
+ def reinforce(
318
+ self,
319
+ memory_ids: list[str],
320
+ boost_type: Literal["additive", "multiplicative", "set_value"] = "additive",
321
+ boost_amount: float | None = None,
322
+ update_access: bool = True,
323
+ ) -> ReinforceResult:
324
+ """Boost memory importance based on usage or explicit feedback.
325
+
326
+ Reinforcement increases importance and can reset decay timer by
327
+ updating the access timestamp.
328
+
329
+ Args:
330
+ memory_ids: Memory IDs to reinforce.
331
+ boost_type: Type of boost ("additive", "multiplicative", "set_value").
332
+ boost_amount: Amount for boost (default from config).
333
+ update_access: Also update last_accessed timestamp (default True).
334
+
335
+ Returns:
336
+ ReinforceResult with reinforcement statistics.
337
+
338
+ Raises:
339
+ ReinforcementError: If reinforcement fails.
340
+ ValidationError: If input validation fails.
341
+ """
342
+ if not memory_ids:
343
+ raise ValidationError("memory_ids cannot be empty")
344
+
345
+ if boost_type not in ("additive", "multiplicative", "set_value"):
346
+ raise ValidationError(
347
+ f"Invalid boost_type: {boost_type}. "
348
+ "Must be 'additive', 'multiplicative', or 'set_value'."
349
+ )
350
+
351
+ effective_boost = (
352
+ boost_amount
353
+ if boost_amount is not None
354
+ else self._config.reinforce_default_boost
355
+ )
356
+
357
+ if effective_boost < 0.0 or effective_boost > 1.0:
358
+ raise ValidationError("boost_amount must be between 0.0 and 1.0")
359
+
360
+ try:
361
+ reinforced: list[ReinforcedMemory] = []
362
+ not_found: list[str] = []
363
+ failed_updates: list[str] = []
364
+ total_boost = 0.0
365
+
366
+ # Batch fetch all memories in a single query
367
+ memory_map = self._repo.get_batch(memory_ids)
368
+
369
+ # Track which memories were not found
370
+ for memory_id in memory_ids:
371
+ if memory_id not in memory_map:
372
+ not_found.append(memory_id)
373
+ logger.warning(f"Memory not found for reinforcement: {memory_id}")
374
+
375
+ if not memory_map:
376
+ return ReinforceResult(
377
+ memories_reinforced=0,
378
+ avg_boost=0.0,
379
+ reinforced=[],
380
+ not_found=not_found,
381
+ failed_updates=[],
382
+ )
383
+
384
+ # Calculate reinforcement for all found memories
385
+ now = utc_now()
386
+ batch_updates: list[tuple[str, dict[str, Any]]] = []
387
+ reinforcement_info: list[tuple[str, Memory, float, float]] = [] # id, memory, new_imp, boost
388
+
389
+ for memory_id, memory in memory_map.items():
390
+ # Calculate new importance
391
+ new_importance, actual_boost = calculate_reinforcement(
392
+ current_importance=memory.importance,
393
+ boost_type=boost_type,
394
+ boost_amount=effective_boost,
395
+ max_importance=self._config.reinforce_max_importance,
396
+ )
397
+
398
+ # Prepare update
399
+ updates: dict[str, Any] = {"importance": new_importance}
400
+ if update_access:
401
+ updates["last_accessed"] = now
402
+ updates["access_count"] = memory.access_count + 1
403
+
404
+ batch_updates.append((memory_id, updates))
405
+ reinforcement_info.append((memory_id, memory, new_importance, actual_boost))
406
+
407
+ # Apply all updates in a single batch operation
408
+ try:
409
+ success_count, batch_failed_ids = self._repo.update_batch(batch_updates)
410
+ failed_updates = batch_failed_ids
411
+ logger.debug(
412
+ f"Batch reinforce update: {success_count} succeeded, "
413
+ f"{len(batch_failed_ids)} failed"
414
+ )
415
+ except Exception as e:
416
+ logger.warning(f"Batch reinforce update failed: {e}, falling back to individual updates")
417
+ # Fall back to individual updates on batch failure
418
+ batch_failed_ids = []
419
+ for memory_id, updates in batch_updates:
420
+ try:
421
+ self._repo.update(memory_id, updates)
422
+ except Exception as update_err:
423
+ logger.warning(f"Failed to reinforce {memory_id}: {update_err}")
424
+ batch_failed_ids.append(memory_id)
425
+ failed_updates = batch_failed_ids
426
+
427
+ # Build reinforced results for successful updates
428
+ failed_set = set(failed_updates)
429
+ for memory_id, memory, new_importance, actual_boost in reinforcement_info:
430
+ if memory_id not in failed_set:
431
+ reinforced.append(
432
+ ReinforcedMemory(
433
+ id=memory_id,
434
+ content_preview=memory.content[:100] + "..."
435
+ if len(memory.content) > 100
436
+ else memory.content,
437
+ old_importance=memory.importance,
438
+ new_importance=new_importance,
439
+ boost_applied=actual_boost,
440
+ )
441
+ )
442
+ total_boost += actual_boost
443
+
444
+ avg_boost = total_boost / len(reinforced) if reinforced else 0.0
445
+
446
+ return ReinforceResult(
447
+ memories_reinforced=len(reinforced),
448
+ avg_boost=avg_boost,
449
+ reinforced=reinforced,
450
+ not_found=not_found,
451
+ failed_updates=failed_updates,
452
+ )
453
+
454
+ except (ValidationError, ReinforcementError):
455
+ raise
456
+ except Exception as e:
457
+ raise ReinforcementError(f"Reinforcement operation failed: {e}") from e
458
+
459
+ def extract(
460
+ self,
461
+ text: str,
462
+ namespace: str | None = None,
463
+ min_confidence: float = 0.5,
464
+ deduplicate: bool = True,
465
+ dedup_threshold: float = 0.9,
466
+ ) -> ExtractResult:
467
+ """Automatically extract memories from conversation text.
468
+
469
+ Uses pattern matching to identify facts, decisions, and key information
470
+ from unstructured text.
471
+
472
+ Args:
473
+ text: Text to extract memories from.
474
+ namespace: Namespace for extracted memories (default from config).
475
+ min_confidence: Minimum confidence to extract (0-1).
476
+ deduplicate: Skip if similar memory exists (default True).
477
+ dedup_threshold: Similarity threshold for deduplication (0.7-0.99).
478
+
479
+ Returns:
480
+ ExtractResult with extraction statistics and created memories.
481
+
482
+ Raises:
483
+ ExtractionError: If extraction fails.
484
+ ValidationError: If input validation fails.
485
+ """
486
+ if not text or not text.strip():
487
+ raise ValidationError("Text cannot be empty")
488
+
489
+ if len(text) > self._config.extract_max_text_length:
490
+ raise ValidationError(
491
+ f"Text exceeds maximum length of {self._config.extract_max_text_length}"
492
+ )
493
+
494
+ if not 0.0 <= min_confidence <= 1.0:
495
+ raise ValidationError("min_confidence must be between 0.0 and 1.0")
496
+
497
+ if not 0.7 <= dedup_threshold <= 0.99:
498
+ raise ValidationError("dedup_threshold must be between 0.7 and 0.99")
499
+
500
+ effective_namespace = namespace or self._config.extract_default_namespace
501
+ effective_namespace = validate_namespace(effective_namespace)
502
+
503
+ try:
504
+ # Extract candidates using pattern matching
505
+ candidates = extract_candidates(
506
+ text=text,
507
+ min_confidence=min_confidence,
508
+ max_candidates=self._config.extract_max_candidates,
509
+ )
510
+
511
+ if not candidates:
512
+ logger.info("No extraction candidates found")
513
+ return ExtractResult(
514
+ candidates_found=0,
515
+ memories_created=0,
516
+ deduplicated_count=0,
517
+ extractions=[],
518
+ )
519
+
520
+ # Generate embeddings for all candidates in a single batch
521
+ # This is much more efficient than generating one at a time
522
+ candidate_texts = [c.content for c in candidates]
523
+ candidate_vectors = self._embeddings.embed_batch(candidate_texts)
524
+ logger.debug(f"Generated {len(candidate_vectors)} embeddings in batch")
525
+
526
+ extractions: list[ExtractedMemory] = []
527
+ memories_created = 0
528
+ deduplicated_count = 0
529
+
530
+ for candidate, vector in zip(candidates, candidate_vectors):
531
+ extraction = ExtractedMemory(
532
+ content=candidate.content,
533
+ confidence=candidate.confidence,
534
+ pattern_matched=candidate.pattern_type,
535
+ start_pos=candidate.start_pos,
536
+ end_pos=candidate.end_pos,
537
+ stored=False,
538
+ memory_id=None,
539
+ )
540
+
541
+ # Check for duplicates if requested (use pre-computed vector)
542
+ if deduplicate:
543
+ is_duplicate = self._check_duplicate_with_vector(
544
+ content=candidate.content,
545
+ vector=vector,
546
+ namespace=effective_namespace,
547
+ threshold=dedup_threshold,
548
+ )
549
+ if is_duplicate:
550
+ deduplicated_count += 1
551
+ extractions.append(extraction)
552
+ continue
553
+
554
+ # Store the extracted memory (use pre-computed vector)
555
+ try:
556
+ memory_id = self._store_extracted_memory_with_vector(
557
+ content=candidate.content,
558
+ vector=vector,
559
+ namespace=effective_namespace,
560
+ confidence=candidate.confidence,
561
+ pattern_type=candidate.pattern_type,
562
+ )
563
+ extraction.stored = True
564
+ extraction.memory_id = memory_id
565
+ memories_created += 1
566
+ except Exception as e:
567
+ logger.warning(f"Failed to store extraction: {e}")
568
+
569
+ extractions.append(extraction)
570
+
571
+ return ExtractResult(
572
+ candidates_found=len(candidates),
573
+ memories_created=memories_created,
574
+ deduplicated_count=deduplicated_count,
575
+ extractions=extractions,
576
+ )
577
+
578
+ except (ValidationError, ExtractionError):
579
+ raise
580
+ except Exception as e:
581
+ raise ExtractionError(f"Extraction operation failed: {e}") from e
582
+
583
+ def consolidate(
584
+ self,
585
+ namespace: str,
586
+ similarity_threshold: float = 0.85,
587
+ strategy: Literal[
588
+ "keep_newest", "keep_oldest", "keep_highest_importance", "merge_content"
589
+ ] = "keep_highest_importance",
590
+ dry_run: bool = True,
591
+ max_groups: int = 50,
592
+ ) -> ConsolidateResult:
593
+ """Merge similar or duplicate memories to reduce redundancy.
594
+
595
+ Finds memories above similarity threshold and merges them according
596
+ to the specified strategy.
597
+
598
+ Args:
599
+ namespace: Namespace to consolidate (required).
600
+ similarity_threshold: Minimum similarity for duplicates (0.7-0.99).
601
+ strategy: How to handle duplicates:
602
+ - "keep_newest": Keep most recent memory
603
+ - "keep_oldest": Keep oldest memory
604
+ - "keep_highest_importance": Keep highest importance
605
+ - "merge_content": Combine content and re-embed
606
+ dry_run: Preview without changes (default True).
607
+ max_groups: Maximum groups to process.
608
+
609
+ Returns:
610
+ ConsolidateResult with consolidation statistics.
611
+
612
+ Raises:
613
+ ConsolidationError: If consolidation fails.
614
+ ValidationError: If input validation fails.
615
+ """
616
+ namespace = validate_namespace(namespace)
617
+
618
+ if not 0.7 <= similarity_threshold <= 0.99:
619
+ raise ValidationError(
620
+ "similarity_threshold must be between 0.7 and 0.99"
621
+ )
622
+
623
+ # Validate strategy using the strategy registry
624
+ try:
625
+ strategy_impl = get_strategy(strategy)
626
+ except ValueError as e:
627
+ raise ValidationError(str(e)) from e
628
+
629
+ if max_groups < 1:
630
+ raise ValidationError("max_groups must be at least 1")
631
+
632
+ try:
633
+ # Get total count to decide processing strategy
634
+ total_count = self._repo.count(namespace=namespace)
635
+
636
+ if total_count < 2:
637
+ logger.info("Not enough memories for consolidation")
638
+ return ConsolidateResult(
639
+ groups_found=0,
640
+ memories_merged=0,
641
+ memories_deleted=0,
642
+ groups=[],
643
+ dry_run=dry_run,
644
+ )
645
+
646
+ # Use chunked processing for large namespaces to reduce memory usage
647
+ chunk_size = min(
648
+ self._config.consolidate_chunk_size,
649
+ self._config.consolidate_max_batch,
650
+ )
651
+ use_chunked = total_count > chunk_size
652
+
653
+ if use_chunked:
654
+ logger.info(
655
+ f"Using chunked consolidation: {total_count} memories in "
656
+ f"chunks of {chunk_size}"
657
+ )
658
+ return self._consolidate_chunked(
659
+ namespace=namespace,
660
+ similarity_threshold=similarity_threshold,
661
+ strategy=strategy,
662
+ dry_run=dry_run,
663
+ max_groups=max_groups,
664
+ chunk_size=chunk_size,
665
+ )
666
+
667
+ # Standard single-pass processing for smaller namespaces
668
+ all_memories = self._repo.get_all(
669
+ namespace=namespace,
670
+ limit=self._config.consolidate_max_batch,
671
+ )
672
+
673
+ # Build lookup structures
674
+ memories = [m for m, _ in all_memories]
675
+ vectors_list = [v for _, v in all_memories]
676
+ vectors_array = np.array(vectors_list, dtype=np.float32)
677
+ memory_ids = [m.id for m in memories]
678
+ contents = [m.content for m in memories]
679
+ memory_dicts: list[dict[str, Any]] = [
680
+ {
681
+ "id": m.id,
682
+ "content": m.content,
683
+ "created_at": m.created_at,
684
+ "last_accessed": m.last_accessed,
685
+ "access_count": m.access_count,
686
+ "importance": m.importance,
687
+ "tags": list(m.tags),
688
+ }
689
+ for m in memories
690
+ ]
691
+
692
+ # Find duplicate groups using array-based API
693
+ group_indices = find_duplicate_groups(
694
+ memory_ids=memory_ids,
695
+ vectors=vectors_array,
696
+ contents=contents,
697
+ threshold=similarity_threshold,
698
+ content_weight=self._config.consolidate_content_weight,
699
+ )
700
+
701
+ # Limit groups
702
+ group_indices = group_indices[:max_groups]
703
+
704
+ if not group_indices:
705
+ logger.info("No duplicate groups found")
706
+ return ConsolidateResult(
707
+ groups_found=0,
708
+ memories_merged=0,
709
+ memories_deleted=0,
710
+ groups=[],
711
+ dry_run=dry_run,
712
+ )
713
+
714
+ result_groups: list[ConsolidationGroupResult] = []
715
+ memories_merged = 0
716
+ memories_deleted = 0
717
+
718
+ for member_indices in group_indices:
719
+ group_member_dicts = [memory_dicts[i] for i in member_indices]
720
+ group_member_ids = [str(d["id"]) for d in group_member_dicts]
721
+
722
+ # Calculate average similarity for the group
723
+ total_sim = 0.0
724
+ pair_count = 0
725
+ for i_idx, i in enumerate(member_indices):
726
+ for j in member_indices[i_idx + 1 :]:
727
+ # Vector similarity
728
+ v1, v2 = vectors_array[i], vectors_array[j]
729
+ dot = float(np.dot(v1, v2))
730
+ norm1 = float(np.linalg.norm(v1))
731
+ norm2 = float(np.linalg.norm(v2))
732
+ if norm1 > 1e-10 and norm2 > 1e-10:
733
+ v_sim = dot / (norm1 * norm2)
734
+ else:
735
+ v_sim = 0.0
736
+ # Content similarity
737
+ c_sim = jaccard_similarity(contents[i], contents[j])
738
+ combined = combined_similarity(
739
+ v_sim, c_sim, self._config.consolidate_content_weight
740
+ )
741
+ total_sim += combined
742
+ pair_count += 1
743
+ avg_similarity = total_sim / pair_count if pair_count > 0 else 0.0
744
+
745
+ # Apply consolidation strategy
746
+ try:
747
+ action_result: ConsolidationAction = strategy_impl.apply(
748
+ members=group_member_dicts,
749
+ member_ids=group_member_ids,
750
+ namespace=namespace,
751
+ repository=self._repo,
752
+ embeddings=self._embeddings,
753
+ dry_run=dry_run,
754
+ )
755
+ memories_merged += action_result.memories_merged
756
+ memories_deleted += action_result.memories_deleted
757
+ except Exception as e:
758
+ logger.warning(f"Failed to consolidate group: {e}")
759
+ action_result = ConsolidationAction(
760
+ representative_id=group_member_ids[0],
761
+ deleted_ids=[],
762
+ action="failed",
763
+ )
764
+
765
+ result_groups.append(
766
+ ConsolidationGroupResult(
767
+ representative_id=action_result.representative_id,
768
+ member_ids=group_member_ids,
769
+ avg_similarity=avg_similarity,
770
+ action_taken=action_result.action,
771
+ )
772
+ )
773
+
774
+ return ConsolidateResult(
775
+ groups_found=len(group_indices),
776
+ memories_merged=memories_merged,
777
+ memories_deleted=memories_deleted,
778
+ groups=result_groups,
779
+ dry_run=dry_run,
780
+ )
781
+
782
+ except (ValidationError, ConsolidationError):
783
+ raise
784
+ except Exception as e:
785
+ raise ConsolidationError(f"Consolidation operation failed: {e}") from e
786
+
787
+ # =========================================================================
788
+ # Helper Methods
789
+ # =========================================================================
790
+
791
+ def _consolidate_chunked(
792
+ self,
793
+ namespace: str,
794
+ similarity_threshold: float,
795
+ strategy: Literal[
796
+ "keep_newest", "keep_oldest", "keep_highest_importance", "merge_content"
797
+ ],
798
+ dry_run: bool,
799
+ max_groups: int,
800
+ chunk_size: int,
801
+ ) -> ConsolidateResult:
802
+ """Process consolidation in memory-efficient chunks.
803
+
804
+ Processes memories in smaller chunks to reduce peak memory usage.
805
+ Note: This may miss duplicates that span chunk boundaries.
806
+
807
+ Args:
808
+ namespace: Namespace to consolidate.
809
+ similarity_threshold: Minimum similarity for duplicates.
810
+ strategy: How to handle duplicates.
811
+ dry_run: Preview without changes.
812
+ max_groups: Maximum groups to process total.
813
+ chunk_size: Memories per chunk.
814
+
815
+ Returns:
816
+ Aggregated ConsolidateResult from all chunks.
817
+ """
818
+ all_groups: list[ConsolidationGroupResult] = []
819
+ total_merged = 0
820
+ total_deleted = 0
821
+ offset = 0
822
+ groups_remaining = max_groups
823
+
824
+ while groups_remaining > 0:
825
+ # Fetch chunk of memories
826
+ chunk_memories = self._repo.get_all(
827
+ namespace=namespace,
828
+ limit=chunk_size,
829
+ )
830
+
831
+ # Skip already processed memories by filtering by offset
832
+ # Note: This is a simplified approach - in production, you'd want
833
+ # to track processed IDs or use cursor-based pagination
834
+ if offset > 0:
835
+ # Re-fetch with offset simulation (get more and skip)
836
+ all_chunk = self._repo.get_all(
837
+ namespace=namespace,
838
+ limit=offset + chunk_size,
839
+ )
840
+ if len(all_chunk) <= offset:
841
+ # No more memories to process
842
+ break
843
+ chunk_memories = all_chunk[offset:offset + chunk_size]
844
+
845
+ if len(chunk_memories) < 2:
846
+ break
847
+
848
+ # Build lookup structures for this chunk
849
+ memories = [m for m, _ in chunk_memories]
850
+ vectors_list = [v for _, v in chunk_memories]
851
+ vectors_array = np.array(vectors_list, dtype=np.float32)
852
+ memory_ids = [m.id for m in memories]
853
+ contents = [m.content for m in memories]
854
+ memory_dicts: list[dict[str, Any]] = [
855
+ {
856
+ "id": m.id,
857
+ "content": m.content,
858
+ "created_at": m.created_at,
859
+ "last_accessed": m.last_accessed,
860
+ "access_count": m.access_count,
861
+ "importance": m.importance,
862
+ "tags": list(m.tags),
863
+ }
864
+ for m in memories
865
+ ]
866
+
867
+ # Find duplicate groups in this chunk
868
+ group_indices = find_duplicate_groups(
869
+ memory_ids=memory_ids,
870
+ vectors=vectors_array,
871
+ contents=contents,
872
+ threshold=similarity_threshold,
873
+ content_weight=self._config.consolidate_content_weight,
874
+ )
875
+
876
+ # Limit groups for this chunk
877
+ group_indices = group_indices[:groups_remaining]
878
+
879
+ if not group_indices:
880
+ offset += len(chunk_memories)
881
+ continue
882
+
883
+ # Get strategy implementation
884
+ strategy_impl = get_strategy(strategy)
885
+
886
+ # Process groups in this chunk
887
+ for member_indices in group_indices:
888
+ group_member_dicts = [memory_dicts[i] for i in member_indices]
889
+ group_member_ids = [str(d["id"]) for d in group_member_dicts]
890
+
891
+ # Calculate average similarity
892
+ total_sim = 0.0
893
+ pair_count = 0
894
+ for i_idx, i in enumerate(member_indices):
895
+ for j in member_indices[i_idx + 1:]:
896
+ v1, v2 = vectors_array[i], vectors_array[j]
897
+ dot = float(np.dot(v1, v2))
898
+ norm1 = float(np.linalg.norm(v1))
899
+ norm2 = float(np.linalg.norm(v2))
900
+ if norm1 > 1e-10 and norm2 > 1e-10:
901
+ v_sim = dot / (norm1 * norm2)
902
+ else:
903
+ v_sim = 0.0
904
+ c_sim = jaccard_similarity(contents[i], contents[j])
905
+ combined = combined_similarity(
906
+ v_sim, c_sim, self._config.consolidate_content_weight
907
+ )
908
+ total_sim += combined
909
+ pair_count += 1
910
+ avg_similarity = total_sim / pair_count if pair_count > 0 else 0.0
911
+
912
+ # Apply consolidation strategy
913
+ try:
914
+ action_result: ConsolidationAction = strategy_impl.apply(
915
+ members=group_member_dicts,
916
+ member_ids=group_member_ids,
917
+ namespace=namespace,
918
+ repository=self._repo,
919
+ embeddings=self._embeddings,
920
+ dry_run=dry_run,
921
+ )
922
+ total_merged += action_result.memories_merged
923
+ total_deleted += action_result.memories_deleted
924
+ except Exception as e:
925
+ logger.warning(f"Failed to consolidate group: {e}")
926
+ action_result = ConsolidationAction(
927
+ representative_id=group_member_ids[0],
928
+ deleted_ids=[],
929
+ action="failed",
930
+ )
931
+
932
+ all_groups.append(
933
+ ConsolidationGroupResult(
934
+ representative_id=action_result.representative_id,
935
+ member_ids=group_member_ids,
936
+ avg_similarity=avg_similarity,
937
+ action_taken=action_result.action,
938
+ )
939
+ )
940
+ groups_remaining -= 1
941
+
942
+ offset += len(chunk_memories)
943
+ logger.debug(
944
+ f"Processed chunk at offset {offset - len(chunk_memories)}, "
945
+ f"found {len(group_indices)} groups"
946
+ )
947
+
948
+ return ConsolidateResult(
949
+ groups_found=len(all_groups),
950
+ memories_merged=total_merged,
951
+ memories_deleted=total_deleted,
952
+ groups=all_groups,
953
+ dry_run=dry_run,
954
+ )
955
+
956
+ def _check_duplicate(
957
+ self,
958
+ content: str,
959
+ namespace: str,
960
+ threshold: float,
961
+ ) -> bool:
962
+ """Check if similar content already exists.
963
+
964
+ Args:
965
+ content: Content to check.
966
+ namespace: Namespace to search.
967
+ threshold: Similarity threshold.
968
+
969
+ Returns:
970
+ True if a similar memory exists.
971
+ """
972
+ try:
973
+ # Generate embedding for content
974
+ vector = self._embeddings.embed(content)
975
+
976
+ # Search for similar memories
977
+ results = self._repo.search(vector, limit=5, namespace=namespace)
978
+
979
+ for result in results:
980
+ # Check vector similarity
981
+ if result.similarity >= threshold:
982
+ return True
983
+
984
+ # Also check content overlap
985
+ content_sim = jaccard_similarity(content, result.content)
986
+ combined = combined_similarity(
987
+ result.similarity,
988
+ content_sim,
989
+ self._config.consolidate_content_weight,
990
+ )
991
+ if combined >= threshold:
992
+ return True
993
+
994
+ return False
995
+
996
+ except Exception as e:
997
+ logger.warning(f"Duplicate check failed: {e}")
998
+ return False
999
+
1000
+ def _store_extracted_memory(
1001
+ self,
1002
+ content: str,
1003
+ namespace: str,
1004
+ confidence: float,
1005
+ pattern_type: str,
1006
+ ) -> str:
1007
+ """Store an extracted memory.
1008
+
1009
+ Args:
1010
+ content: Memory content.
1011
+ namespace: Target namespace.
1012
+ confidence: Extraction confidence.
1013
+ pattern_type: Type of pattern matched.
1014
+
1015
+ Returns:
1016
+ The new memory's ID.
1017
+ """
1018
+ # Generate embedding
1019
+ vector = self._embeddings.embed(content)
1020
+
1021
+ # Scale importance by confidence but keep lower than manual memories
1022
+ importance = self._config.extract_default_importance * confidence
1023
+
1024
+ # Create memory
1025
+ memory = Memory(
1026
+ id="", # Will be assigned
1027
+ content=content,
1028
+ namespace=namespace,
1029
+ tags=[f"extracted-{pattern_type}"],
1030
+ importance=importance,
1031
+ source=MemorySource.EXTRACTED,
1032
+ metadata={
1033
+ "extraction_confidence": confidence,
1034
+ "extraction_pattern": pattern_type,
1035
+ },
1036
+ )
1037
+
1038
+ return self._repo.add(memory, vector)
1039
+
1040
+ def _check_duplicate_with_vector(
1041
+ self,
1042
+ content: str,
1043
+ vector: np.ndarray,
1044
+ namespace: str,
1045
+ threshold: float,
1046
+ ) -> bool:
1047
+ """Check if similar content already exists using pre-computed vector.
1048
+
1049
+ Args:
1050
+ content: Content to check.
1051
+ vector: Pre-computed embedding vector.
1052
+ namespace: Namespace to search.
1053
+ threshold: Similarity threshold.
1054
+
1055
+ Returns:
1056
+ True if a similar memory exists.
1057
+ """
1058
+ try:
1059
+ # Search for similar memories using pre-computed vector
1060
+ results = self._repo.search(vector, limit=5, namespace=namespace)
1061
+
1062
+ for result in results:
1063
+ # Check vector similarity
1064
+ if result.similarity >= threshold:
1065
+ return True
1066
+
1067
+ # Also check content overlap
1068
+ content_sim = jaccard_similarity(content, result.content)
1069
+ combined = combined_similarity(
1070
+ result.similarity,
1071
+ content_sim,
1072
+ self._config.consolidate_content_weight,
1073
+ )
1074
+ if combined >= threshold:
1075
+ return True
1076
+
1077
+ return False
1078
+
1079
+ except Exception as e:
1080
+ logger.warning(f"Duplicate check failed: {e}")
1081
+ return False
1082
+
1083
+ def _store_extracted_memory_with_vector(
1084
+ self,
1085
+ content: str,
1086
+ vector: np.ndarray,
1087
+ namespace: str,
1088
+ confidence: float,
1089
+ pattern_type: str,
1090
+ ) -> str:
1091
+ """Store an extracted memory using pre-computed vector.
1092
+
1093
+ Args:
1094
+ content: Memory content.
1095
+ vector: Pre-computed embedding vector.
1096
+ namespace: Target namespace.
1097
+ confidence: Extraction confidence.
1098
+ pattern_type: Type of pattern matched.
1099
+
1100
+ Returns:
1101
+ The new memory's ID.
1102
+ """
1103
+ # Scale importance by confidence but keep lower than manual memories
1104
+ importance = self._config.extract_default_importance * confidence
1105
+
1106
+ # Create memory
1107
+ memory = Memory(
1108
+ id="", # Will be assigned
1109
+ content=content,
1110
+ namespace=namespace,
1111
+ tags=[f"extracted-{pattern_type}"],
1112
+ importance=importance,
1113
+ source=MemorySource.EXTRACTED,
1114
+ metadata={
1115
+ "extraction_confidence": confidence,
1116
+ "extraction_pattern": pattern_type,
1117
+ },
1118
+ )
1119
+
1120
+ return self._repo.add(memory, vector)