alma-memory 0.4.0__py3-none-any.whl → 0.5.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 (94) hide show
  1. alma/__init__.py +121 -45
  2. alma/confidence/__init__.py +1 -1
  3. alma/confidence/engine.py +92 -58
  4. alma/confidence/types.py +34 -14
  5. alma/config/loader.py +3 -2
  6. alma/consolidation/__init__.py +23 -0
  7. alma/consolidation/engine.py +678 -0
  8. alma/consolidation/prompts.py +84 -0
  9. alma/core.py +136 -28
  10. alma/domains/__init__.py +6 -6
  11. alma/domains/factory.py +12 -9
  12. alma/domains/schemas.py +17 -3
  13. alma/domains/types.py +8 -4
  14. alma/events/__init__.py +75 -0
  15. alma/events/emitter.py +284 -0
  16. alma/events/storage_mixin.py +246 -0
  17. alma/events/types.py +126 -0
  18. alma/events/webhook.py +425 -0
  19. alma/exceptions.py +49 -0
  20. alma/extraction/__init__.py +31 -0
  21. alma/extraction/auto_learner.py +265 -0
  22. alma/extraction/extractor.py +420 -0
  23. alma/graph/__init__.py +106 -0
  24. alma/graph/backends/__init__.py +32 -0
  25. alma/graph/backends/kuzu.py +624 -0
  26. alma/graph/backends/memgraph.py +432 -0
  27. alma/graph/backends/memory.py +236 -0
  28. alma/graph/backends/neo4j.py +417 -0
  29. alma/graph/base.py +159 -0
  30. alma/graph/extraction.py +198 -0
  31. alma/graph/store.py +860 -0
  32. alma/harness/__init__.py +4 -4
  33. alma/harness/base.py +18 -9
  34. alma/harness/domains.py +27 -11
  35. alma/initializer/__init__.py +1 -1
  36. alma/initializer/initializer.py +51 -43
  37. alma/initializer/types.py +25 -17
  38. alma/integration/__init__.py +9 -9
  39. alma/integration/claude_agents.py +32 -20
  40. alma/integration/helena.py +32 -22
  41. alma/integration/victor.py +57 -33
  42. alma/learning/__init__.py +27 -27
  43. alma/learning/forgetting.py +198 -148
  44. alma/learning/heuristic_extractor.py +40 -24
  45. alma/learning/protocols.py +65 -17
  46. alma/learning/validation.py +7 -2
  47. alma/mcp/__init__.py +4 -4
  48. alma/mcp/__main__.py +2 -1
  49. alma/mcp/resources.py +17 -16
  50. alma/mcp/server.py +102 -44
  51. alma/mcp/tools.py +180 -45
  52. alma/observability/__init__.py +84 -0
  53. alma/observability/config.py +302 -0
  54. alma/observability/logging.py +424 -0
  55. alma/observability/metrics.py +583 -0
  56. alma/observability/tracing.py +440 -0
  57. alma/progress/__init__.py +3 -3
  58. alma/progress/tracker.py +26 -20
  59. alma/progress/types.py +8 -12
  60. alma/py.typed +0 -0
  61. alma/retrieval/__init__.py +11 -11
  62. alma/retrieval/cache.py +20 -21
  63. alma/retrieval/embeddings.py +4 -4
  64. alma/retrieval/engine.py +179 -39
  65. alma/retrieval/scoring.py +73 -63
  66. alma/session/__init__.py +2 -2
  67. alma/session/manager.py +5 -5
  68. alma/session/types.py +5 -4
  69. alma/storage/__init__.py +70 -0
  70. alma/storage/azure_cosmos.py +414 -133
  71. alma/storage/base.py +215 -4
  72. alma/storage/chroma.py +1443 -0
  73. alma/storage/constants.py +103 -0
  74. alma/storage/file_based.py +59 -28
  75. alma/storage/migrations/__init__.py +21 -0
  76. alma/storage/migrations/base.py +321 -0
  77. alma/storage/migrations/runner.py +323 -0
  78. alma/storage/migrations/version_stores.py +337 -0
  79. alma/storage/migrations/versions/__init__.py +11 -0
  80. alma/storage/migrations/versions/v1_0_0.py +373 -0
  81. alma/storage/pinecone.py +1080 -0
  82. alma/storage/postgresql.py +1559 -0
  83. alma/storage/qdrant.py +1306 -0
  84. alma/storage/sqlite_local.py +504 -60
  85. alma/testing/__init__.py +46 -0
  86. alma/testing/factories.py +301 -0
  87. alma/testing/mocks.py +389 -0
  88. alma/types.py +62 -14
  89. alma_memory-0.5.1.dist-info/METADATA +939 -0
  90. alma_memory-0.5.1.dist-info/RECORD +93 -0
  91. {alma_memory-0.4.0.dist-info → alma_memory-0.5.1.dist-info}/WHEEL +1 -1
  92. alma_memory-0.4.0.dist-info/METADATA +0 -488
  93. alma_memory-0.4.0.dist-info/RECORD +0 -52
  94. {alma_memory-0.4.0.dist-info → alma_memory-0.5.1.dist-info}/top_level.txt +0 -0
