spatial-memory-mcp 1.6.1__py3-none-any.whl → 1.7.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.

Potentially problematic release.


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

@@ -7,6 +7,9 @@ from spatial_memory.core.errors import (
7
7
  NamespaceOperationError,
8
8
  ReinforcementError,
9
9
  )
10
+ from spatial_memory.services.export_import import (
11
+ ExportImportService,
12
+ )
10
13
  from spatial_memory.services.lifecycle import (
11
14
  ConsolidateResult,
12
15
  ConsolidationGroupResult,
@@ -33,9 +36,6 @@ from spatial_memory.services.spatial import (
33
36
  from spatial_memory.services.utility import (
34
37
  UtilityService,
35
38
  )
36
- from spatial_memory.services.export_import import (
37
- ExportImportService,
38
- )
39
39
 
40
40
  __all__ = [
41
41
  # Lifecycle
@@ -0,0 +1,406 @@
1
+ """Automatic decay manager for real-time importance decay.
2
+
3
+ This service provides automatic decay calculation during recall operations,
4
+ re-ranking search results based on time-decayed importance. Updates are
5
+ optionally persisted to the database in the background.
6
+
7
+ Architecture:
8
+ recall() / hybrid_recall()
9
+
10
+
11
+ DecayManager.apply_decay_to_results() ← Real-time (~20-50μs)
12
+
13
+ ┌────┴────┐
14
+ ▼ ▼
15
+ [Re-ranked [Background Queue]
16
+ Results] │
17
+
18
+ [Batch Persist Thread]
19
+
20
+
21
+ [LanceDB Update]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import math
28
+ import threading
29
+ import time
30
+ from collections import deque
31
+ from dataclasses import dataclass
32
+ from datetime import datetime
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ from spatial_memory.core.models import AutoDecayConfig
36
+ from spatial_memory.core.utils import to_aware_utc, utc_now
37
+
38
+ if TYPE_CHECKING:
39
+ from spatial_memory.ports.repositories import MemoryRepositoryProtocol
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ @dataclass
45
+ class DecayUpdate:
46
+ """A pending decay update for a memory."""
47
+
48
+ memory_id: str
49
+ old_importance: float
50
+ new_importance: float
51
+ timestamp: float # time.monotonic() for deduplication
52
+
53
+
54
+ class DecayManager:
55
+ """Manages automatic decay calculation and persistence.
56
+
57
+ This service calculates effective importance during search operations
58
+ using exponential decay based on time since last access. Results are
59
+ re-ranked by multiplying similarity with effective importance.
60
+
61
+ Background persistence is optional and uses a daemon thread with
62
+ batched updates to minimize database overhead.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ repository: MemoryRepositoryProtocol,
68
+ config: AutoDecayConfig | None = None,
69
+ ) -> None:
70
+ """Initialize the decay manager.
71
+
72
+ Args:
73
+ repository: Repository for persisting decay updates.
74
+ config: Configuration for decay behavior.
75
+ """
76
+ self._repo = repository
77
+ self._config = config or AutoDecayConfig()
78
+
79
+ # Threading primitives
80
+ self._lock = threading.Lock()
81
+ self._shutdown_event = threading.Event()
82
+ self._worker_thread: threading.Thread | None = None
83
+
84
+ # Update queue with backpressure (deque with maxlen)
85
+ # Using maxlen for automatic backpressure - oldest items dropped
86
+ self._update_queue: deque[DecayUpdate] = deque(
87
+ maxlen=self._config.max_queue_size
88
+ )
89
+
90
+ # Track pending updates by memory_id for deduplication
91
+ self._pending_updates: dict[str, DecayUpdate] = {}
92
+
93
+ # Statistics
94
+ self._stats_lock = threading.Lock()
95
+ self._updates_queued = 0
96
+ self._updates_persisted = 0
97
+ self._updates_deduplicated = 0
98
+
99
+ @property
100
+ def enabled(self) -> bool:
101
+ """Whether auto-decay is enabled."""
102
+ return self._config.enabled
103
+
104
+ @property
105
+ def persist_enabled(self) -> bool:
106
+ """Whether persistence is enabled."""
107
+ return self._config.persist_enabled
108
+
109
+ def start(self) -> None:
110
+ """Start the background persistence worker.
111
+
112
+ Safe to call multiple times - will only start if not already running.
113
+ """
114
+ if not self._config.enabled or not self._config.persist_enabled:
115
+ logger.debug("Auto-decay persistence disabled, skipping worker start")
116
+ return
117
+
118
+ if self._worker_thread is not None and self._worker_thread.is_alive():
119
+ logger.debug("Decay worker already running")
120
+ return
121
+
122
+ self._shutdown_event.clear()
123
+ self._worker_thread = threading.Thread(
124
+ target=self._background_worker,
125
+ name="decay-persist-worker",
126
+ daemon=True,
127
+ )
128
+ self._worker_thread.start()
129
+ logger.info("Auto-decay background worker started")
130
+
131
+ def stop(self, timeout: float = 5.0) -> None:
132
+ """Stop the background worker gracefully.
133
+
134
+ Flushes any pending updates before stopping.
135
+
136
+ Args:
137
+ timeout: Maximum time to wait for worker shutdown.
138
+ """
139
+ if self._worker_thread is None or not self._worker_thread.is_alive():
140
+ return
141
+
142
+ logger.info("Stopping auto-decay background worker...")
143
+ self._shutdown_event.set()
144
+
145
+ # Wait for worker to finish
146
+ self._worker_thread.join(timeout=timeout)
147
+
148
+ if self._worker_thread.is_alive():
149
+ logger.warning("Decay worker did not stop within timeout")
150
+ else:
151
+ logger.info(
152
+ f"Auto-decay worker stopped. "
153
+ f"Queued: {self._updates_queued}, "
154
+ f"Persisted: {self._updates_persisted}, "
155
+ f"Deduplicated: {self._updates_deduplicated}"
156
+ )
157
+
158
+ def calculate_effective_importance(
159
+ self,
160
+ stored_importance: float,
161
+ last_accessed: datetime,
162
+ access_count: int,
163
+ ) -> float:
164
+ """Calculate time-decayed effective importance.
165
+
166
+ Uses exponential decay: importance * 2^(-days/half_life)
167
+ Access count slows decay via effective_half_life adjustment.
168
+
169
+ Args:
170
+ stored_importance: The stored importance value (0-1).
171
+ last_accessed: When the memory was last accessed.
172
+ access_count: Number of times the memory has been accessed.
173
+
174
+ Returns:
175
+ Effective importance after decay (clamped to min_importance_floor).
176
+ """
177
+ if not self._config.enabled:
178
+ return stored_importance
179
+
180
+ # Calculate days since last access
181
+ # Normalize last_accessed to timezone-aware UTC (database may return naive)
182
+ now = utc_now()
183
+ last_accessed_aware = to_aware_utc(last_accessed)
184
+ delta = now - last_accessed_aware
185
+ days_since_access = delta.total_seconds() / 86400.0 # seconds in a day
186
+
187
+ if days_since_access <= 0:
188
+ return stored_importance
189
+
190
+ # Calculate effective half-life (access count slows decay)
191
+ # More accesses = longer effective half-life = slower decay
192
+ access_factor = 1.0 + self._config.access_weight * access_count
193
+ effective_half_life = self._config.half_life_days * access_factor
194
+
195
+ # Exponential decay: importance * 2^(-days/half_life)
196
+ decay_factor = math.pow(2, -days_since_access / effective_half_life)
197
+ effective_importance = stored_importance * decay_factor
198
+
199
+ # Clamp to minimum floor
200
+ return max(effective_importance, self._config.min_importance_floor)
201
+
202
+ def apply_decay_to_results(
203
+ self,
204
+ results: list[dict[str, Any]],
205
+ rerank: bool = True,
206
+ ) -> list[dict[str, Any]]:
207
+ """Apply decay to search results and optionally re-rank.
208
+
209
+ Calculates effective_importance for each result and optionally
210
+ re-ranks results by multiplying similarity with effective_importance.
211
+
212
+ Args:
213
+ results: List of memory result dictionaries.
214
+ rerank: Whether to re-rank by adjusted score (similarity × effective_importance).
215
+
216
+ Returns:
217
+ Results with effective_importance added, optionally re-ranked.
218
+ """
219
+ if not self._config.enabled or not results:
220
+ return results
221
+
222
+ updates_to_queue: list[DecayUpdate] = []
223
+
224
+ for result in results:
225
+ # Extract required fields
226
+ stored_importance = result.get("importance", 0.5)
227
+ last_accessed = result.get("last_accessed")
228
+ access_count = result.get("access_count", 0)
229
+ memory_id = result.get("id", "")
230
+
231
+ # Handle datetime parsing if needed
232
+ if isinstance(last_accessed, str):
233
+ try:
234
+ last_accessed = datetime.fromisoformat(last_accessed.replace("Z", "+00:00"))
235
+ except (ValueError, AttributeError):
236
+ last_accessed = utc_now()
237
+ elif last_accessed is None:
238
+ last_accessed = utc_now()
239
+
240
+ # Calculate effective importance
241
+ effective_importance = self.calculate_effective_importance(
242
+ stored_importance=stored_importance,
243
+ last_accessed=last_accessed,
244
+ access_count=access_count,
245
+ )
246
+
247
+ # Add to result
248
+ result["effective_importance"] = effective_importance
249
+
250
+ # Check if we should queue an update
251
+ if self._config.persist_enabled and memory_id:
252
+ change = abs(stored_importance - effective_importance)
253
+ if change >= self._config.min_change_threshold:
254
+ updates_to_queue.append(
255
+ DecayUpdate(
256
+ memory_id=memory_id,
257
+ old_importance=stored_importance,
258
+ new_importance=effective_importance,
259
+ timestamp=time.monotonic(),
260
+ )
261
+ )
262
+
263
+ # Queue updates in bulk
264
+ if updates_to_queue:
265
+ self._queue_updates(updates_to_queue)
266
+
267
+ # Re-rank by adjusted score if requested
268
+ if rerank:
269
+ # Calculate adjusted score: similarity × effective_importance
270
+ for result in results:
271
+ similarity = result.get("similarity", 0.0)
272
+ effective = result.get("effective_importance", result.get("importance", 0.5))
273
+ result["_adjusted_score"] = similarity * effective
274
+
275
+ # Sort by adjusted score (descending)
276
+ results.sort(key=lambda r: r.get("_adjusted_score", 0.0), reverse=True)
277
+
278
+ # Remove temporary score field
279
+ for result in results:
280
+ result.pop("_adjusted_score", None)
281
+
282
+ return results
283
+
284
+ def _queue_updates(self, updates: list[DecayUpdate]) -> None:
285
+ """Queue updates for background persistence with deduplication.
286
+
287
+ Latest update per memory_id wins - prevents duplicate writes.
288
+
289
+ Args:
290
+ updates: List of decay updates to queue.
291
+ """
292
+ with self._lock:
293
+ for update in updates:
294
+ # Deduplicate: keep latest update per memory_id
295
+ existing = self._pending_updates.get(update.memory_id)
296
+ if existing is not None:
297
+ with self._stats_lock:
298
+ self._updates_deduplicated += 1
299
+
300
+ self._pending_updates[update.memory_id] = update
301
+ self._update_queue.append(update)
302
+
303
+ with self._stats_lock:
304
+ self._updates_queued += 1
305
+
306
+ def _background_worker(self) -> None:
307
+ """Background worker that batches and persists decay updates."""
308
+ logger.debug("Decay background worker started")
309
+
310
+ while not self._shutdown_event.is_set():
311
+ try:
312
+ # Wait for flush interval or shutdown
313
+ self._shutdown_event.wait(timeout=self._config.persist_flush_interval_seconds)
314
+
315
+ # Collect batch of updates
316
+ batch = self._collect_batch()
317
+
318
+ if batch:
319
+ self._persist_batch(batch)
320
+
321
+ except Exception as e:
322
+ logger.error(f"Error in decay background worker: {e}", exc_info=True)
323
+ # Don't crash the worker on transient errors
324
+ time.sleep(1.0)
325
+
326
+ # Final flush on shutdown
327
+ try:
328
+ batch = self._collect_batch()
329
+ if batch:
330
+ logger.debug(f"Final flush: {len(batch)} updates")
331
+ self._persist_batch(batch)
332
+ except Exception as e:
333
+ logger.error(f"Error in final decay flush: {e}", exc_info=True)
334
+
335
+ logger.debug("Decay background worker stopped")
336
+
337
+ def _collect_batch(self) -> list[DecayUpdate]:
338
+ """Collect a batch of updates for persistence.
339
+
340
+ Returns:
341
+ List of unique updates (latest per memory_id).
342
+ """
343
+ with self._lock:
344
+ if not self._pending_updates:
345
+ return []
346
+
347
+ # Get unique updates (already deduplicated in _pending_updates)
348
+ batch = list(self._pending_updates.values())[:self._config.persist_batch_size]
349
+
350
+ # Clear processed updates from pending dict
351
+ for update in batch:
352
+ self._pending_updates.pop(update.memory_id, None)
353
+
354
+ return batch
355
+
356
+ def _persist_batch(self, batch: list[DecayUpdate]) -> None:
357
+ """Persist a batch of decay updates to the database.
358
+
359
+ Args:
360
+ batch: List of decay updates to persist.
361
+ """
362
+ if not batch:
363
+ return
364
+
365
+ # Build update tuples for batch update
366
+ updates = [
367
+ (update.memory_id, {"importance": update.new_importance})
368
+ for update in batch
369
+ ]
370
+
371
+ try:
372
+ success_count, failed_ids = self._repo.update_batch(updates)
373
+
374
+ with self._stats_lock:
375
+ self._updates_persisted += success_count
376
+
377
+ if failed_ids:
378
+ logger.warning(f"Failed to persist decay for {len(failed_ids)} memories")
379
+
380
+ logger.debug(f"Persisted decay updates for {success_count} memories")
381
+
382
+ except Exception as e:
383
+ logger.error(f"Failed to persist decay batch: {e}")
384
+ # Re-queue failed updates? For now, just log and continue
385
+ # In a production system, you might want retry logic here
386
+
387
+ def get_stats(self) -> dict[str, Any]:
388
+ """Get decay manager statistics.
389
+
390
+ Returns:
391
+ Dictionary with queue and persistence stats.
392
+ """
393
+ with self._stats_lock:
394
+ return {
395
+ "enabled": self._config.enabled,
396
+ "persist_enabled": self._config.persist_enabled,
397
+ "updates_queued": self._updates_queued,
398
+ "updates_persisted": self._updates_persisted,
399
+ "updates_deduplicated": self._updates_deduplicated,
400
+ "pending_updates": len(self._pending_updates),
401
+ "queue_size": len(self._update_queue),
402
+ "queue_max_size": self._config.max_queue_size,
403
+ "worker_alive": (
404
+ self._worker_thread is not None and self._worker_thread.is_alive()
405
+ ),
406
+ }
@@ -17,11 +17,11 @@ import csv
17
17
  import json
18
18
  import logging
19
19
  import time
20
- from collections.abc import Sequence
21
- from datetime import datetime, timezone
22
- from pathlib import Path
20
+ from collections.abc import Iterator, Sequence
21
+ from datetime import datetime
23
22
  from io import TextIOWrapper
24
- from typing import TYPE_CHECKING, Any, BinaryIO, Iterator
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, BinaryIO
25
25
 
26
26
  import numpy as np
27
27
 
@@ -45,6 +45,8 @@ from spatial_memory.core.models import (
45
45
  logger = logging.getLogger(__name__)
46
46
 
47
47
  if TYPE_CHECKING:
48
+ import pyarrow as pa
49
+
48
50
  from spatial_memory.ports.repositories import (
49
51
  EmbeddingServiceProtocol,
50
52
  MemoryRepositoryProtocol,
@@ -504,7 +506,7 @@ class ExportImportService:
504
506
  # Export Format Handlers
505
507
  # =========================================================================
506
508
 
507
- def _create_parquet_schema(self, include_vectors: bool) -> "pa.Schema":
509
+ def _create_parquet_schema(self, include_vectors: bool) -> pa.Schema:
508
510
  """Create PyArrow schema for Parquet export.
509
511
 
510
512
  Args:
@@ -816,8 +818,7 @@ class ExportImportService:
816
818
  if content.startswith("["):
817
819
  # JSON array
818
820
  records = json.loads(content)
819
- for record in records:
820
- yield record
821
+ yield from records
821
822
  else:
822
823
  # JSON Lines (one object per line)
823
824
  for line in content.split("\n"):
@@ -959,7 +960,8 @@ class ExportImportService:
959
960
  ImportValidationError(
960
961
  row_number=row_number,
961
962
  field="vector",
962
- error=f"Vector dimension mismatch: expected {expected_dims}, got {actual_dims}",
963
+ error=f"Vector dimension mismatch: expected "
964
+ f"{expected_dims}, got {actual_dims}",
963
965
  value=f"[{actual_dims} dimensions]",
964
966
  )
965
967
  )
@@ -20,6 +20,10 @@ from typing import TYPE_CHECKING, Any, Literal
20
20
 
21
21
  import numpy as np
22
22
 
23
+ from spatial_memory.core.consolidation_strategies import (
24
+ ConsolidationAction,
25
+ get_strategy,
26
+ )
23
27
  from spatial_memory.core.errors import (
24
28
  ConsolidationError,
25
29
  DecayError,
@@ -27,10 +31,6 @@ from spatial_memory.core.errors import (
27
31
  ReinforcementError,
28
32
  ValidationError,
29
33
  )
30
- from spatial_memory.core.consolidation_strategies import (
31
- ConsolidationAction,
32
- get_strategy,
33
- )
34
34
  from spatial_memory.core.lifecycle_ops import (
35
35
  apply_decay,
36
36
  calculate_decay_factor,
@@ -52,11 +52,11 @@ from spatial_memory.core.models import (
52
52
  ReinforcedMemory,
53
53
  ReinforceResult,
54
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
55
57
 
56
58
  # Alias for backward compatibility
57
59
  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
60
 
61
61
  logger = logging.getLogger(__name__)
62
62
 
@@ -384,7 +384,8 @@ class LifecycleService:
384
384
  # Calculate reinforcement for all found memories
385
385
  now = utc_now()
386
386
  batch_updates: list[tuple[str, dict[str, Any]]] = []
387
- reinforcement_info: list[tuple[str, Memory, float, float]] = [] # id, memory, new_imp, boost
387
+ # Tuple: (id, memory, new_importance, boost_applied)
388
+ reinforcement_info: list[tuple[str, Memory, float, float]] = []
388
389
 
389
390
  for memory_id, memory in memory_map.items():
390
391
  # Calculate new importance
@@ -413,7 +414,9 @@ class LifecycleService:
413
414
  f"{len(batch_failed_ids)} failed"
414
415
  )
415
416
  except Exception as e:
416
- logger.warning(f"Batch reinforce update failed: {e}, falling back to individual updates")
417
+ logger.warning(
418
+ f"Batch reinforce update failed: {e}, falling back to individual updates"
419
+ )
417
420
  # Fall back to individual updates on batch failure
418
421
  batch_failed_ids = []
419
422
  for memory_id, updates in batch_updates:
@@ -267,8 +267,11 @@ class SpatialService:
267
267
  steps_with_memories += 1
268
268
 
269
269
  # Use 0.0 if no memories found (inf means no distance calculated)
270
- # Clamp to 0.0 to handle floating point precision errors (e.g., -4.89e-08)
271
- final_distance = 0.0 if distance_to_path == float("inf") else max(0.0, distance_to_path)
270
+ # Clamp to 0.0 to handle floating point precision errors
271
+ if distance_to_path == float("inf"):
272
+ final_distance = 0.0
273
+ else:
274
+ final_distance = max(0.0, distance_to_path)
272
275
  journey_steps.append(
273
276
  JourneyStep(
274
277
  step=step_num,
@@ -243,7 +243,8 @@ class UtilityService:
243
243
  namespace=namespace,
244
244
  memories_deleted=memory_count,
245
245
  success=True,
246
- message=f"DRY RUN: Would delete {memory_count} memories from namespace '{namespace}'",
246
+ message=f"DRY RUN: Would delete {memory_count} memories "
247
+ f"from namespace '{namespace}'",
247
248
  dry_run=True,
248
249
  )
249
250
 
@@ -261,7 +262,7 @@ class UtilityService:
261
262
  namespace=namespace,
262
263
  memories_deleted=deleted_count,
263
264
  success=True,
264
- message=f"Successfully deleted {deleted_count} memories from namespace '{namespace}'",
265
+ message=f"Deleted {deleted_count} memories from namespace '{namespace}'",
265
266
  dry_run=False,
266
267
  )
267
268
 
@@ -313,7 +314,8 @@ class UtilityService:
313
314
  new_namespace=new_namespace,
314
315
  memories_renamed=renamed_count,
315
316
  success=True,
316
- message=f"Successfully renamed {renamed_count} memories from '{old_namespace}' to '{new_namespace}'",
317
+ message=f"Renamed {renamed_count} memories "
318
+ f"from '{old_namespace}' to '{new_namespace}'",
317
319
  )
318
320
 
319
321
  except NamespaceNotFoundError:
@@ -397,9 +399,21 @@ class UtilityService:
397
399
  vector_score=getattr(result, "vector_score", None),
398
400
  fts_score=getattr(result, "fts_score", None),
399
401
  combined_score=result.similarity,
402
+ # For auto-decay support
403
+ last_accessed=result.last_accessed,
404
+ access_count=result.access_count,
400
405
  )
401
406
  )
402
407
 
408
+ # Update access stats for returned memories (batch for efficiency)
409
+ if memories:
410
+ memory_ids = [m.id for m in memories]
411
+ try:
412
+ self._repo.update_access_batch(memory_ids)
413
+ except Exception as e:
414
+ # Log but don't fail the search if access update fails
415
+ logger.warning(f"Failed to update access stats: {e}")
416
+
403
417
  return HybridRecallResult(
404
418
  query=query,
405
419
  alpha=alpha,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatial-memory-mcp
3
- Version: 1.6.1
3
+ Version: 1.7.0
4
4
  Summary: Spatial bidirectional persistent memory MCP server for LLMs - vector-based semantic memory as a navigable landscape
5
5
  Project-URL: Homepage, https://github.com/arman-tech/spatial-memory-mcp
6
6
  Project-URL: Repository, https://github.com/arman-tech/spatial-memory-mcp
@@ -48,7 +48,7 @@ Description-Content-Type: text/markdown
48
48
 
49
49
  A vector-based spatial memory system that treats knowledge as a navigable landscape, not a filing cabinet.
50
50
 
51
- > **Version 1.6.1** — Production-ready with 1,360 tests passing.
51
+ > **Version 1.6.2** — Production-ready with 1,360 tests passing.
52
52
 
53
53
  ## Supported Platforms
54
54