superlocalmemory 2.7.6 → 2.8.0
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.
- package/CHANGELOG.md +120 -155
- package/README.md +115 -89
- package/api_server.py +2 -12
- package/docs/PATTERN-LEARNING.md +64 -199
- package/docs/example_graph_usage.py +4 -6
- package/install.sh +59 -0
- package/mcp_server.py +83 -7
- package/package.json +1 -8
- package/scripts/generate-thumbnails.py +3 -5
- package/skills/slm-build-graph/SKILL.md +1 -1
- package/skills/slm-list-recent/SKILL.md +1 -1
- package/skills/slm-recall/SKILL.md +1 -1
- package/skills/slm-remember/SKILL.md +1 -1
- package/skills/slm-show-patterns/SKILL.md +1 -1
- package/skills/slm-status/SKILL.md +1 -1
- package/skills/slm-switch-profile/SKILL.md +1 -1
- package/src/agent_registry.py +7 -18
- package/src/auth_middleware.py +3 -5
- package/src/auto_backup.py +3 -7
- package/src/behavioral/__init__.py +49 -0
- package/src/behavioral/behavioral_listener.py +203 -0
- package/src/behavioral/behavioral_patterns.py +275 -0
- package/src/behavioral/cross_project_transfer.py +206 -0
- package/src/behavioral/outcome_inference.py +194 -0
- package/src/behavioral/outcome_tracker.py +193 -0
- package/src/behavioral/tests/__init__.py +4 -0
- package/src/behavioral/tests/test_behavioral_integration.py +108 -0
- package/src/behavioral/tests/test_behavioral_patterns.py +150 -0
- package/src/behavioral/tests/test_cross_project_transfer.py +142 -0
- package/src/behavioral/tests/test_mcp_behavioral.py +139 -0
- package/src/behavioral/tests/test_mcp_report_outcome.py +117 -0
- package/src/behavioral/tests/test_outcome_inference.py +107 -0
- package/src/behavioral/tests/test_outcome_tracker.py +96 -0
- package/src/cache_manager.py +4 -6
- package/src/compliance/__init__.py +48 -0
- package/src/compliance/abac_engine.py +149 -0
- package/src/compliance/abac_middleware.py +116 -0
- package/src/compliance/audit_db.py +215 -0
- package/src/compliance/audit_logger.py +148 -0
- package/src/compliance/retention_manager.py +289 -0
- package/src/compliance/retention_scheduler.py +186 -0
- package/src/compliance/tests/__init__.py +4 -0
- package/src/compliance/tests/test_abac_enforcement.py +95 -0
- package/src/compliance/tests/test_abac_engine.py +124 -0
- package/src/compliance/tests/test_abac_mcp_integration.py +118 -0
- package/src/compliance/tests/test_audit_db.py +123 -0
- package/src/compliance/tests/test_audit_logger.py +98 -0
- package/src/compliance/tests/test_mcp_audit.py +128 -0
- package/src/compliance/tests/test_mcp_retention_policy.py +125 -0
- package/src/compliance/tests/test_retention_manager.py +131 -0
- package/src/compliance/tests/test_retention_scheduler.py +99 -0
- package/src/db_connection_manager.py +2 -12
- package/src/embedding_engine.py +61 -669
- package/src/embeddings/__init__.py +47 -0
- package/src/embeddings/cache.py +70 -0
- package/src/embeddings/cli.py +113 -0
- package/src/embeddings/constants.py +47 -0
- package/src/embeddings/database.py +91 -0
- package/src/embeddings/engine.py +247 -0
- package/src/embeddings/model_loader.py +145 -0
- package/src/event_bus.py +3 -13
- package/src/graph/__init__.py +36 -0
- package/src/graph/build_helpers.py +74 -0
- package/src/graph/cli.py +87 -0
- package/src/graph/cluster_builder.py +188 -0
- package/src/graph/cluster_summary.py +148 -0
- package/src/graph/constants.py +47 -0
- package/src/graph/edge_builder.py +162 -0
- package/src/graph/entity_extractor.py +95 -0
- package/src/graph/graph_core.py +226 -0
- package/src/graph/graph_search.py +231 -0
- package/src/graph/hierarchical.py +207 -0
- package/src/graph/schema.py +99 -0
- package/src/graph_engine.py +45 -1451
- package/src/hnsw_index.py +3 -7
- package/src/hybrid_search.py +36 -683
- package/src/learning/__init__.py +27 -12
- package/src/learning/adaptive_ranker.py +50 -12
- package/src/learning/cross_project_aggregator.py +2 -12
- package/src/learning/engagement_tracker.py +2 -12
- package/src/learning/feature_extractor.py +175 -43
- package/src/learning/feedback_collector.py +7 -12
- package/src/learning/learning_db.py +180 -12
- package/src/learning/project_context_manager.py +2 -12
- package/src/learning/source_quality_scorer.py +2 -12
- package/src/learning/synthetic_bootstrap.py +2 -12
- package/src/learning/tests/__init__.py +2 -0
- package/src/learning/tests/test_adaptive_ranker.py +2 -6
- package/src/learning/tests/test_adaptive_ranker_v28.py +60 -0
- package/src/learning/tests/test_aggregator.py +2 -6
- package/src/learning/tests/test_auto_retrain_v28.py +35 -0
- package/src/learning/tests/test_e2e_ranking_v28.py +82 -0
- package/src/learning/tests/test_feature_extractor_v28.py +93 -0
- package/src/learning/tests/test_feedback_collector.py +2 -6
- package/src/learning/tests/test_learning_db.py +2 -6
- package/src/learning/tests/test_learning_db_v28.py +110 -0
- package/src/learning/tests/test_learning_init_v28.py +48 -0
- package/src/learning/tests/test_outcome_signals.py +48 -0
- package/src/learning/tests/test_project_context.py +2 -6
- package/src/learning/tests/test_schema_migration.py +319 -0
- package/src/learning/tests/test_signal_inference.py +11 -13
- package/src/learning/tests/test_source_quality.py +2 -6
- package/src/learning/tests/test_synthetic_bootstrap.py +3 -7
- package/src/learning/tests/test_workflow_miner.py +2 -6
- package/src/learning/workflow_pattern_miner.py +2 -12
- package/src/lifecycle/__init__.py +54 -0
- package/src/lifecycle/bounded_growth.py +239 -0
- package/src/lifecycle/compaction_engine.py +226 -0
- package/src/lifecycle/lifecycle_engine.py +302 -0
- package/src/lifecycle/lifecycle_evaluator.py +225 -0
- package/src/lifecycle/lifecycle_scheduler.py +130 -0
- package/src/lifecycle/retention_policy.py +285 -0
- package/src/lifecycle/tests/__init__.py +4 -0
- package/src/lifecycle/tests/test_bounded_growth.py +193 -0
- package/src/lifecycle/tests/test_compaction.py +179 -0
- package/src/lifecycle/tests/test_lifecycle_engine.py +137 -0
- package/src/lifecycle/tests/test_lifecycle_evaluation.py +177 -0
- package/src/lifecycle/tests/test_lifecycle_scheduler.py +127 -0
- package/src/lifecycle/tests/test_lifecycle_search.py +109 -0
- package/src/lifecycle/tests/test_mcp_compact.py +149 -0
- package/src/lifecycle/tests/test_mcp_lifecycle_status.py +114 -0
- package/src/lifecycle/tests/test_retention_policy.py +162 -0
- package/src/mcp_tools_v28.py +280 -0
- package/src/memory-profiles.py +2 -12
- package/src/memory-reset.py +2 -12
- package/src/memory_compression.py +2 -12
- package/src/memory_store_v2.py +76 -20
- package/src/migrate_v1_to_v2.py +2 -12
- package/src/pattern_learner.py +29 -975
- package/src/patterns/__init__.py +24 -0
- package/src/patterns/analyzers.py +247 -0
- package/src/patterns/learner.py +267 -0
- package/src/patterns/scoring.py +167 -0
- package/src/patterns/store.py +223 -0
- package/src/patterns/terminology.py +138 -0
- package/src/provenance_tracker.py +4 -14
- package/src/query_optimizer.py +4 -6
- package/src/rate_limiter.py +2 -6
- package/src/search/__init__.py +20 -0
- package/src/search/cli.py +77 -0
- package/src/search/constants.py +26 -0
- package/src/search/engine.py +239 -0
- package/src/search/fusion.py +122 -0
- package/src/search/index_loader.py +112 -0
- package/src/search/methods.py +162 -0
- package/src/search_engine_v2.py +4 -6
- package/src/setup_validator.py +7 -13
- package/src/subscription_manager.py +2 -12
- package/src/tree/__init__.py +59 -0
- package/src/tree/builder.py +183 -0
- package/src/tree/nodes.py +196 -0
- package/src/tree/queries.py +252 -0
- package/src/tree/schema.py +76 -0
- package/src/tree_manager.py +10 -711
- package/src/trust/__init__.py +45 -0
- package/src/trust/constants.py +66 -0
- package/src/trust/queries.py +157 -0
- package/src/trust/schema.py +95 -0
- package/src/trust/scorer.py +299 -0
- package/src/trust/signals.py +95 -0
- package/src/trust_scorer.py +39 -697
- package/src/webhook_dispatcher.py +2 -12
- package/ui/app.js +1 -1
- package/ui/js/agents.js +1 -1
- package/ui_server.py +2 -14
- package/ATTRIBUTION.md +0 -140
- package/docs/ARCHITECTURE-V2.5.md +0 -190
- package/docs/GRAPH-ENGINE.md +0 -503
- package/docs/architecture-diagram.drawio +0 -405
- package/docs/plans/2026-02-13-benchmark-suite.md +0 -1349
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
3
|
+
"""embeddings package - Local Embedding Generation for SuperLocalMemory V2
|
|
4
|
+
|
|
5
|
+
Re-exports all public classes and constants so that
|
|
6
|
+
``from embeddings import EmbeddingEngine`` works.
|
|
7
|
+
"""
|
|
8
|
+
from embeddings.constants import (
|
|
9
|
+
MAX_BATCH_SIZE,
|
|
10
|
+
MAX_TEXT_LENGTH,
|
|
11
|
+
CACHE_MAX_SIZE,
|
|
12
|
+
SENTENCE_TRANSFORMERS_AVAILABLE,
|
|
13
|
+
SKLEARN_AVAILABLE,
|
|
14
|
+
TORCH_AVAILABLE,
|
|
15
|
+
CUDA_AVAILABLE,
|
|
16
|
+
MPS_AVAILABLE,
|
|
17
|
+
MEMORY_DIR,
|
|
18
|
+
EMBEDDING_CACHE_PATH,
|
|
19
|
+
MODEL_CACHE_PATH,
|
|
20
|
+
)
|
|
21
|
+
from embeddings.cache import LRUCache
|
|
22
|
+
from embeddings.model_loader import ModelLoaderMixin
|
|
23
|
+
from embeddings.engine import EmbeddingEngine
|
|
24
|
+
from embeddings.database import add_embeddings_to_database
|
|
25
|
+
from embeddings.cli import main
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# Constants
|
|
29
|
+
"MAX_BATCH_SIZE",
|
|
30
|
+
"MAX_TEXT_LENGTH",
|
|
31
|
+
"CACHE_MAX_SIZE",
|
|
32
|
+
"SENTENCE_TRANSFORMERS_AVAILABLE",
|
|
33
|
+
"SKLEARN_AVAILABLE",
|
|
34
|
+
"TORCH_AVAILABLE",
|
|
35
|
+
"CUDA_AVAILABLE",
|
|
36
|
+
"MPS_AVAILABLE",
|
|
37
|
+
"MEMORY_DIR",
|
|
38
|
+
"EMBEDDING_CACHE_PATH",
|
|
39
|
+
"MODEL_CACHE_PATH",
|
|
40
|
+
# Classes
|
|
41
|
+
"LRUCache",
|
|
42
|
+
"ModelLoaderMixin",
|
|
43
|
+
"EmbeddingEngine",
|
|
44
|
+
# Functions
|
|
45
|
+
"add_embeddings_to_database",
|
|
46
|
+
"main",
|
|
47
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""LRU cache for embedding vectors.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from embeddings.constants import CACHE_MAX_SIZE
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LRUCache:
|
|
20
|
+
"""Simple LRU cache for embeddings."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, max_size: int = CACHE_MAX_SIZE):
|
|
23
|
+
self.cache = OrderedDict()
|
|
24
|
+
self.max_size = max_size
|
|
25
|
+
|
|
26
|
+
def get(self, key: str) -> Optional[np.ndarray]:
|
|
27
|
+
"""Get item from cache, moving to end (most recent)."""
|
|
28
|
+
if key not in self.cache:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
# Move to end (most recently used)
|
|
32
|
+
self.cache.move_to_end(key)
|
|
33
|
+
return np.array(self.cache[key])
|
|
34
|
+
|
|
35
|
+
def set(self, key: str, value: np.ndarray):
|
|
36
|
+
"""Set item in cache, evicting oldest if full."""
|
|
37
|
+
if key in self.cache:
|
|
38
|
+
# Update existing
|
|
39
|
+
self.cache.move_to_end(key)
|
|
40
|
+
self.cache[key] = value.tolist()
|
|
41
|
+
else:
|
|
42
|
+
# Add new
|
|
43
|
+
if len(self.cache) >= self.max_size:
|
|
44
|
+
# Evict oldest
|
|
45
|
+
self.cache.popitem(last=False)
|
|
46
|
+
self.cache[key] = value.tolist()
|
|
47
|
+
|
|
48
|
+
def save(self, path: Path):
|
|
49
|
+
"""Save cache to disk."""
|
|
50
|
+
try:
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with open(path, 'w') as f:
|
|
53
|
+
json.dump(dict(self.cache), f)
|
|
54
|
+
logger.debug(f"Saved {len(self.cache)} cached embeddings")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to save embedding cache: {e}")
|
|
57
|
+
|
|
58
|
+
def load(self, path: Path):
|
|
59
|
+
"""Load cache from disk."""
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with open(path, 'r') as f:
|
|
65
|
+
data = json.load(f)
|
|
66
|
+
self.cache = OrderedDict(data)
|
|
67
|
+
logger.info(f"Loaded {len(self.cache)} cached embeddings")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to load embedding cache: {e}")
|
|
70
|
+
self.cache = OrderedDict()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""CLI interface for testing the embedding engine.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from embeddings.constants import MEMORY_DIR
|
|
12
|
+
from embeddings.engine import EmbeddingEngine
|
|
13
|
+
from embeddings.database import add_embeddings_to_database
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
"""Run the embedding engine CLI."""
|
|
18
|
+
# Configure logging
|
|
19
|
+
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
|
20
|
+
|
|
21
|
+
if len(sys.argv) < 2:
|
|
22
|
+
print("EmbeddingEngine CLI - Local Embedding Generation")
|
|
23
|
+
print("\nCommands:")
|
|
24
|
+
print(" python embedding_engine.py stats # Show engine statistics")
|
|
25
|
+
print(" python embedding_engine.py generate # Generate embeddings for database")
|
|
26
|
+
print(" python embedding_engine.py test # Run performance test")
|
|
27
|
+
print(" python embedding_engine.py clear-cache # Clear embedding cache")
|
|
28
|
+
sys.exit(0)
|
|
29
|
+
|
|
30
|
+
command = sys.argv[1]
|
|
31
|
+
|
|
32
|
+
if command == "stats":
|
|
33
|
+
engine = EmbeddingEngine()
|
|
34
|
+
stats = engine.get_stats()
|
|
35
|
+
print(json.dumps(stats, indent=2))
|
|
36
|
+
|
|
37
|
+
elif command == "generate":
|
|
38
|
+
db_path = MEMORY_DIR / "memory.db"
|
|
39
|
+
if not db_path.exists():
|
|
40
|
+
print(f"Database not found at {db_path}")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
print("Generating embeddings for all memories...")
|
|
44
|
+
engine = EmbeddingEngine()
|
|
45
|
+
add_embeddings_to_database(engine, db_path)
|
|
46
|
+
print("Generation complete!")
|
|
47
|
+
print(json.dumps(engine.get_stats(), indent=2))
|
|
48
|
+
|
|
49
|
+
elif command == "clear-cache":
|
|
50
|
+
engine = EmbeddingEngine()
|
|
51
|
+
engine.clear_cache()
|
|
52
|
+
engine.save_cache()
|
|
53
|
+
print("Cache cleared!")
|
|
54
|
+
|
|
55
|
+
elif command == "test":
|
|
56
|
+
print("Running embedding performance test...")
|
|
57
|
+
|
|
58
|
+
engine = EmbeddingEngine()
|
|
59
|
+
|
|
60
|
+
# Test single encoding
|
|
61
|
+
print("\nTest 1: Single text encoding")
|
|
62
|
+
text = "This is a test sentence for embedding generation."
|
|
63
|
+
start = time.time()
|
|
64
|
+
embedding = engine.encode(text)
|
|
65
|
+
elapsed = time.time() - start
|
|
66
|
+
print(f" Time: {elapsed*1000:.2f}ms")
|
|
67
|
+
print(f" Dimension: {len(embedding)}")
|
|
68
|
+
print(f" Sample values: {embedding[:5]}")
|
|
69
|
+
|
|
70
|
+
# Test batch encoding
|
|
71
|
+
print("\nTest 2: Batch encoding (100 texts)")
|
|
72
|
+
texts = [f"This is test sentence number {i} with some content." for i in range(100)]
|
|
73
|
+
start = time.time()
|
|
74
|
+
embeddings = engine.encode(texts, batch_size=32)
|
|
75
|
+
elapsed = time.time() - start
|
|
76
|
+
print(f" Time: {elapsed*1000:.2f}ms ({100/elapsed:.0f} texts/sec)")
|
|
77
|
+
print(f" Shape: {embeddings.shape}")
|
|
78
|
+
|
|
79
|
+
# Test cache
|
|
80
|
+
print("\nTest 3: Cache performance")
|
|
81
|
+
start = time.time()
|
|
82
|
+
embedding_cached = engine.encode(text)
|
|
83
|
+
elapsed = time.time() - start
|
|
84
|
+
print(f" Cache hit time: {elapsed*1000:.4f}ms")
|
|
85
|
+
print(f" Speedup: {(elapsed*1000):.0f}x faster")
|
|
86
|
+
|
|
87
|
+
# Test similarity
|
|
88
|
+
print("\nTest 4: Similarity computation")
|
|
89
|
+
text1 = "The weather is nice today."
|
|
90
|
+
text2 = "It's a beautiful day outside."
|
|
91
|
+
text3 = "Python is a programming language."
|
|
92
|
+
|
|
93
|
+
emb1 = engine.encode(text1)
|
|
94
|
+
emb2 = engine.encode(text2)
|
|
95
|
+
emb3 = engine.encode(text3)
|
|
96
|
+
|
|
97
|
+
sim_12 = engine.similarity(emb1, emb2)
|
|
98
|
+
sim_13 = engine.similarity(emb1, emb3)
|
|
99
|
+
|
|
100
|
+
print(f" Similarity (weather vs beautiful day): {sim_12:.3f}")
|
|
101
|
+
print(f" Similarity (weather vs programming): {sim_13:.3f}")
|
|
102
|
+
|
|
103
|
+
# Print stats
|
|
104
|
+
print("\nEngine statistics:")
|
|
105
|
+
print(json.dumps(engine.get_stats(), indent=2))
|
|
106
|
+
|
|
107
|
+
# Save cache
|
|
108
|
+
engine.save_cache()
|
|
109
|
+
print("\nCache saved!")
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
print(f"Unknown command: {command}")
|
|
113
|
+
print("Run without arguments to see available commands.")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""Shared constants and feature-detection for the embeddings package.
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# SECURITY: Embedding generation limits to prevent resource exhaustion
|
|
10
|
+
MAX_BATCH_SIZE = 128
|
|
11
|
+
MAX_TEXT_LENGTH = 10_000
|
|
12
|
+
CACHE_MAX_SIZE = 10_000
|
|
13
|
+
|
|
14
|
+
# Optional sentence-transformers dependency
|
|
15
|
+
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
|
16
|
+
try:
|
|
17
|
+
from sentence_transformers import SentenceTransformer # noqa: F401
|
|
18
|
+
SENTENCE_TRANSFORMERS_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
# Fallback: TF-IDF vectorization
|
|
23
|
+
SKLEARN_AVAILABLE = False
|
|
24
|
+
try:
|
|
25
|
+
from sklearn.feature_extraction.text import TfidfVectorizer # noqa: F401
|
|
26
|
+
SKLEARN_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
SKLEARN_AVAILABLE = False
|
|
29
|
+
|
|
30
|
+
# GPU detection
|
|
31
|
+
TORCH_AVAILABLE = False
|
|
32
|
+
CUDA_AVAILABLE = False
|
|
33
|
+
MPS_AVAILABLE = False # Apple Silicon
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import torch
|
|
37
|
+
TORCH_AVAILABLE = True
|
|
38
|
+
CUDA_AVAILABLE = torch.cuda.is_available()
|
|
39
|
+
MPS_AVAILABLE = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
MEMORY_DIR = Path.home() / ".claude-memory"
|
|
44
|
+
EMBEDDING_CACHE_PATH = MEMORY_DIR / "embedding_cache.json"
|
|
45
|
+
MODEL_CACHE_PATH = MEMORY_DIR / "models" # Local model storage
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""Database integration for batch embedding generation.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add_embeddings_to_database(
|
|
15
|
+
engine,
|
|
16
|
+
db_path: Path,
|
|
17
|
+
embedding_column: str = 'embedding',
|
|
18
|
+
batch_size: int = 32
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Generate embeddings for all memories in database.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
engine: An EmbeddingEngine instance
|
|
25
|
+
db_path: Path to SQLite database
|
|
26
|
+
embedding_column: Column name to store embeddings
|
|
27
|
+
batch_size: Batch size for processing
|
|
28
|
+
"""
|
|
29
|
+
conn = sqlite3.connect(db_path)
|
|
30
|
+
cursor = conn.cursor()
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
# Check if embedding column exists
|
|
34
|
+
cursor.execute("PRAGMA table_info(memories)")
|
|
35
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
36
|
+
|
|
37
|
+
if embedding_column not in columns:
|
|
38
|
+
# Add column
|
|
39
|
+
logger.info(f"Adding '{embedding_column}' column to database")
|
|
40
|
+
cursor.execute(f'ALTER TABLE memories ADD COLUMN {embedding_column} TEXT')
|
|
41
|
+
conn.commit()
|
|
42
|
+
|
|
43
|
+
# Get memories without embeddings
|
|
44
|
+
cursor.execute(f'''
|
|
45
|
+
SELECT id, content, summary
|
|
46
|
+
FROM memories
|
|
47
|
+
WHERE {embedding_column} IS NULL OR {embedding_column} = ''
|
|
48
|
+
''')
|
|
49
|
+
rows = cursor.fetchall()
|
|
50
|
+
|
|
51
|
+
if not rows:
|
|
52
|
+
logger.info("All memories already have embeddings")
|
|
53
|
+
conn.close()
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
logger.info(f"Generating embeddings for {len(rows)} memories...")
|
|
57
|
+
|
|
58
|
+
# Process in batches
|
|
59
|
+
for i in range(0, len(rows), batch_size):
|
|
60
|
+
batch = rows[i:i+batch_size]
|
|
61
|
+
memory_ids = [row[0] for row in batch]
|
|
62
|
+
|
|
63
|
+
# Combine content and summary
|
|
64
|
+
texts = []
|
|
65
|
+
for row in batch:
|
|
66
|
+
content = row[1] or ""
|
|
67
|
+
summary = row[2] or ""
|
|
68
|
+
text = f"{content} {summary}".strip()
|
|
69
|
+
texts.append(text)
|
|
70
|
+
|
|
71
|
+
# Generate embeddings
|
|
72
|
+
embeddings = engine.encode(texts, batch_size=batch_size)
|
|
73
|
+
|
|
74
|
+
# Store in database
|
|
75
|
+
for mem_id, embedding in zip(memory_ids, embeddings):
|
|
76
|
+
embedding_json = json.dumps(embedding.tolist())
|
|
77
|
+
cursor.execute(
|
|
78
|
+
f'UPDATE memories SET {embedding_column} = ? WHERE id = ?',
|
|
79
|
+
(embedding_json, mem_id)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
conn.commit()
|
|
83
|
+
logger.info(f"Processed {min(i+batch_size, len(rows))}/{len(rows)} memories")
|
|
84
|
+
|
|
85
|
+
# Save cache
|
|
86
|
+
engine.save_cache()
|
|
87
|
+
|
|
88
|
+
logger.info(f"Successfully generated embeddings for {len(rows)} memories")
|
|
89
|
+
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 SuperLocalMemory (superlocalmemory.com)
|
|
4
|
+
"""EmbeddingEngine - Core encoding logic for local embedding generation.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from embeddings.constants import (
|
|
14
|
+
MAX_BATCH_SIZE,
|
|
15
|
+
MAX_TEXT_LENGTH,
|
|
16
|
+
CACHE_MAX_SIZE,
|
|
17
|
+
SENTENCE_TRANSFORMERS_AVAILABLE,
|
|
18
|
+
SKLEARN_AVAILABLE,
|
|
19
|
+
TORCH_AVAILABLE,
|
|
20
|
+
CUDA_AVAILABLE,
|
|
21
|
+
MPS_AVAILABLE,
|
|
22
|
+
EMBEDDING_CACHE_PATH,
|
|
23
|
+
MODEL_CACHE_PATH,
|
|
24
|
+
)
|
|
25
|
+
from embeddings.cache import LRUCache
|
|
26
|
+
from embeddings.model_loader import ModelLoaderMixin
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EmbeddingEngine(ModelLoaderMixin):
|
|
32
|
+
"""
|
|
33
|
+
Local embedding generation using sentence-transformers.
|
|
34
|
+
|
|
35
|
+
Features:
|
|
36
|
+
- all-MiniLM-L6-v2 model (384 dimensions, 80MB, fast)
|
|
37
|
+
- Batch processing for efficiency (up to 128 texts)
|
|
38
|
+
- GPU acceleration (CUDA/MPS) with automatic detection
|
|
39
|
+
- LRU cache for repeated queries (10K entries)
|
|
40
|
+
- Graceful fallback to TF-IDF if dependencies unavailable
|
|
41
|
+
|
|
42
|
+
Performance:
|
|
43
|
+
- CPU: ~100 embeddings/sec
|
|
44
|
+
- GPU (CUDA): ~1000 embeddings/sec
|
|
45
|
+
- Apple Silicon (MPS): ~500 embeddings/sec
|
|
46
|
+
- Cache hit: ~0.001ms
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
model_name: str = "all-MiniLM-L6-v2",
|
|
52
|
+
device: Optional[str] = None,
|
|
53
|
+
cache_path: Optional[Path] = None,
|
|
54
|
+
model_cache_path: Optional[Path] = None,
|
|
55
|
+
use_cache: bool = True
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Initialize embedding engine.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
model_name: Sentence transformer model name
|
|
62
|
+
device: Device to use ('cuda', 'mps', 'cpu', or None for auto)
|
|
63
|
+
cache_path: Custom path for embedding cache
|
|
64
|
+
model_cache_path: Custom path for model storage
|
|
65
|
+
use_cache: Whether to use LRU cache
|
|
66
|
+
"""
|
|
67
|
+
self.model_name = model_name
|
|
68
|
+
self.cache_path = cache_path or EMBEDDING_CACHE_PATH
|
|
69
|
+
self.model_cache_path = model_cache_path or MODEL_CACHE_PATH
|
|
70
|
+
self.use_cache = use_cache
|
|
71
|
+
|
|
72
|
+
# Auto-detect device
|
|
73
|
+
if device is None:
|
|
74
|
+
if CUDA_AVAILABLE:
|
|
75
|
+
device = 'cuda'
|
|
76
|
+
logger.info("Using CUDA GPU acceleration")
|
|
77
|
+
elif MPS_AVAILABLE:
|
|
78
|
+
device = 'mps'
|
|
79
|
+
logger.info("Using Apple Silicon (MPS) GPU acceleration")
|
|
80
|
+
else:
|
|
81
|
+
device = 'cpu'
|
|
82
|
+
logger.info("Using CPU (consider GPU for faster processing)")
|
|
83
|
+
self.device = device
|
|
84
|
+
|
|
85
|
+
# Initialize model
|
|
86
|
+
self.model = None
|
|
87
|
+
self.dimension = 384 # Default for all-MiniLM-L6-v2
|
|
88
|
+
self.use_transformers = SENTENCE_TRANSFORMERS_AVAILABLE
|
|
89
|
+
|
|
90
|
+
# Initialize cache
|
|
91
|
+
self.cache = LRUCache(max_size=CACHE_MAX_SIZE) if use_cache else None
|
|
92
|
+
if self.cache:
|
|
93
|
+
self.cache.load(self.cache_path)
|
|
94
|
+
|
|
95
|
+
# Fallback: TF-IDF vectorizer
|
|
96
|
+
self.tfidf_vectorizer = None
|
|
97
|
+
self.tfidf_fitted = False
|
|
98
|
+
|
|
99
|
+
# Load model (from ModelLoaderMixin)
|
|
100
|
+
self._load_model()
|
|
101
|
+
|
|
102
|
+
def _get_cache_key(self, text: str) -> str:
|
|
103
|
+
"""Generate cache key for text."""
|
|
104
|
+
return hashlib.sha256(text.encode('utf-8')).hexdigest()[:32]
|
|
105
|
+
|
|
106
|
+
def encode(
|
|
107
|
+
self,
|
|
108
|
+
texts: Union[str, List[str]],
|
|
109
|
+
batch_size: int = 32,
|
|
110
|
+
show_progress: bool = False,
|
|
111
|
+
normalize: bool = True
|
|
112
|
+
) -> np.ndarray:
|
|
113
|
+
"""
|
|
114
|
+
Generate embeddings for text(s).
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
texts: Single text or list of texts
|
|
118
|
+
batch_size: Batch size for processing (max: 128)
|
|
119
|
+
show_progress: Show progress bar for large batches
|
|
120
|
+
normalize: Normalize embeddings to unit length
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Array of shape (n_texts, dimension) or (dimension,) for single text
|
|
124
|
+
"""
|
|
125
|
+
single_input = isinstance(texts, str)
|
|
126
|
+
if single_input:
|
|
127
|
+
texts = [texts]
|
|
128
|
+
|
|
129
|
+
if len(texts) == 0:
|
|
130
|
+
return np.array([])
|
|
131
|
+
|
|
132
|
+
batch_size = min(batch_size, MAX_BATCH_SIZE)
|
|
133
|
+
|
|
134
|
+
# Validate text length
|
|
135
|
+
for i, text in enumerate(texts):
|
|
136
|
+
if not isinstance(text, str):
|
|
137
|
+
raise ValueError(f"Text at index {i} is not a string")
|
|
138
|
+
if len(text) > MAX_TEXT_LENGTH:
|
|
139
|
+
logger.warning(f"Text {i} truncated from {len(text)} to {MAX_TEXT_LENGTH} chars")
|
|
140
|
+
texts[i] = text[:MAX_TEXT_LENGTH]
|
|
141
|
+
|
|
142
|
+
# Check cache for hits
|
|
143
|
+
embeddings = []
|
|
144
|
+
uncached_texts = []
|
|
145
|
+
uncached_indices = []
|
|
146
|
+
|
|
147
|
+
if self.cache:
|
|
148
|
+
for i, text in enumerate(texts):
|
|
149
|
+
cache_key = self._get_cache_key(text)
|
|
150
|
+
cached = self.cache.get(cache_key)
|
|
151
|
+
if cached is not None:
|
|
152
|
+
embeddings.append((i, cached))
|
|
153
|
+
else:
|
|
154
|
+
uncached_texts.append(text)
|
|
155
|
+
uncached_indices.append(i)
|
|
156
|
+
else:
|
|
157
|
+
uncached_texts = texts
|
|
158
|
+
uncached_indices = list(range(len(texts)))
|
|
159
|
+
|
|
160
|
+
# Generate embeddings for uncached texts
|
|
161
|
+
if uncached_texts:
|
|
162
|
+
if self.use_transformers and self.model:
|
|
163
|
+
uncached_embeddings = self._encode_transformer(
|
|
164
|
+
uncached_texts, batch_size=batch_size, show_progress=show_progress
|
|
165
|
+
)
|
|
166
|
+
elif self.tfidf_vectorizer:
|
|
167
|
+
uncached_embeddings = self._encode_tfidf(uncached_texts)
|
|
168
|
+
else:
|
|
169
|
+
raise RuntimeError("No embedding method available")
|
|
170
|
+
|
|
171
|
+
for i, text, embedding in zip(uncached_indices, uncached_texts, uncached_embeddings):
|
|
172
|
+
if self.cache:
|
|
173
|
+
cache_key = self._get_cache_key(text)
|
|
174
|
+
self.cache.set(cache_key, embedding)
|
|
175
|
+
embeddings.append((i, embedding))
|
|
176
|
+
|
|
177
|
+
# Sort by original index and extract embeddings
|
|
178
|
+
embeddings.sort(key=lambda x: x[0])
|
|
179
|
+
result = np.array([emb for _, emb in embeddings])
|
|
180
|
+
|
|
181
|
+
if normalize and len(result) > 0:
|
|
182
|
+
norms = np.linalg.norm(result, axis=1, keepdims=True)
|
|
183
|
+
norms[norms == 0] = 1
|
|
184
|
+
result = result / norms
|
|
185
|
+
|
|
186
|
+
if single_input:
|
|
187
|
+
return result[0]
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def encode_batch(
|
|
191
|
+
self,
|
|
192
|
+
texts: List[str],
|
|
193
|
+
batch_size: int = 32,
|
|
194
|
+
show_progress: bool = True
|
|
195
|
+
) -> np.ndarray:
|
|
196
|
+
"""Convenience method for batch encoding with progress."""
|
|
197
|
+
return self.encode(texts, batch_size=batch_size, show_progress=show_progress)
|
|
198
|
+
|
|
199
|
+
def similarity(self, embedding1: np.ndarray, embedding2: np.ndarray) -> float:
|
|
200
|
+
"""Compute cosine similarity between two embeddings. Returns [0, 1]."""
|
|
201
|
+
emb1 = embedding1 / (np.linalg.norm(embedding1) + 1e-8)
|
|
202
|
+
emb2 = embedding2 / (np.linalg.norm(embedding2) + 1e-8)
|
|
203
|
+
similarity = np.dot(emb1, emb2)
|
|
204
|
+
return float(max(0.0, min(1.0, similarity)))
|
|
205
|
+
|
|
206
|
+
def save_cache(self):
|
|
207
|
+
"""Save embedding cache to disk."""
|
|
208
|
+
if self.cache:
|
|
209
|
+
self.cache.save(self.cache_path)
|
|
210
|
+
|
|
211
|
+
def clear_cache(self):
|
|
212
|
+
"""Clear embedding cache."""
|
|
213
|
+
if self.cache:
|
|
214
|
+
self.cache.cache.clear()
|
|
215
|
+
logger.info("Cleared embedding cache")
|
|
216
|
+
|
|
217
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
218
|
+
"""Get embedding engine statistics."""
|
|
219
|
+
return {
|
|
220
|
+
'sentence_transformers_available': SENTENCE_TRANSFORMERS_AVAILABLE,
|
|
221
|
+
'use_transformers': self.use_transformers,
|
|
222
|
+
'sklearn_available': SKLEARN_AVAILABLE,
|
|
223
|
+
'torch_available': TORCH_AVAILABLE,
|
|
224
|
+
'cuda_available': CUDA_AVAILABLE,
|
|
225
|
+
'mps_available': MPS_AVAILABLE,
|
|
226
|
+
'device': self.device,
|
|
227
|
+
'model_name': self.model_name,
|
|
228
|
+
'dimension': self.dimension,
|
|
229
|
+
'cache_enabled': self.cache is not None,
|
|
230
|
+
'cache_size': len(self.cache.cache) if self.cache else 0,
|
|
231
|
+
'cache_max_size': CACHE_MAX_SIZE,
|
|
232
|
+
'model_loaded': self.model is not None or self.tfidf_vectorizer is not None
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def add_to_database(
|
|
236
|
+
self,
|
|
237
|
+
db_path,
|
|
238
|
+
embedding_column: str = 'embedding',
|
|
239
|
+
batch_size: int = 32
|
|
240
|
+
):
|
|
241
|
+
"""
|
|
242
|
+
Generate embeddings for all memories in database.
|
|
243
|
+
|
|
244
|
+
Delegates to :func:`embeddings.database.add_embeddings_to_database`.
|
|
245
|
+
"""
|
|
246
|
+
from embeddings.database import add_embeddings_to_database
|
|
247
|
+
add_embeddings_to_database(self, db_path, embedding_column, batch_size)
|