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,769 @@
1
+ """Configuration system for Spatial Memory MCP Server."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import Field, SecretStr
7
+ from pydantic_settings import BaseSettings
8
+
9
+ from spatial_memory.core.errors import ConfigurationError
10
+
11
+ # Re-export for backward compatibility
12
+ __all__ = [
13
+ "Settings",
14
+ "ConfigurationError",
15
+ "get_settings",
16
+ "override_settings",
17
+ "reset_settings",
18
+ "validate_startup",
19
+ ]
20
+
21
+
22
+ class Settings(BaseSettings):
23
+ """Spatial Memory Server Configuration."""
24
+
25
+ # Storage
26
+ memory_path: Path = Field(
27
+ default=Path("./.spatial-memory"),
28
+ description="Path to LanceDB storage directory",
29
+ )
30
+ acknowledge_network_filesystem_risk: bool = Field(
31
+ default=False,
32
+ description=(
33
+ "Set to True to suppress warnings about network filesystem usage. "
34
+ "File-based locking does not work reliably on NFS/SMB/CIFS. "
35
+ "Only set this if you are certain only one instance will access the storage."
36
+ ),
37
+ )
38
+
39
+ # Embedding Model
40
+ embedding_model: str = Field(
41
+ default="all-MiniLM-L6-v2",
42
+ description="Sentence-transformers model name or 'openai:model-name'",
43
+ )
44
+ embedding_dimensions: int = Field(
45
+ default=384,
46
+ description="Embedding vector dimensions (auto-detected if not set)",
47
+ )
48
+ embedding_backend: str = Field(
49
+ default="auto",
50
+ description="Embedding backend: 'auto', 'onnx', or 'pytorch'",
51
+ )
52
+
53
+ # OpenAI (optional)
54
+ openai_api_key: SecretStr | None = Field(
55
+ default=None,
56
+ description="OpenAI API key for API-based embeddings",
57
+ )
58
+ openai_embedding_model: str = Field(
59
+ default="text-embedding-3-small",
60
+ description="OpenAI embedding model to use",
61
+ )
62
+
63
+ # Server
64
+ log_level: str = Field(
65
+ default="INFO",
66
+ description="Logging level (DEBUG, INFO, WARNING, ERROR)",
67
+ )
68
+ log_format: str = Field(
69
+ default="text",
70
+ description="Log format: 'text' or 'json'",
71
+ )
72
+
73
+ # Memory Defaults
74
+ default_namespace: str = Field(
75
+ default="default",
76
+ description="Default namespace for memories",
77
+ )
78
+ default_importance: float = Field(
79
+ default=0.5,
80
+ ge=0.0,
81
+ le=1.0,
82
+ description="Default importance for new memories",
83
+ )
84
+
85
+ # Limits
86
+ max_batch_size: int = Field(
87
+ default=100,
88
+ description="Maximum memories per batch operation",
89
+ )
90
+ max_recall_limit: int = Field(
91
+ default=100,
92
+ description="Maximum results from recall",
93
+ )
94
+ max_journey_steps: int = Field(
95
+ default=20,
96
+ description="Maximum steps in journey",
97
+ )
98
+ max_wander_steps: int = Field(
99
+ default=20,
100
+ description="Maximum steps in wander",
101
+ )
102
+ max_visualize_memories: int = Field(
103
+ default=500,
104
+ description="Maximum memories in visualization",
105
+ )
106
+ regions_max_memories: int = Field(
107
+ default=1000,
108
+ description="Maximum memories to consider for region clustering",
109
+ )
110
+ visualize_similarity_threshold: float = Field(
111
+ default=0.7,
112
+ ge=0.0,
113
+ le=1.0,
114
+ description="Minimum similarity to show edges in visualization",
115
+ )
116
+
117
+ # Clustering
118
+ min_cluster_size: int = Field(
119
+ default=3,
120
+ ge=2,
121
+ description="Minimum memories for a cluster",
122
+ )
123
+
124
+ # Indexing
125
+ vector_index_threshold: int = Field(
126
+ default=10_000,
127
+ ge=1000,
128
+ description="Create vector index when dataset exceeds this size",
129
+ )
130
+ auto_create_indexes: bool = Field(
131
+ default=True,
132
+ description="Automatically create indexes when thresholds are met",
133
+ )
134
+ index_nprobes: int = Field(
135
+ default=20,
136
+ ge=1,
137
+ description="Number of partitions to search (higher = better recall, slower)",
138
+ )
139
+ index_refine_factor: int = Field(
140
+ default=5,
141
+ ge=1,
142
+ description="Re-rank top (refine_factor * limit) candidates for accuracy",
143
+ )
144
+ index_type: Literal["IVF_PQ", "IVF_FLAT", "HNSW_SQ"] = Field(
145
+ default="IVF_PQ",
146
+ description="Vector index type: IVF_PQ, IVF_FLAT, or HNSW_SQ",
147
+ )
148
+ hnsw_m: int = Field(
149
+ default=20,
150
+ ge=4,
151
+ le=64,
152
+ description="HNSW connections per node",
153
+ )
154
+ hnsw_ef_construction: int = Field(
155
+ default=300,
156
+ ge=100,
157
+ le=1000,
158
+ description="HNSW build-time search width",
159
+ )
160
+
161
+ # Hybrid Search
162
+ enable_fts_index: bool = Field(
163
+ default=True,
164
+ description="Enable full-text search index for hybrid search",
165
+ )
166
+
167
+ # FTS Configuration
168
+ fts_stem: bool = Field(
169
+ default=True,
170
+ description="Enable stemming in FTS (running -> run)",
171
+ )
172
+ fts_remove_stop_words: bool = Field(
173
+ default=True,
174
+ description="Remove stop words in FTS (the, is, etc.)",
175
+ )
176
+ fts_language: str = Field(
177
+ default="English",
178
+ description="Language for FTS stemming",
179
+ )
180
+
181
+ # Performance
182
+ max_retry_attempts: int = Field(
183
+ default=3,
184
+ ge=1,
185
+ description="Maximum retry attempts for transient errors",
186
+ )
187
+ retry_backoff_seconds: float = Field(
188
+ default=0.5,
189
+ ge=0.1,
190
+ description="Initial backoff time for retries (doubles each attempt)",
191
+ )
192
+ batch_size: int = Field(
193
+ default=1000,
194
+ ge=100,
195
+ description="Batch size for large operations",
196
+ )
197
+ compaction_threshold: int = Field(
198
+ default=10,
199
+ ge=1,
200
+ description="Number of small fragments before auto-compaction",
201
+ )
202
+
203
+ # Connection Pool
204
+ connection_pool_max_size: int = Field(
205
+ default=10,
206
+ ge=1,
207
+ le=100,
208
+ description="Maximum connections in the pool (LRU eviction)",
209
+ )
210
+
211
+ # Cross-Process Locking
212
+ filelock_enabled: bool = Field(
213
+ default=True,
214
+ description="Enable cross-process file locking for multi-instance safety",
215
+ )
216
+ filelock_timeout: float = Field(
217
+ default=30.0,
218
+ ge=1.0,
219
+ le=300.0,
220
+ description="Timeout in seconds for acquiring filelock",
221
+ )
222
+ filelock_poll_interval: float = Field(
223
+ default=0.1,
224
+ ge=0.01,
225
+ le=1.0,
226
+ description="Interval between lock acquisition attempts",
227
+ )
228
+
229
+ # Read Consistency
230
+ read_consistency_interval_ms: int = Field(
231
+ default=0,
232
+ ge=0,
233
+ description="Interval for read consistency checks (0 = strong consistency)",
234
+ )
235
+
236
+ # Index Management
237
+ index_wait_timeout_seconds: float = Field(
238
+ default=30.0,
239
+ ge=0.0,
240
+ description="Timeout for waiting on index creation",
241
+ )
242
+
243
+ # UMAP
244
+ umap_n_neighbors: int = Field(
245
+ default=15,
246
+ ge=2,
247
+ description="UMAP neighborhood size",
248
+ )
249
+ umap_min_dist: float = Field(
250
+ default=0.1,
251
+ ge=0.0,
252
+ le=1.0,
253
+ description="UMAP minimum distance",
254
+ )
255
+
256
+ # TTL Configuration
257
+ enable_memory_expiration: bool = Field(
258
+ default=False,
259
+ description="Enable automatic memory expiration",
260
+ )
261
+ default_memory_ttl_days: int | None = Field(
262
+ default=None,
263
+ description="Default TTL for memories in days (None = no expiration)",
264
+ )
265
+
266
+ # Rate Limiting
267
+ embedding_rate_limit: float = Field(
268
+ default=100.0,
269
+ ge=1.0,
270
+ description="Maximum embedding operations per second",
271
+ )
272
+ batch_rate_limit: float = Field(
273
+ default=10.0,
274
+ ge=1.0,
275
+ description="Maximum batch operations per second",
276
+ )
277
+
278
+ # =========================================================================
279
+ # Lifecycle Settings
280
+ # =========================================================================
281
+
282
+ # Decay Settings
283
+ decay_default_half_life_days: float = Field(
284
+ default=30.0,
285
+ ge=1.0,
286
+ le=365.0,
287
+ description="Default half-life for exponential decay",
288
+ )
289
+ decay_default_function: str = Field(
290
+ default="exponential",
291
+ description="Default decay function (exponential, linear, step)",
292
+ )
293
+ decay_min_importance_floor: float = Field(
294
+ default=0.1,
295
+ ge=0.0,
296
+ le=0.5,
297
+ description="Minimum importance after decay",
298
+ )
299
+ decay_batch_size: int = Field(
300
+ default=500,
301
+ ge=100,
302
+ description="Batch size for decay updates",
303
+ )
304
+
305
+ # Reinforcement Settings
306
+ reinforce_default_boost: float = Field(
307
+ default=0.1,
308
+ ge=0.01,
309
+ le=0.5,
310
+ description="Default boost amount for reinforcement",
311
+ )
312
+ reinforce_max_importance: float = Field(
313
+ default=1.0,
314
+ ge=0.5,
315
+ le=1.0,
316
+ description="Maximum importance after reinforcement",
317
+ )
318
+
319
+ # Extraction Settings
320
+ extract_max_text_length: int = Field(
321
+ default=50000,
322
+ ge=1000,
323
+ description="Maximum text length for extraction",
324
+ )
325
+ extract_max_candidates: int = Field(
326
+ default=20,
327
+ ge=1,
328
+ description="Maximum candidates per extraction",
329
+ )
330
+ extract_default_importance: float = Field(
331
+ default=0.4,
332
+ ge=0.0,
333
+ le=1.0,
334
+ description="Default importance for extracted memories",
335
+ )
336
+ extract_default_namespace: str = Field(
337
+ default="extracted",
338
+ description="Default namespace for extracted memories",
339
+ )
340
+
341
+ # Consolidation Settings
342
+ consolidate_min_threshold: float = Field(
343
+ default=0.7,
344
+ ge=0.5,
345
+ le=0.99,
346
+ description="Minimum similarity threshold for consolidation",
347
+ )
348
+ consolidate_content_weight: float = Field(
349
+ default=0.3,
350
+ ge=0.0,
351
+ le=1.0,
352
+ description="Weight of content overlap vs vector similarity",
353
+ )
354
+ consolidate_max_batch: int = Field(
355
+ default=1000,
356
+ ge=100,
357
+ description="Maximum memories per consolidation pass",
358
+ )
359
+
360
+ # =========================================================================
361
+ # Phase 5: Utility Settings
362
+ # =========================================================================
363
+
364
+ hybrid_default_alpha: float = Field(
365
+ default=0.5,
366
+ ge=0.0,
367
+ le=1.0,
368
+ description="Default alpha for hybrid search (1.0=vector, 0.0=keyword)",
369
+ )
370
+ namespace_batch_size: int = Field(
371
+ default=1000,
372
+ ge=100,
373
+ description="Batch size for namespace operations",
374
+ )
375
+
376
+ # =========================================================================
377
+ # Phase 5: File Security Settings
378
+ # =========================================================================
379
+
380
+ # Export Settings
381
+ export_allowed_paths: list[str] = Field(
382
+ default_factory=lambda: ["./exports", "./backups"],
383
+ description="Directories where exports are allowed (relative to memory_path)",
384
+ )
385
+ export_allow_symlinks: bool = Field(
386
+ default=False,
387
+ description="Allow following symlinks in export paths",
388
+ )
389
+
390
+ # Import Settings
391
+ import_allowed_paths: list[str] = Field(
392
+ default_factory=lambda: ["./imports", "./backups"],
393
+ description="Directories where imports are allowed (relative to memory_path)",
394
+ )
395
+ import_allow_symlinks: bool = Field(
396
+ default=False,
397
+ description="Allow following symlinks in import paths",
398
+ )
399
+ import_max_file_size_mb: float = Field(
400
+ default=100.0,
401
+ ge=1.0,
402
+ le=1000.0,
403
+ description="Maximum import file size in megabytes",
404
+ )
405
+ import_max_records: int = Field(
406
+ default=100_000,
407
+ ge=1000,
408
+ le=10_000_000,
409
+ description="Maximum records per import operation",
410
+ )
411
+ import_fail_fast: bool = Field(
412
+ default=False,
413
+ description="Stop import on first validation error",
414
+ )
415
+ import_validate_vectors: bool = Field(
416
+ default=True,
417
+ description="Validate vector dimensions match embedding model",
418
+ )
419
+
420
+ # =========================================================================
421
+ # Phase 5: Destructive Operation Settings
422
+ # =========================================================================
423
+
424
+ destructive_confirm_threshold: int = Field(
425
+ default=100,
426
+ ge=1,
427
+ description="Require confirmation for operations affecting more than N records",
428
+ )
429
+ destructive_require_namespace_confirmation: bool = Field(
430
+ default=True,
431
+ description="Require explicit namespace confirmation for delete_namespace",
432
+ )
433
+
434
+ # =========================================================================
435
+ # Phase 5: Export/Import Operational Settings
436
+ # =========================================================================
437
+
438
+ export_default_format: str = Field(
439
+ default="parquet",
440
+ description="Default export format (parquet, json, csv)",
441
+ )
442
+ export_batch_size: int = Field(
443
+ default=5000,
444
+ ge=100,
445
+ description="Records per batch during export",
446
+ )
447
+ import_batch_size: int = Field(
448
+ default=1000,
449
+ ge=100,
450
+ description="Records per batch during import",
451
+ )
452
+ import_deduplicate_default: bool = Field(
453
+ default=False,
454
+ description="Deduplicate imports by default",
455
+ )
456
+ import_dedup_threshold: float = Field(
457
+ default=0.95,
458
+ ge=0.7,
459
+ le=0.99,
460
+ description="Similarity threshold for import deduplication",
461
+ )
462
+
463
+ # CSV Export
464
+ csv_include_vectors: bool = Field(
465
+ default=False,
466
+ description="Include vector embeddings in CSV exports (large files)",
467
+ )
468
+
469
+ # Export Limits
470
+ max_export_records: int = Field(
471
+ default=1_000_000,
472
+ ge=1000,
473
+ le=10_000_000,
474
+ description="Maximum records per export operation",
475
+ )
476
+
477
+ # Hybrid Search Bounds
478
+ hybrid_min_alpha: float = Field(
479
+ default=0.0,
480
+ ge=0.0,
481
+ le=1.0,
482
+ description="Minimum alpha for hybrid search (0.0=pure keyword)",
483
+ )
484
+ hybrid_max_alpha: float = Field(
485
+ default=1.0,
486
+ ge=0.0,
487
+ le=1.0,
488
+ description="Maximum alpha for hybrid search (1.0=pure vector)",
489
+ )
490
+
491
+ # =========================================================================
492
+ # v1.5.3 Phase 1: Observability Settings
493
+ # =========================================================================
494
+
495
+ include_request_meta: bool = Field(
496
+ default=False,
497
+ description="Include _meta object in responses (request_id, timing, etc.)",
498
+ )
499
+ log_include_trace_context: bool = Field(
500
+ default=True,
501
+ description="Add [req=][agent=] trace context to log messages",
502
+ )
503
+ include_timing_breakdown: bool = Field(
504
+ default=False,
505
+ description="Include timing_ms breakdown in _meta (requires include_request_meta)",
506
+ )
507
+
508
+ # =========================================================================
509
+ # v1.5.3 Phase 2: Efficiency Settings
510
+ # =========================================================================
511
+
512
+ warm_up_on_start: bool = Field(
513
+ default=True,
514
+ description="Pre-load embedding model on startup for faster first request",
515
+ )
516
+ response_cache_enabled: bool = Field(
517
+ default=True,
518
+ description="Enable response caching for idempotent operations",
519
+ )
520
+ response_cache_max_size: int = Field(
521
+ default=1000,
522
+ ge=100,
523
+ le=100000,
524
+ description="Maximum number of cached responses (LRU eviction)",
525
+ )
526
+ response_cache_default_ttl: float = Field(
527
+ default=60.0,
528
+ ge=1.0,
529
+ le=3600.0,
530
+ description="Default TTL in seconds for cached responses",
531
+ )
532
+ response_cache_regions_ttl: float = Field(
533
+ default=300.0,
534
+ ge=60.0,
535
+ le=3600.0,
536
+ description="TTL in seconds for regions() responses (expensive operation)",
537
+ )
538
+ idempotency_enabled: bool = Field(
539
+ default=True,
540
+ description="Enable idempotency key support for write operations",
541
+ )
542
+ idempotency_key_ttl_hours: float = Field(
543
+ default=24.0,
544
+ ge=1.0,
545
+ le=168.0,
546
+ description="Hours to remember idempotency keys (max 7 days)",
547
+ )
548
+
549
+ # =========================================================================
550
+ # v1.5.3 Phase 3: Resilience Settings
551
+ # =========================================================================
552
+
553
+ rate_limit_per_agent_enabled: bool = Field(
554
+ default=True,
555
+ description="Enable per-agent rate limiting",
556
+ )
557
+ rate_limit_per_agent_rate: float = Field(
558
+ default=25.0,
559
+ ge=1.0,
560
+ le=1000.0,
561
+ description="Maximum operations per second per agent",
562
+ )
563
+ rate_limit_max_tracked_agents: int = Field(
564
+ default=20,
565
+ ge=1,
566
+ le=1000,
567
+ description="Maximum number of agents to track for rate limiting (LRU eviction)",
568
+ )
569
+ circuit_breaker_enabled: bool = Field(
570
+ default=True,
571
+ description="Enable circuit breaker for external dependencies",
572
+ )
573
+ circuit_breaker_failure_threshold: int = Field(
574
+ default=5,
575
+ ge=1,
576
+ le=100,
577
+ description="Number of consecutive failures before circuit opens",
578
+ )
579
+ circuit_breaker_reset_timeout: float = Field(
580
+ default=60.0,
581
+ ge=5.0,
582
+ le=600.0,
583
+ description="Seconds to wait before attempting half-open state",
584
+ )
585
+ backpressure_queue_enabled: bool = Field(
586
+ default=False,
587
+ description="Enable backpressure queue for overload protection (future)",
588
+ )
589
+ backpressure_queue_max_size: int = Field(
590
+ default=100,
591
+ ge=10,
592
+ le=10000,
593
+ description="Maximum queue depth when backpressure is enabled",
594
+ )
595
+
596
+ # =========================================================================
597
+ # v1.6.3: Auto-Decay Settings
598
+ # =========================================================================
599
+
600
+ auto_decay_enabled: bool = Field(
601
+ default=True,
602
+ description="Enable automatic decay calculation during recall operations",
603
+ )
604
+ auto_decay_persist_enabled: bool = Field(
605
+ default=True,
606
+ description="Persist decay updates to database (disable for read-only scenarios)",
607
+ )
608
+ auto_decay_persist_batch_size: int = Field(
609
+ default=100,
610
+ ge=10,
611
+ le=1000,
612
+ description="Batch size for persisting decay updates to database",
613
+ )
614
+ auto_decay_persist_flush_interval_seconds: float = Field(
615
+ default=5.0,
616
+ ge=1.0,
617
+ le=60.0,
618
+ description="Interval between background flush operations for decay updates",
619
+ )
620
+ auto_decay_min_change_threshold: float = Field(
621
+ default=0.01,
622
+ ge=0.001,
623
+ le=0.1,
624
+ description="Minimum importance change to trigger database persistence (1% default)",
625
+ )
626
+ auto_decay_max_queue_size: int = Field(
627
+ default=10000,
628
+ ge=1000,
629
+ le=100000,
630
+ description="Maximum queue size for pending decay updates (backpressure control)",
631
+ )
632
+ auto_decay_function: str = Field(
633
+ default="exponential",
634
+ description="Decay function for auto-decay: exponential, linear, or step",
635
+ )
636
+
637
+ model_config = {
638
+ "env_prefix": "SPATIAL_MEMORY_",
639
+ "env_file": ".env",
640
+ "env_file_encoding": "utf-8",
641
+ }
642
+
643
+
644
+ # Settings singleton with dependency injection support
645
+ _settings: Settings | None = None
646
+
647
+
648
+ def get_settings() -> Settings:
649
+ """Get the settings instance (lazy-loaded singleton).
650
+
651
+ Returns:
652
+ The Settings instance.
653
+
654
+ Example:
655
+ from spatial_memory.config import get_settings
656
+ settings = get_settings()
657
+ print(settings.memory_path)
658
+ """
659
+ global _settings
660
+ if _settings is None:
661
+ _settings = Settings()
662
+ return _settings
663
+
664
+
665
+ def override_settings(new_settings: Settings) -> None:
666
+ """Override the settings instance (for testing).
667
+
668
+ Args:
669
+ new_settings: The new Settings instance to use.
670
+
671
+ Example:
672
+ from spatial_memory.config import override_settings, Settings
673
+ test_settings = Settings(memory_path="/tmp/test")
674
+ override_settings(test_settings)
675
+ """
676
+ global _settings
677
+ _settings = new_settings
678
+
679
+
680
+ def reset_settings() -> None:
681
+ """Reset settings to None (forces reload on next get_settings call)."""
682
+ global _settings
683
+ _settings = None
684
+
685
+
686
+ # Backwards compatibility - lazy property that calls get_settings()
687
+ class _SettingsProxy:
688
+ """Proxy object for backwards compatibility with `settings` global."""
689
+
690
+ def __getattr__(self, name: str) -> Any:
691
+ return getattr(get_settings(), name)
692
+
693
+ def __repr__(self) -> str:
694
+ return repr(get_settings())
695
+
696
+
697
+ settings = _SettingsProxy()
698
+
699
+
700
+ def validate_startup(settings: Settings) -> list[str]:
701
+ """Validate settings at startup.
702
+
703
+ Args:
704
+ settings: The settings to validate.
705
+
706
+ Returns:
707
+ List of warning messages (non-fatal issues).
708
+
709
+ Raises:
710
+ ConfigurationError: For fatal configuration issues.
711
+ """
712
+ warnings = []
713
+
714
+ # 1. Validate OpenAI key when using OpenAI embeddings
715
+ has_openai_key = (
716
+ settings.openai_api_key is not None
717
+ and settings.openai_api_key.get_secret_value() != ""
718
+ )
719
+ if settings.embedding_model.startswith("openai:") and not has_openai_key:
720
+ raise ConfigurationError(
721
+ "OpenAI API key required when using OpenAI embeddings. "
722
+ "Set SPATIAL_MEMORY_OPENAI_API_KEY environment variable."
723
+ )
724
+
725
+ # 2. Validate storage path exists or can be created
726
+ try:
727
+ settings.memory_path.mkdir(parents=True, exist_ok=True)
728
+ except (OSError, PermissionError) as e:
729
+ raise ConfigurationError(f"Cannot create storage path: {settings.memory_path}: {e}")
730
+
731
+ # 3. Check storage path is writable
732
+ test_file = settings.memory_path / ".write_test"
733
+ try:
734
+ test_file.touch()
735
+ test_file.unlink()
736
+ except (OSError, PermissionError) as e:
737
+ raise ConfigurationError(f"Storage path not writable: {settings.memory_path}: {e}")
738
+
739
+ # 4. Validate embedding_backend setting
740
+ valid_backends = ("auto", "onnx", "pytorch")
741
+ if settings.embedding_backend not in valid_backends:
742
+ raise ConfigurationError(
743
+ f"Invalid embedding_backend: '{settings.embedding_backend}'. "
744
+ f"Must be one of: {', '.join(valid_backends)}"
745
+ )
746
+
747
+ # 5. Check ONNX availability if explicitly requested
748
+ if settings.embedding_backend == "onnx":
749
+ try:
750
+ import onnxruntime # noqa: F401
751
+ import optimum.onnxruntime # noqa: F401
752
+ except ImportError:
753
+ raise ConfigurationError(
754
+ "ONNX Runtime requested but not fully installed. "
755
+ "Install with: pip install sentence-transformers[onnx]"
756
+ )
757
+
758
+ # 6. Warn on suboptimal settings
759
+ if settings.index_nprobes < 10:
760
+ warnings.append(
761
+ f"index_nprobes={settings.index_nprobes} is low; consider 20+ for better recall"
762
+ )
763
+
764
+ if settings.max_retry_attempts < 2:
765
+ warnings.append(
766
+ "max_retry_attempts < 2 may cause failures on transient errors"
767
+ )
768
+
769
+ return warnings