@@ -6,28 +6,31 @@ This is the recommended backend for local development and testing.
6
6
  """
7
7
 
8
8
  import json
9
- import sqlite3
10
9
  import logging
11
- import numpy as np
12
- from pathlib import Path
13
- from datetime import datetime, timezone
14
- from typing import Optional, List, Dict, Any, Tuple
10
+ import sqlite3
15
11
  from contextlib import contextmanager
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
16
15
 
16
+ import numpy as np
17
+
18
+ from alma.storage.base import StorageBackend
19
+ from alma.storage.constants import SQLITE_TABLE_NAMES, MemoryType
17
20
  from alma.types import (
21
+ AntiPattern,
22
+ DomainKnowledge,
18
23
  Heuristic,
19
24
  Outcome,
20
25
  UserPreference,
21
- DomainKnowledge,
22
- AntiPattern,
23
26
  )
24
- from alma.storage.base import StorageBackend
25
27
 
26
28
  logger = logging.getLogger(__name__)
27
29
 
28
30
  # Try to import FAISS, fall back to numpy-based search if not available
29
31
  try:
30
32
  import faiss
33
+
31
34
  FAISS_AVAILABLE = True
32
35
  except ImportError:
33
36
  FAISS_AVAILABLE = False
@@ -54,6 +57,7 @@ class SQLiteStorage(StorageBackend):
54
57
  self,
55
58
  db_path: Path,
56
59
  embedding_dim: int = 384, # Default for all-MiniLM-L6-v2
60
+ auto_migrate: bool = True,
57
61
  ):
58
62
  """
59
63
  Initialize SQLite storage.
@@ -61,19 +65,29 @@ class SQLiteStorage(StorageBackend):
61
65
  Args:
62
66
  db_path: Path to SQLite database file
63
67
  embedding_dim: Dimension of embedding vectors
68
+ auto_migrate: If True, automatically apply pending migrations on startup
64
69
  """
65
70
  self.db_path = Path(db_path)
66
71
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
67
72
  self.embedding_dim = embedding_dim
68
73
 
74
+ # Migration support (lazy-loaded)
75
+ self._migration_runner = None
76
+ self._version_store = None
77
+
69
78
  # Initialize database
70
79
  self._init_database()
71
80
 
72
81
  # Initialize FAISS indices (one per memory type)
73
82
  self._indices: Dict[str, Any] = {}
74
83
  self._id_maps: Dict[str, List[str]] = {} # memory_type -> [memory_ids]
84
+ self._index_dirty: Dict[str, bool] = {} # Track which indexes need rebuilding
75
85
  self._load_faiss_indices()
76
86
 
87
+ # Auto-migrate if enabled
88
+ if auto_migrate:
89
+ self._ensure_migrated()
90
+
77
91
  @classmethod
78
92
  def from_config(cls, config: Dict[str, Any]) -> "SQLiteStorage":
79
93
  """Create instance from configuration."""
@@ -149,6 +163,10 @@ class SQLiteStorage(StorageBackend):
149
163
  "CREATE INDEX IF NOT EXISTS idx_outcomes_task_type "
150
164
  "ON outcomes(project_id, agent, task_type)"
151
165
  )
166
+ cursor.execute(
167
+ "CREATE INDEX IF NOT EXISTS idx_outcomes_timestamp "
168
+ "ON outcomes(project_id, timestamp)"
169
+ )
152
170
 
153
171
  # User preferences table
154
172
  cursor.execute("""
@@ -222,9 +240,14 @@ class SQLiteStorage(StorageBackend):
222
240
  "ON embeddings(memory_type)"
223
241
  )
224
242
 
225
- def _load_faiss_indices(self):
226
- """Load or create FAISS indices for each memory type."""
227
- memory_types = ["heuristics", "outcomes", "domain_knowledge", "anti_patterns"]
243
+ def _load_faiss_indices(self, memory_types: Optional[List[str]] = None):
244
+ """Load or create FAISS indices for specified memory types.
245
+
246
+ Args:
247
+ memory_types: List of memory types to load. If None, loads all types.
248
+ """
249
+ if memory_types is None:
250
+ memory_types = list(MemoryType.VECTOR_ENABLED)
228
251
 
229
252
  for memory_type in memory_types:
230
253
  if FAISS_AVAILABLE:
@@ -235,6 +258,7 @@ class SQLiteStorage(StorageBackend):
235
258
  self._indices[memory_type] = []
236
259
 
237
260
  self._id_maps[memory_type] = []
261
+ self._index_dirty[memory_type] = False # Mark as fresh after rebuild
238
262
 
239
263
  # Load existing embeddings
240
264
  with self._get_connection() as conn:
