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