@@ -257,6 +281,19 @@ class SQLiteStorage(StorageBackend):
257
281
  else:
258
282
  self._indices[memory_type].append(embedding)
259
283
 
284
+ def _ensure_index_fresh(self, memory_type: str) -> None:
285
+ """Rebuild index for a memory type if it has been marked dirty.
286
+
287
+ This implements lazy rebuilding - indexes are only rebuilt when
288
+ actually needed for search, not immediately on every delete.
289
+
290
+ Args:
291
+ memory_type: The type of memory index to check/rebuild.
292
+ """
293
+ if self._index_dirty.get(memory_type, False):
294
+ logger.debug(f"Rebuilding dirty index for {memory_type}")
295
+ self._load_faiss_indices([memory_type])
296
+
260
297
  def _add_to_index(
261
298
  self,
262
299
  memory_type: str,
@@ -296,6 +333,9 @@ class SQLiteStorage(StorageBackend):
296
333
  top_k: int,
297
334
  ) -> List[Tuple[str, float]]:
298
335
  """Search FAISS index for similar embeddings."""
336
+ # Ensure index is up-to-date before searching (lazy rebuild)
337
+ self._ensure_index_fresh(memory_type)
338
+
299
339
  if not self._id_maps[memory_type]:
300
340
  return []
301
341
 
@@ -304,10 +344,12 @@ class SQLiteStorage(StorageBackend):
304
344
  if FAISS_AVAILABLE:
305
345
  # Normalize for cosine similarity (IndexFlatIP)
306
346
  faiss.normalize_L2(query)
307
- scores, indices = self._indices[memory_type].search(query, min(top_k, len(self._id_maps[memory_type])))
347
+ scores, indices = self._indices[memory_type].search(
348
+ query, min(top_k, len(self._id_maps[memory_type]))
349
+ )
308
350
 
309
351
  results = []
310
- for score, idx in zip(scores[0], indices[0]):
352
+ for score, idx in zip(scores[0], indices[0], strict=False):
311
353
  if idx >= 0 and idx < len(self._id_maps[memory_type]):
312
354
  results.append((self._id_maps[memory_type][idx], float(score)))
313
355
  return results
@@ -354,14 +396,18 @@ class SQLiteStorage(StorageBackend):
354
396
  heuristic.confidence,
355
397
  heuristic.occurrence_count,
356
398
  heuristic.success_count,
357
- heuristic.last_validated.isoformat() if heuristic.last_validated else None,
399
+ (
400
+ heuristic.last_validated.isoformat()
401
+ if heuristic.last_validated
402
+ else None
403
+ ),
358
404
  heuristic.created_at.isoformat() if heuristic.created_at else None,
359
405
  json.dumps(heuristic.metadata) if heuristic.metadata else None,
360
406
  ),
361
407
  )
362
408
 
363
409
  # Add embedding to index
364
- self._add_to_index("heuristics", heuristic.id, heuristic.embedding)
410
+ self._add_to_index(MemoryType.HEURISTICS, heuristic.id, heuristic.embedding)
365
411
  logger.debug(f"Saved heuristic: {heuristic.id}")
366
412
  return heuristic.id
367
413
 
@@ -393,7 +439,7 @@ class SQLiteStorage(StorageBackend):
393
439
  )
394
440
 
395
441
  # Add embedding to index
396
- self._add_to_index("outcomes", outcome.id, outcome.embedding)
442
+ self._add_to_index(MemoryType.OUTCOMES, outcome.id, outcome.embedding)
397
443
  logger.debug(f"Saved outcome: {outcome.id}")
398
444
  return outcome.id
399
445
 
@@ -439,13 +485,19 @@ class SQLiteStorage(StorageBackend):
439
485
  knowledge.fact,
440
486
  knowledge.source,
441
487
  knowledge.confidence,
442
- knowledge.last_verified.isoformat() if knowledge.last_verified else None,
488
+ (
489
+ knowledge.last_verified.isoformat()
490
+ if knowledge.last_verified
491
+ else None
492
+ ),
443
493
  json.dumps(knowledge.metadata) if knowledge.metadata else None,
444
494
  ),
445
495
  )
446
496
 
447
497
  # Add embedding to index
448
- self._add_to_index("domain_knowledge", knowledge.id, knowledge.embedding)
498
+ self._add_to_index(
499
+ MemoryType.DOMAIN_KNOWLEDGE, knowledge.id, knowledge.embedding
500
+ )
449
501
  logger.debug(f"Saved domain knowledge: {knowledge.id}")
450
502
  return knowledge.id
451
503
 
@@ -468,17 +520,150 @@ class SQLiteStorage(StorageBackend):
468
520
  anti_pattern.why_bad,
469
521
  anti_pattern.better_alternative,
470
522
  anti_pattern.occurrence_count,
471
- anti_pattern.last_seen.isoformat() if anti_pattern.last_seen else None,
472
- anti_pattern.created_at.isoformat() if anti_pattern.created_at else None,
473
- json.dumps(anti_pattern.metadata) if anti_pattern.metadata else None,
523
+ (
524
+ anti_pattern.last_seen.isoformat()
525
+ if anti_pattern.last_seen
526
+ else None
527
+ ),
528
+ (
529
+ anti_pattern.created_at.isoformat()
530
+ if anti_pattern.created_at
531
+ else None
532
+ ),
533
+ (
534
+ json.dumps(anti_pattern.metadata)
535
+ if anti_pattern.metadata
536
+ else None
537
+ ),
474
538
  ),
475
539
  )
476
540
 
477
541
  # Add embedding to index
478
- self._add_to_index("anti_patterns", anti_pattern.id, anti_pattern.embedding)
542
+ self._add_to_index(
543
+ MemoryType.ANTI_PATTERNS, anti_pattern.id, anti_pattern.embedding
544
+ )
479
545
  logger.debug(f"Saved anti-pattern: {anti_pattern.id}")
480
546
  return anti_pattern.id
481
547
 
548
+ # ==================== BATCH WRITE OPERATIONS ====================
549
+
550
+ def save_heuristics(self, heuristics: List[Heuristic]) -> List[str]:
551
+ """Save multiple heuristics in a batch using executemany."""
552
+ if not heuristics:
553
+ return []
554
+
555
+ with self._get_connection() as conn:
556
+ cursor = conn.cursor()
557
+ cursor.executemany(
558
+ """
559
+ INSERT OR REPLACE INTO heuristics
560
+ (id, agent, project_id, condition, strategy, confidence,
561
+ occurrence_count, success_count, last_validated, created_at, metadata)
562
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
563
+ """,
564
+ [
565
+ (
566
+ h.id,
567
+ h.agent,
568
+ h.project_id,
569
+ h.condition,
570
+ h.strategy,
571
+ h.confidence,
572
+ h.occurrence_count,
573
+ h.success_count,
574
+ h.last_validated.isoformat() if h.last_validated else None,
575
+ h.created_at.isoformat() if h.created_at else None,
576
+ json.dumps(h.metadata) if h.metadata else None,
577
+ )
578
+ for h in heuristics
579
+ ],
580
+ )
581
+
582
+ # Add embeddings to index
583
+ for h in heuristics:
584
+ self._add_to_index(MemoryType.HEURISTICS, h.id, h.embedding)
585
+
586
+ logger.debug(f"Batch saved {len(heuristics)} heuristics")
587
+ return [h.id for h in heuristics]
588
+
589
+ def save_outcomes(self, outcomes: List[Outcome]) -> List[str]:
590
+ """Save multiple outcomes in a batch using executemany."""
591
+ if not outcomes:
592
+ return []
593
+
594
+ with self._get_connection() as conn:
595
+ cursor = conn.cursor()
596
+ cursor.executemany(
597
+ """
598
+ INSERT OR REPLACE INTO outcomes
599
+ (id, agent, project_id, task_type, task_description, success,
600
+ strategy_used, duration_ms, error_message, user_feedback, timestamp, metadata)
601
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
602
+ """,
603
+ [
604
+ (
605
+ o.id,
606
+ o.agent,
607
+ o.project_id,
608
+ o.task_type,
609
+ o.task_description,
610
+ 1 if o.success else 0,
611
+ o.strategy_used,
612
+ o.duration_ms,
613
+ o.error_message,
614
+ o.user_feedback,
615
+ o.timestamp.isoformat() if o.timestamp else None,
616
+ json.dumps(o.metadata) if o.metadata else None,
617
+ )
618
+ for o in outcomes
619
+ ],
620
+ )
621
+
622
+ # Add embeddings to index
623
+ for o in outcomes:
624
+ self._add_to_index(MemoryType.OUTCOMES, o.id, o.embedding)
625
+
626
+ logger.debug(f"Batch saved {len(outcomes)} outcomes")
627
+ return [o.id for o in outcomes]
628
+
629
+ def save_domain_knowledge_batch(
630
+ self, knowledge_items: List[DomainKnowledge]
631
+ ) -> List[str]:
632
+ """Save multiple domain knowledge items in a batch using executemany."""
633
+ if not knowledge_items:
634
+ return []
635
+
636
+ with self._get_connection() as conn:
637
+ cursor = conn.cursor()
638
+ cursor.executemany(
639
+ """
640
+ INSERT OR REPLACE INTO domain_knowledge
641
+ (id, agent, project_id, domain, fact, source, confidence, last_verified, metadata)
642
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
643
+ """,
644
+ [
645
+ (
646
+ k.id,
647
+ k.agent,
648
+ k.project_id,
649
+ k.domain,
650
+ k.fact,
651
+ k.source,
652
+ k.confidence,
653
+ k.last_verified.isoformat() if k.last_verified else None,
654
+ json.dumps(k.metadata) if k.metadata else None,
655
+ )
656
+ for k in knowledge_items
657
+ ],
658
+ )
659
+
660
+ # Add embeddings to index
661
+ for k in knowledge_items:
662
+ self._add_to_index(MemoryType.DOMAIN_KNOWLEDGE, k.id, k.embedding)
663
+
664
+ logger.debug(f"Batch saved {len(knowledge_items)} domain knowledge items")
665
+ return [k.id for k in knowledge_items]
666
+
482
667
  # ==================== READ OPERATIONS ====================
483
668
 
484
669
  def get_heuristics(
@@ -493,7 +678,9 @@ class SQLiteStorage(StorageBackend):
493
678
  # If embedding provided, use vector search to get candidate IDs
494
679
  candidate_ids = None
495
680
  if embedding:
496
- search_results = self._search_index("heuristics", embedding, top_k * 2)
681
+ search_results = self._search_index(
682
+ MemoryType.HEURISTICS, embedding, top_k * 2
683
+ )
497
684
  candidate_ids = [id for id, _ in search_results]
498
685
 
499
686
  with self._get_connection() as conn:
@@ -531,7 +718,9 @@ class SQLiteStorage(StorageBackend):
531
718
  """Get outcomes with optional vector search."""
532
719
  candidate_ids = None
533
720
  if embedding:
534
- search_results = self._search_index("outcomes", embedding, top_k * 2)
721
+ search_results = self._search_index(
722
+ MemoryType.OUTCOMES, embedding, top_k * 2
723
+ )
535
724
  candidate_ids = [id for id, _ in search_results]
536
725
 
537
726
  with self._get_connection() as conn:
@@ -596,7 +785,9 @@ class SQLiteStorage(StorageBackend):
596
785
  """Get domain knowledge with optional vector search."""
597
786
  candidate_ids = None
598
787
  if embedding:
599
- search_results = self._search_index("domain_knowledge", embedding, top_k * 2)
788
+ search_results = self._search_index(
789
+ MemoryType.DOMAIN_KNOWLEDGE, embedding, top_k * 2
790
+ )
600
791
  candidate_ids = [id for id, _ in search_results]
601
792
 
602
793
  with self._get_connection() as conn:
@@ -636,7 +827,9 @@ class SQLiteStorage(StorageBackend):
636
827
  """Get anti-patterns with optional vector search."""
637
828
  candidate_ids = None
638
829
  if embedding:
639
- search_results = self._search_index("anti_patterns", embedding, top_k * 2)
830
+ search_results = self._search_index(
831
+ MemoryType.ANTI_PATTERNS, embedding, top_k * 2
832
+ )
640
833
  candidate_ids = [id for id, _ in search_results]
641
834
 
642
835
  with self._get_connection() as conn:
@@ -662,6 +855,175 @@ class SQLiteStorage(StorageBackend):
662
855
 
663
856
  return [self._row_to_anti_pattern(row) for row in rows]
664
857
 
858
+ # ==================== MULTI-AGENT MEMORY SHARING ====================
859
+
860
+ def get_heuristics_for_agents(
861
+ self,
862
+ project_id: str,
863
+ agents: List[str],
864
+ embedding: Optional[List[float]] = None,
865
+ top_k: int = 5,
866
+ min_confidence: float = 0.0,
867
+ ) -> List[Heuristic]:
868
+ """Get heuristics from multiple agents using optimized IN query."""
869
+ if not agents:
870
+ return []
871
+
872
+ candidate_ids = None
873
+ if embedding:
874
+ search_results = self._search_index(
875
+ MemoryType.HEURISTICS, embedding, top_k * 2 * len(agents)
876
+ )
877
+ candidate_ids = [id for id, _ in search_results]
878
+
879
+ with self._get_connection() as conn:
880
+ cursor = conn.cursor()
881
+
882
+ placeholders = ",".join("?" * len(agents))
883
+ query = f"SELECT * FROM heuristics WHERE project_id = ? AND confidence >= ? AND agent IN ({placeholders})"
884
+ params: List[Any] = [project_id, min_confidence] + list(agents)
885
+
886
+ if candidate_ids is not None:
887
+ id_placeholders = ",".join("?" * len(candidate_ids))
888
+ query += f" AND id IN ({id_placeholders})"
889
+ params.extend(candidate_ids)
890
+
891
+ query += " ORDER BY confidence DESC LIMIT ?"
892
+ params.append(top_k * len(agents))
893
+
894
+ cursor.execute(query, params)
895
+ rows = cursor.fetchall()
896
+
897
+ return [self._row_to_heuristic(row) for row in rows]
898
+
899
+ def get_outcomes_for_agents(
900
+ self,
901
+ project_id: str,
902
+ agents: List[str],
903
+ task_type: Optional[str] = None,
904
+ embedding: Optional[List[float]] = None,
905
+ top_k: int = 5,
906
+ success_only: bool = False,
907
+ ) -> List[Outcome]:
908
+ """Get outcomes from multiple agents using optimized IN query."""
909
+ if not agents:
910
+ return []
911
+
912
+ candidate_ids = None
913
+ if embedding:
914
+ search_results = self._search_index(
915
+ MemoryType.OUTCOMES, embedding, top_k * 2 * len(agents)
916
+ )
917
+ candidate_ids = [id for id, _ in search_results]
918
+
919
+ with self._get_connection() as conn:
920
+ cursor = conn.cursor()
921
+
922
+ placeholders = ",".join("?" * len(agents))
923
+ query = f"SELECT * FROM outcomes WHERE project_id = ? AND agent IN ({placeholders})"
924
+ params: List[Any] = [project_id] + list(agents)
925
+
926
+ if task_type:
927
+ query += " AND task_type = ?"
928
+ params.append(task_type)
929
+
930
+ if success_only:
931
+ query += " AND success = 1"
932
+
933
+ if candidate_ids is not None:
934
+ id_placeholders = ",".join("?" * len(candidate_ids))
935
+ query += f" AND id IN ({id_placeholders})"
936
+ params.extend(candidate_ids)
937
+
938
+ query += " ORDER BY timestamp DESC LIMIT ?"
939
+ params.append(top_k * len(agents))
940
+
941
+ cursor.execute(query, params)
942
+ rows = cursor.fetchall()
943
+
944
+ return [self._row_to_outcome(row) for row in rows]
945
+
946
+ def get_domain_knowledge_for_agents(
947
+ self,
948
+ project_id: str,
949
+ agents: List[str],
950
+ domain: Optional[str] = None,
951
+ embedding: Optional[List[float]] = None,
952
+ top_k: int = 5,
953
+ ) -> List[DomainKnowledge]:
954
+ """Get domain knowledge from multiple agents using optimized IN query."""
955
+ if not agents:
956
+ return []
957
+
958
+ candidate_ids = None
959
+ if embedding:
960
+ search_results = self._search_index(
961
+ MemoryType.DOMAIN_KNOWLEDGE, embedding, top_k * 2 * len(agents)
962
+ )
963
+ candidate_ids = [id for id, _ in search_results]
964
+
965
+ with self._get_connection() as conn:
966
+ cursor = conn.cursor()
967
+
968
+ placeholders = ",".join("?" * len(agents))
969
+ query = f"SELECT * FROM domain_knowledge WHERE project_id = ? AND agent IN ({placeholders})"
970
+ params: List[Any] = [project_id] + list(agents)
971
+
972
+ if domain:
973
+ query += " AND domain = ?"
974
+ params.append(domain)
975
+
976
+ if candidate_ids is not None:
977
+ id_placeholders = ",".join("?" * len(candidate_ids))
978
+ query += f" AND id IN ({id_placeholders})"
979
+ params.extend(candidate_ids)
980
+
981
+ query += " ORDER BY confidence DESC LIMIT ?"
982
+ params.append(top_k * len(agents))
983
+
984
+ cursor.execute(query, params)
985
+ rows = cursor.fetchall()
986
+
987
+ return [self._row_to_domain_knowledge(row) for row in rows]
988
+
989
+ def get_anti_patterns_for_agents(
990
+ self,
991
+ project_id: str,
992
+ agents: List[str],
993
+ embedding: Optional[List[float]] = None,
994
+ top_k: int = 5,
995
+ ) -> List[AntiPattern]:
996
+ """Get anti-patterns from multiple agents using optimized IN query."""
997
+ if not agents:
998
+ return []
999
+
1000
+ candidate_ids = None
1001
+ if embedding:
1002
+ search_results = self._search_index(
1003
+ MemoryType.ANTI_PATTERNS, embedding, top_k * 2 * len(agents)
1004
+ )
1005
+ candidate_ids = [id for id, _ in search_results]
1006
+
1007
+ with self._get_connection() as conn:
1008
+ cursor = conn.cursor()
1009
+
1010
+ placeholders = ",".join("?" * len(agents))
1011
+ query = f"SELECT * FROM anti_patterns WHERE project_id = ? AND agent IN ({placeholders})"
1012
+ params: List[Any] = [project_id] + list(agents)
1013
+
1014
+ if candidate_ids is not None:
1015
+ id_placeholders = ",".join("?" * len(candidate_ids))
1016
+ query += f" AND id IN ({id_placeholders})"
1017
+ params.extend(candidate_ids)
1018
+
1019
+ query += " ORDER BY occurrence_count DESC LIMIT ?"
1020
+ params.append(top_k * len(agents))
1021
+
1022
+ cursor.execute(query, params)
1023
+ rows = cursor.fetchall()
1024
+
1025
+ return [self._row_to_anti_pattern(row) for row in rows]
1026
+
665
1027
  # ==================== UPDATE OPERATIONS ====================
666
1028
 
667
1029
  def update_heuristic(
@@ -792,19 +1154,22 @@ class SQLiteStorage(StorageBackend):
792
1154
  with self._get_connection() as conn:
793
1155
  cursor = conn.cursor()
794
1156
 
795
- tables = ["heuristics", "outcomes", "domain_knowledge", "anti_patterns"]
796
- for table in tables:
797
- query = f"SELECT COUNT(*) FROM {table} WHERE project_id = ?"
798
- params: List[Any] = [project_id]
799
- if agent:
800
- query += " AND agent = ?"
801
- params.append(agent)
802
- cursor.execute(query, params)
803
- stats[f"{table}_count"] = cursor.fetchone()[0]
804
-
805
- # Preferences don't have project_id
806
- cursor.execute("SELECT COUNT(*) FROM preferences")
807
- stats["preferences_count"] = cursor.fetchone()[0]
1157
+ # Use canonical memory types for stats
1158
+ for memory_type in MemoryType.ALL:
1159
+ if memory_type == MemoryType.PREFERENCES:
1160
+ # Preferences don't have project_id
1161
+ cursor.execute(
1162
+ f"SELECT COUNT(*) FROM {SQLITE_TABLE_NAMES[memory_type]}"
1163
+ )
1164
+ stats[f"{memory_type}_count"] = cursor.fetchone()[0]
1165
+ else:
1166
+ query = f"SELECT COUNT(*) FROM {SQLITE_TABLE_NAMES[memory_type]} WHERE project_id = ?"
1167
+ params: List[Any] = [project_id]
1168
+ if agent:
1169
+ query += " AND agent = ?"
1170
+ params.append(agent)
1171
+ cursor.execute(query, params)
1172
+ stats[f"{memory_type}_count"] = cursor.fetchone()[0]
808
1173
 
809
1174
  # Embedding counts
810
1175
  cursor.execute("SELECT COUNT(*) FROM embeddings")
@@ -944,17 +1309,16 @@ class SQLiteStorage(StorageBackend):
944
1309
  with self._get_connection() as conn:
945
1310
  # Also remove from embedding index
946
1311
  conn.execute(
947
- "DELETE FROM embeddings WHERE memory_type = 'heuristic' AND memory_id = ?",
948
- (heuristic_id,),
1312
+ "DELETE FROM embeddings WHERE memory_type = ? AND memory_id = ?",
1313
+ (MemoryType.HEURISTICS, heuristic_id),
949
1314
  )
950
1315
  cursor = conn.execute(
951
- "DELETE FROM heuristics WHERE id = ?",
1316
+ f"DELETE FROM {SQLITE_TABLE_NAMES[MemoryType.HEURISTICS]} WHERE id = ?",
952
1317
  (heuristic_id,),
953
1318
  )
954
1319
  if cursor.rowcount > 0:
955
- # Rebuild index if we had one
956
- if "heuristic" in self._indices:
957
- self._load_faiss_indices()
1320
+ # Mark index as dirty for lazy rebuild on next search
1321
+ self._index_dirty[MemoryType.HEURISTICS] = True
958
1322
  return True
959
1323
  return False
960
1324
 
@@ -963,16 +1327,16 @@ class SQLiteStorage(StorageBackend):
963
1327
  with self._get_connection() as conn:
964
1328
  # Also remove from embedding index
965
1329
  conn.execute(
966
- "DELETE FROM embeddings WHERE memory_type = 'outcome' AND memory_id = ?",
967
- (outcome_id,),
1330
+ "DELETE FROM embeddings WHERE memory_type = ? AND memory_id = ?",
1331
+ (MemoryType.OUTCOMES, outcome_id),
968
1332
  )
969
1333
  cursor = conn.execute(
970
- "DELETE FROM outcomes WHERE id = ?",
1334
+ f"DELETE FROM {SQLITE_TABLE_NAMES[MemoryType.OUTCOMES]} WHERE id = ?",
971
1335
  (outcome_id,),
972
1336
  )
973
1337
  if cursor.rowcount > 0:
974
- if "outcome" in self._indices:
975
- self._load_faiss_indices()
1338
+ # Mark index as dirty for lazy rebuild on next search
1339
+ self._index_dirty[MemoryType.OUTCOMES] = True
976
1340
  return True
977
1341
  return False
978
1342
 
@@ -981,16 +1345,16 @@ class SQLiteStorage(StorageBackend):
981
1345
  with self._get_connection() as conn:
982
1346
  # Also remove from embedding index
983
1347
  conn.execute(
984
- "DELETE FROM embeddings WHERE memory_type = 'domain_knowledge' AND memory_id = ?",
985
- (knowledge_id,),
1348
+ "DELETE FROM embeddings WHERE memory_type = ? AND memory_id = ?",
1349
+ (MemoryType.DOMAIN_KNOWLEDGE, knowledge_id),
986
1350
  )
987
1351
  cursor = conn.execute(
988
- "DELETE FROM domain_knowledge WHERE id = ?",
1352
+ f"DELETE FROM {SQLITE_TABLE_NAMES[MemoryType.DOMAIN_KNOWLEDGE]} WHERE id = ?",
989
1353
  (knowledge_id,),
990
1354
  )
991
1355
  if cursor.rowcount > 0:
992
- if "domain_knowledge" in self._indices:
993
- self._load_faiss_indices()
1356
+ # Mark index as dirty for lazy rebuild on next search
1357
+ self._index_dirty[MemoryType.DOMAIN_KNOWLEDGE] = True
994
1358
  return True
995
1359
  return False
996
1360
 
@@ -999,15 +1363,95 @@ class SQLiteStorage(StorageBackend):
999
1363
  with self._get_connection() as conn:
1000
1364
  # Also remove from embedding index
1001
1365
  conn.execute(
1002
- "DELETE FROM embeddings WHERE memory_type = 'anti_pattern' AND memory_id = ?",
1003
- (anti_pattern_id,),
1366
+ "DELETE FROM embeddings WHERE memory_type = ? AND memory_id = ?",
1367
+ (MemoryType.ANTI_PATTERNS, anti_pattern_id),
1004
1368
  )
1005
1369
  cursor = conn.execute(
1006
- "DELETE FROM anti_patterns WHERE id = ?",
1370
+ f"DELETE FROM {SQLITE_TABLE_NAMES[MemoryType.ANTI_PATTERNS]} WHERE id = ?",
1007
1371
  (anti_pattern_id,),
1008
1372
  )
1009
1373
  if cursor.rowcount > 0:
1010
- if "anti_pattern" in self._indices:
1011
- self._load_faiss_indices()
1374
+ # Mark index as dirty for lazy rebuild on next search
1375
+ self._index_dirty[MemoryType.ANTI_PATTERNS] = True
1012
1376
  return True
1013
1377
  return False
1378
+
1379
+ # ==================== MIGRATION SUPPORT ====================
1380
+
1381
+ def _get_version_store(self):
1382
+ """Get or create the version store."""
1383
+ if self._version_store is None:
1384
+ from alma.storage.migrations.version_stores import SQLiteVersionStore
1385
+
1386
+ self._version_store = SQLiteVersionStore(self.db_path)
1387
+ return self._version_store
1388
+
1389
+ def _get_migration_runner(self):
1390
+ """Get or create the migration runner."""
1391
+ if self._migration_runner is None:
1392
+ from alma.storage.migrations.runner import MigrationRunner
1393
+ from alma.storage.migrations.versions import v1_0_0 # noqa: F401
1394
+
1395
+ self._migration_runner = MigrationRunner(
1396
+ version_store=self._get_version_store(),
1397
+ backend="sqlite",
1398
+ )
1399
+ return self._migration_runner
1400
+
1401
+ def _ensure_migrated(self) -> None:
1402
+ """Ensure database is migrated to latest version."""
1403
+ runner = self._get_migration_runner()
1404
+ if runner.needs_migration():
1405
+ with self._get_connection() as conn:
1406
+ applied = runner.migrate(conn)
1407
+ if applied:
1408
+ logger.info(f"Applied {len(applied)} migrations: {applied}")
1409
+
1410
+ def get_schema_version(self) -> Optional[str]:
1411
+ """Get the current schema version."""
1412
+ return self._get_version_store().get_current_version()
1413
+
1414
+ def get_migration_status(self) -> Dict[str, Any]:
1415
+ """Get migration status information."""
1416
+ runner = self._get_migration_runner()
1417
+ status = runner.get_status()
1418
+ status["migration_supported"] = True
1419
+ return status
1420
+
1421
+ def migrate(
1422
+ self,
1423
+ target_version: Optional[str] = None,
1424
+ dry_run: bool = False,
1425
+ ) -> List[str]:
1426
+ """
1427
+ Apply pending schema migrations.
1428
+
1429
+ Args:
1430
+ target_version: Optional target version (applies all if not specified)
1431
+ dry_run: If True, show what would be done without making changes
1432
+
1433
+ Returns:
1434
+ List of applied migration versions
1435
+ """
1436
+ runner = self._get_migration_runner()
1437
+ with self._get_connection() as conn:
1438
+ return runner.migrate(conn, target_version=target_version, dry_run=dry_run)
1439
+
1440
+ def rollback(
1441
+ self,
1442
+ target_version: str,
1443
+ dry_run: bool = False,
1444
+ ) -> List[str]:
1445
+ """
1446
+ Roll back schema to a previous version.
1447
+
1448
+ Args:
1449
+ target_version: Version to roll back to
1450
+ dry_run: If True, show what would be done without making changes
1451
+
1452
+ Returns:
1453
+ List of rolled back migration versions
1454
+ """
1455
+ runner = self._get_migration_runner()
1456
+ with self._get_connection() as conn:
1457
+ return runner.rollback(conn, target_version=target_version, dry_run=dry_run)