superlocalmemory 3.7.5 → 3.7.7

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 (44) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin/skills/slm-recall/SKILL.md +4 -3
  7. package/plugin-src/manifest.json +1 -1
  8. package/plugin-src/requirements.txt +1 -1
  9. package/plugin-src/skills/slm-recall/SKILL.md +4 -3
  10. package/pyproject.toml +6 -6
  11. package/src/superlocalmemory/__init__.py +1 -1
  12. package/src/superlocalmemory/cli/commands.py +169 -9
  13. package/src/superlocalmemory/cli/setup_wizard.py +53 -2
  14. package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
  15. package/src/superlocalmemory/core/config.py +1 -1
  16. package/src/superlocalmemory/core/engine.py +3 -2
  17. package/src/superlocalmemory/core/engine_wiring.py +3 -1
  18. package/src/superlocalmemory/core/scale_engine.py +9 -1
  19. package/src/superlocalmemory/core/store_pipeline.py +1 -1
  20. package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
  21. package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -1
  22. package/src/superlocalmemory/infra/auth_middleware.py +5 -5
  23. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  24. package/src/superlocalmemory/mcp/server.py +1 -0
  25. package/src/superlocalmemory/mcp/tools_active.py +11 -7
  26. package/src/superlocalmemory/mcp/tools_core.py +178 -20
  27. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  28. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  29. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  30. package/src/superlocalmemory/retrieval/engine.py +16 -8
  31. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  32. package/src/superlocalmemory/server/recall_health.py +13 -7
  33. package/src/superlocalmemory/server/routes/chat.py +2 -2
  34. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  35. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  36. package/src/superlocalmemory/server/routes/v3_api.py +97 -20
  37. package/src/superlocalmemory/server/unified_daemon.py +261 -60
  38. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  39. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  40. package/src/superlocalmemory/ui/index.html +32 -1
  41. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  42. package/src/superlocalmemory/ui/js/memory-chat.js +2 -2
  43. package/src/superlocalmemory/ui/js/profiles.js +11 -2
  44. package/src/superlocalmemory/vector/lancedb_backend.py +42 -6
@@ -17,10 +17,8 @@ engine state exists in exactly one process: the daemon.
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
- import json
21
20
  import logging
22
21
  import urllib.parse
23
- import urllib.request
24
22
  from typing import Any
25
23
 
26
24
  logger = logging.getLogger(__name__)
@@ -40,9 +38,6 @@ class DaemonPoolProxy:
40
38
  self._port = port
41
39
  self._timeout = timeout_s
42
40
 
43
- def _url(self, path: str) -> str:
44
- return f"http://127.0.0.1:{self._port}{path}"
45
-
46
41
  def recall(
47
42
  self, query: str, limit: int = 10, session_id: str = "",
48
43
  fast: bool = False,
@@ -64,15 +59,18 @@ class DaemonPoolProxy:
64
59
  _params["include_shared"] = "true" if include_shared else "false"
65
60
  params = urllib.parse.urlencode(_params)
66
61
  try:
67
- with urllib.request.urlopen(
68
- self._url(f"/recall?{params}"), timeout=self._timeout,
69
- ) as resp:
70
- data = json.loads(resp.read().decode() or "{}")
62
+ from superlocalmemory.cli.daemon import daemon_request
63
+
64
+ data = daemon_request(
65
+ "GET",
66
+ f"/recall?{params}",
67
+ timeout_seconds=self._timeout,
68
+ )
71
69
  except Exception as exc:
72
70
  logger.warning("daemon /recall failed: %s", exc)
73
71
  return {"ok": False, "error": str(exc)}
74
72
  if not isinstance(data, dict):
75
- return {"ok": False, "error": "non-dict response"}
73
+ return {"ok": False, "error": "owned daemon unavailable"}
76
74
  data.setdefault("ok", True)
77
75
  return data
78
76
 
@@ -89,6 +89,7 @@ _ESSENTIAL_TOOLS: set[str] = {
89
89
  # Core memory operations (8)
90
90
  "remember", "recall", "search", "fetch",
91
91
  "list_recent", "delete_memory", "update_memory", "get_status",
92
+ "switch_profile",
92
93
  # Session lifecycle (3)
93
94
  "session_init", "observe", "close_session",
94
95
  # Feedback / learning signals — reachable Dash-Core path for
@@ -42,12 +42,13 @@ def _sqlite_emergency_recall(
42
42
  native BM25 ranking via ``ORDER BY fts.rank``. This is the Mem0 / Letta
43
43
  industry pattern — multi-process safe via SQLite WAL mode.
44
44
 
45
- Quality degraded vs full 6-channel (no semantic, no entity graph, no
45
+ Quality degraded vs the full recall path (no semantic, no entity graph, no
46
46
  temporal/spreading-activation/Hopfield) but still provides real BM25
47
47
  math + age gate. Returns ``degraded_mode=True`` via the caller's flag.
48
48
 
49
49
  Used ONLY when Tier-1 (full daemon recall) fails completely. Normal
50
- path is full 6-channel; this is the fire-alarm.
50
+ path is the full five-producer fusion + entity-graph enhancement;
51
+ this is the fire-alarm.
51
52
  """
52
53
  import re
53
54
 
@@ -186,9 +187,11 @@ def register_active_tools(server, get_engine: Callable) -> None:
186
187
  permanently relevant still surface). Default: 30.
187
188
  Set to 0 to disable the age gate entirely.
188
189
 
189
- Scoring: Uses 6-channel fusion (semantic + BM25 + entity_graph + temporal +
190
- spreading_activation + hopfield) with Ebbinghaus exponential recency decay
191
- and FSRS stability strengthening by access frequency.
190
+ Scoring: five candidate producers (semantic + BM25 + temporal +
191
+ spreading_activation + hopfield) feed RRF fusion; the entity graph then
192
+ applies an optional post-fusion score enhancement. Combined with
193
+ Ebbinghaus exponential recency decay and FSRS stability strengthening by
194
+ access frequency.
192
195
  """
193
196
  try:
194
197
  from superlocalmemory.hooks.rules_engine import RulesEngine
@@ -215,8 +218,9 @@ def register_active_tools(server, get_engine: Callable) -> None:
215
218
  search_query = "recent important decisions"
216
219
 
217
220
  # 2-tier recall (industry pattern: Hindsight / Zep / Supermemory):
218
- # PRIMARY: full 6-channel via daemon (semantic + BM25 + entity + temporal
219
- # + Hopfield + spreading-activation, Fisher-Rao fusion, FSRS decay).
221
+ # PRIMARY: full recall via daemon five candidate producers (semantic
222
+ # + BM25 + temporal + Hopfield + spreading-activation) into RRF
223
+ # fusion, then entity-graph post-fusion enhancement, FSRS decay.
220
224
  # Fast because Ollama embed model is kept warm (keep_alive=-1
221
225
  # + eager pre-warm at daemon boot).
222
226
  # EMERGENCY: direct FTS5 BM25 (Mem0 / Letta pattern). Used ONLY when
@@ -25,6 +25,27 @@ from superlocalmemory.mcp.shared import authorize_mcp_mutation
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
+
29
+ async def _runtime_profile(get_engine: Callable, explicit: str = "") -> str:
30
+ """Resolve an MCP default profile from daemon runtime truth."""
31
+ if explicit:
32
+ return explicit
33
+ import asyncio
34
+
35
+ try:
36
+ from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
37
+
38
+ if await asyncio.to_thread(is_daemon_running):
39
+ status = await asyncio.to_thread(daemon_request, "GET", "/status")
40
+ if isinstance(status, dict) and status.get("profile"):
41
+ return str(status["profile"])
42
+ raise RuntimeError("resident daemon did not report its active profile")
43
+ except RuntimeError:
44
+ raise
45
+ except Exception as exc:
46
+ logger.debug("daemon profile resolution failed: %s", exc)
47
+ return str(get_engine().profile_id)
48
+
28
49
  def _emit_event(event_type: str, payload: dict | None = None,
29
50
  source_agent: str = "mcp_client") -> None:
30
51
  """Emit an event to the EventBus (best-effort, never raises)."""
@@ -42,6 +63,7 @@ def _record_recall_hits(
42
63
  query: str,
43
64
  results: list[dict],
44
65
  *,
66
+ profile_id: str = "",
45
67
  query_id: str = "",
46
68
  fact_ids_candidates: list[str] | None = None,
47
69
  ) -> None:
@@ -63,7 +85,7 @@ def _record_recall_hits(
63
85
  )
64
86
 
65
87
  engine = get_engine()
66
- pid = engine.profile_id
88
+ pid = profile_id or engine.profile_id
67
89
  slm_dir = canonical_data_root()
68
90
 
69
91
  shown_ids = [r.get("fact_id", "") for r in results[:10]
@@ -335,7 +357,12 @@ def register_core_tools(server, get_engine: Callable) -> None:
335
357
  if result.get("ok"):
336
358
  # Record implicit feedback: every returned result is a recall_hit
337
359
  try:
338
- _record_recall_hits(get_engine, query, result.get("results", []))
360
+ _record_recall_hits(
361
+ get_engine,
362
+ query,
363
+ result.get("results", []),
364
+ profile_id=str(result.get("profile", "")),
365
+ )
339
366
  except Exception:
340
367
  pass # Feedback is non-critical, never block recall
341
368
  _emit_event("memory.recalled", {
@@ -370,7 +397,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
370
397
  """Full-text search across memories using FTS5 with BM25 ranking."""
371
398
  try:
372
399
  engine = get_engine()
373
- pid = profile_id or engine.profile_id
400
+ pid = await _runtime_profile(get_engine, profile_id)
374
401
  facts = engine._db.search_facts_fts(query, pid, limit=limit)
375
402
  items = []
376
403
  for f in facts:
@@ -392,7 +419,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
392
419
  try:
393
420
  engine = get_engine()
394
421
  ids = [fid.strip() for fid in fact_ids.split(",") if fid.strip()]
395
- facts = engine._db.get_facts_by_ids(ids, engine.profile_id)
422
+ pid = await _runtime_profile(get_engine)
423
+ facts = engine._db.get_facts_by_ids(ids, pid)
396
424
  items = []
397
425
  for f in facts:
398
426
  items.append({
@@ -417,7 +445,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
417
445
  """List most recently stored memories, newest first."""
418
446
  try:
419
447
  engine = get_engine()
420
- pid = profile_id or engine.profile_id
448
+ pid = await _runtime_profile(get_engine, profile_id)
421
449
  # v3.6.12 (search-2): push the limit into the query — was loading the
422
450
  # ENTIRE facts table (deserializing every 768-float embedding) just
423
451
  # to return the top N. get_all_facts preserves created_at DESC order.
@@ -440,7 +468,37 @@ def register_core_tools(server, get_engine: Callable) -> None:
440
468
  async def get_status() -> dict:
441
469
  """Get memory system status: fact count, entity count, mode, profile, db size."""
442
470
  try:
471
+ import asyncio
443
472
  import os
473
+
474
+ from superlocalmemory.cli.daemon import (
475
+ daemon_request,
476
+ is_daemon_running,
477
+ )
478
+
479
+ if await asyncio.to_thread(is_daemon_running):
480
+ daemon_status = await asyncio.to_thread(
481
+ daemon_request,
482
+ "GET",
483
+ "/status",
484
+ )
485
+ if isinstance(daemon_status, dict) and daemon_status.get("profile"):
486
+ return {
487
+ "success": True,
488
+ "mode": daemon_status.get("mode", "unknown"),
489
+ "provider": daemon_status.get("provider", "none"),
490
+ "profile": daemon_status["profile"],
491
+ "base_dir": daemon_status.get("base_dir", ""),
492
+ "db_path": daemon_status.get("db_path", ""),
493
+ "db_size_mb": float(daemon_status.get("db_size_mb", 0.0)),
494
+ "fact_count": int(daemon_status.get("fact_count", 0)),
495
+ "entity_count": int(daemon_status.get("entity_count", 0)),
496
+ "edge_count": int(daemon_status.get("edge_count", 0)),
497
+ "profile_generation": int(
498
+ daemon_status.get("profile_generation", 0)
499
+ ),
500
+ }
501
+
444
502
  engine = get_engine()
445
503
  pid = engine.profile_id
446
504
  fact_count = engine._db.get_fact_count(pid)
@@ -474,6 +532,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
474
532
  "fact_count": fact_count,
475
533
  "entity_count": entity_count,
476
534
  "edge_count": edge_count,
535
+ "profile_generation": 0,
477
536
  }
478
537
  except Exception as exc:
479
538
  logger.exception("get_status failed")
@@ -484,7 +543,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
484
543
  """Rebuild knowledge graph edges for all facts in the active profile."""
485
544
  try:
486
545
  engine = get_engine()
487
- pid = profile_id or engine.profile_id
546
+ pid = await _runtime_profile(get_engine, profile_id)
488
547
  authorization = authorize_mcp_mutation(
489
548
  engine,
490
549
  "update",
@@ -511,6 +570,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
511
570
  async def switch_profile(profile_id: str) -> dict:
512
571
  """Switch the active memory profile. All operations scope to this profile."""
513
572
  try:
573
+ import asyncio
574
+
514
575
  engine = get_engine()
515
576
  old = engine.profile_id
516
577
  authorization = authorize_mcp_mutation(
@@ -520,18 +581,57 @@ def register_core_tools(server, get_engine: Callable) -> None:
520
581
  profile_id=profile_id,
521
582
  content_preview=f"{old} -> {profile_id}",
522
583
  )
523
- engine.profile_id = profile_id
584
+ from superlocalmemory.cli.daemon import (
585
+ daemon_request,
586
+ is_daemon_running,
587
+ )
524
588
 
525
- # Persist to both config stores so CLI and Dashboard stay in sync
526
- try:
527
- from superlocalmemory.server.routes.helpers import (
528
- ensure_profile_in_db, set_active_profile_everywhere,
589
+ generation = 0
590
+ if await asyncio.to_thread(is_daemon_running):
591
+ result = await asyncio.to_thread(
592
+ daemon_request,
593
+ "POST",
594
+ f"/api/profiles/{profile_id}/switch",
595
+ )
596
+ if not result or not result.get("success"):
597
+ return {
598
+ "success": False,
599
+ "error": "resident daemon rejected the profile switch",
600
+ }
601
+ acknowledged = str(result.get("active_profile", ""))
602
+ if acknowledged != profile_id:
603
+ return {
604
+ "success": False,
605
+ "error": "resident daemon acknowledged a different profile",
606
+ }
607
+ generation = int(result.get("generation", 0))
608
+ else:
609
+ rows = engine._db.execute(
610
+ "SELECT 1 FROM profiles WHERE profile_id = ?",
611
+ (profile_id,),
612
+ )
613
+ if not rows:
614
+ return {
615
+ "success": False,
616
+ "error": f"Profile '{profile_id}' does not exist.",
617
+ }
618
+ from superlocalmemory.server.profile_runtime import (
619
+ persist_active_profile,
529
620
  )
530
- ensure_profile_in_db(profile_id)
531
- set_active_profile_everywhere(profile_id)
532
- except ImportError:
533
- # Dashboard not installed — profile switch still works for MCP/CLI
534
- logger.debug("Dashboard routes not available, profile set in engine only")
621
+
622
+ persistence = persist_active_profile(profile_id)
623
+ try:
624
+ engine.profile_id = profile_id
625
+ engine._config.active_profile = profile_id
626
+ except BaseException:
627
+ engine.profile_id = old
628
+ engine._config.active_profile = old
629
+ persistence.rollback()
630
+ raise
631
+
632
+ # Synchronize this MCP process only after daemon acknowledgement.
633
+ engine.profile_id = profile_id
634
+ engine._config.active_profile = profile_id
535
635
 
536
636
  # v3.6.12 (search-3): recall/delete run in a separate worker
537
637
  # subprocess that caches its engine (and profile_id) at init. Recycle
@@ -547,6 +647,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
547
647
  "success": True,
548
648
  "previous_profile": old,
549
649
  "current_profile": profile_id,
650
+ "generation": generation,
550
651
  }
551
652
  except Exception as exc:
552
653
  logger.exception("switch_profile failed")
@@ -569,7 +670,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
569
670
  """Get memory usage breakdown by fact type and lifecycle state."""
570
671
  try:
571
672
  engine = get_engine()
572
- pid = engine.profile_id
673
+ pid = await _runtime_profile(get_engine)
573
674
  facts = engine._db.get_all_facts(pid)
574
675
  by_type: dict[str, int] = {}
575
676
  by_lifecycle: dict[str, int] = {}
@@ -594,11 +695,12 @@ def register_core_tools(server, get_engine: Callable) -> None:
594
695
  """Get learned behavioral patterns (interests, refinements, archival habits)."""
595
696
  try:
596
697
  engine = get_engine()
698
+ pid = await _runtime_profile(get_engine)
597
699
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
598
700
  store = BehavioralPatternStore(engine._db.db_path)
599
701
  ptype = pattern_type if pattern_type else None
600
702
  patterns = store.get_patterns(
601
- engine.profile_id, pattern_type=ptype, limit=limit,
703
+ pid, pattern_type=ptype, limit=limit,
602
704
  )
603
705
  return {"success": True, "patterns": patterns, "count": len(patterns)}
604
706
  except Exception as exc:
@@ -610,18 +712,19 @@ def register_core_tools(server, get_engine: Callable) -> None:
610
712
  """Correct or annotate a learned behavioral pattern to improve retrieval."""
611
713
  try:
612
714
  engine = get_engine()
715
+ pid = await _runtime_profile(get_engine)
613
716
  authorization = authorize_mcp_mutation(
614
717
  engine,
615
718
  "update",
616
719
  mutation_source="mcp-correct-pattern",
617
- profile_id=engine.profile_id,
720
+ profile_id=pid,
618
721
  fact_id=pattern_id,
619
722
  content_preview=correction,
620
723
  )
621
724
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
622
725
  store = BehavioralPatternStore(engine._db.db_path)
623
726
  store.record(
624
- engine.profile_id,
727
+ pid,
625
728
  pattern_type="correction",
626
729
  pattern_key=pattern_id,
627
730
  metadata={"correction": correction},
@@ -649,6 +752,34 @@ def register_core_tools(server, get_engine: Callable) -> None:
649
752
  from superlocalmemory.mcp.agent_context import get_current_agent_id
650
753
  agent_id = get_current_agent_id()
651
754
  try:
755
+ import asyncio
756
+ import urllib.parse
757
+
758
+ from superlocalmemory.cli.daemon import (
759
+ daemon_request,
760
+ is_daemon_running,
761
+ )
762
+
763
+ if await asyncio.to_thread(is_daemon_running):
764
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
765
+ result = await asyncio.to_thread(
766
+ daemon_request, "DELETE", path,
767
+ )
768
+ if isinstance(result, dict) and result.get("success"):
769
+ _emit_event("memory.deleted", {
770
+ "fact_id": fact_id,
771
+ "agent_id": agent_id,
772
+ }, source_agent=agent_id)
773
+ return {
774
+ "success": True, "deleted": fact_id,
775
+ "agent_id": agent_id,
776
+ }
777
+ return {
778
+ "success": False,
779
+ "retryable": True,
780
+ "error": "resident daemon rejected the delete operation",
781
+ }
782
+
652
783
  from superlocalmemory.core.worker_pool import WorkerPool
653
784
  pool = WorkerPool.shared()
654
785
  result = pool._send({
@@ -691,6 +822,33 @@ def register_core_tools(server, get_engine: Callable) -> None:
691
822
  try:
692
823
  if not content or not content.strip():
693
824
  return {"success": False, "error": "content cannot be empty"}
825
+ import asyncio
826
+ import urllib.parse
827
+
828
+ from superlocalmemory.cli.daemon import (
829
+ daemon_request,
830
+ is_daemon_running,
831
+ )
832
+
833
+ if await asyncio.to_thread(is_daemon_running):
834
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
835
+ result = await asyncio.to_thread(
836
+ daemon_request,
837
+ "PATCH",
838
+ path,
839
+ {"content": content.strip()},
840
+ )
841
+ if isinstance(result, dict) and result.get("success"):
842
+ return {
843
+ "success": True, "fact_id": fact_id,
844
+ "content": content.strip(),
845
+ }
846
+ return {
847
+ "success": False,
848
+ "retryable": True,
849
+ "error": "resident daemon rejected the update operation",
850
+ }
851
+
694
852
  from superlocalmemory.core.worker_pool import WorkerPool
695
853
  pool = WorkerPool.shared()
696
854
  result = pool._send({
@@ -32,7 +32,6 @@ if TYPE_CHECKING:
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
34
  _VARIANCE_FLOOR: float = 1e-6
35
- _EMBED_DIM: int = 768
36
35
 
37
36
  # Stage-9 fix: cap the NUMBER of tenants held in memory. The per-tenant entry
38
37
  # caps (WP-A/B) bound depth, but _centroids/_counts grew once per distinct
@@ -59,11 +58,16 @@ class CentroidStore:
59
58
  Centroid update rule (Welford running mean — exact, O(1) per update):
60
59
  new_centroid = old_centroid * (n / (n+1)) + new_vec * (1 / (n+1))
61
60
  """
62
- def __init__(self, max_tenants: int = _MAX_TENANTS) -> None:
61
+ def __init__(
62
+ self,
63
+ max_tenants: int = _MAX_TENANTS,
64
+ embedding_dimension: int | None = None,
65
+ ) -> None:
63
66
  # OrderedDict for O(1) LRU eviction by tenant count.
64
67
  self._centroids: "OrderedDict[str, np.ndarray]" = OrderedDict() # tenant → vec
65
68
  self._counts: "OrderedDict[str, int]" = OrderedDict() # tenant → count
66
69
  self._max_tenants = max_tenants
70
+ self._embedding_dimension = embedding_dimension
67
71
  self._lock = threading.RLock()
68
72
 
69
73
  def _evict_tenants_if_needed(self) -> None:
@@ -89,7 +93,9 @@ class CentroidStore:
89
93
  for _entry_id, blob, _ctx_fp in rows:
90
94
  try:
91
95
  vec = np.frombuffer(blob, dtype=np.float32).copy()
92
- if vec.shape[0] == _EMBED_DIM:
96
+ if self._embedding_dimension is None:
97
+ self._embedding_dimension = int(vec.shape[0])
98
+ if vec.shape[0] == self._embedding_dimension:
93
99
  vectors.append(vec)
94
100
  except Exception:
95
101
  continue
@@ -116,7 +122,17 @@ class CentroidStore:
116
122
  """
117
123
  try:
118
124
  vec = new_vector.astype(np.float32)
125
+ if vec.ndim != 1 or vec.size == 0:
126
+ return
119
127
  with self._lock:
128
+ if self._embedding_dimension is None:
129
+ self._embedding_dimension = int(vec.shape[0])
130
+ if vec.shape[0] != self._embedding_dimension:
131
+ logger.warning(
132
+ "CentroidStore.update skipped dimension %d; expected %d",
133
+ vec.shape[0], self._embedding_dimension,
134
+ )
135
+ return
120
136
  if tenant_id not in self._centroids:
121
137
  self._centroids[tenant_id] = vec.copy()
122
138
  self._counts[tenant_id] = 1
@@ -156,6 +172,8 @@ class CentroidStore:
156
172
  if centroid is None or count < 5:
157
173
  return False
158
174
  q = query_vector.astype(np.float32)
175
+ if q.shape != centroid.shape:
176
+ return False
159
177
  sim = _cosine_similarity(q, centroid)
160
178
  threshold = 1.0 - distance_floor
161
179
  if sim < threshold:
@@ -96,6 +96,12 @@ class _LazySemanticEmbedder:
96
96
  self._service = EmbeddingService(SLMConfig.load().embedding)
97
97
  return self._service.embed(text)
98
98
 
99
+ @property
100
+ def dimension(self) -> int:
101
+ from superlocalmemory.core.config import SLMConfig
102
+
103
+ return int(SLMConfig.load().embedding.dimension)
104
+
99
105
  def close(self) -> None:
100
106
  service = self._service
101
107
  self._service = None
@@ -492,6 +498,7 @@ class CacheManager:
492
498
  db=self._db,
493
499
  config=optimize_config,
494
500
  embedder=desired_embedder,
501
+ embedding_dimension=getattr(desired_embedder, "dimension", None),
495
502
  ))
496
503
 
497
504
  # ---- core request path ----
@@ -43,6 +43,7 @@ if TYPE_CHECKING:
43
43
 
44
44
  logger = logging.getLogger(__name__)
45
45
 
46
+ # Backward-compatible exported default; runtime validation uses _embed_dim.
46
47
  _EMBED_DIM: int = 768
47
48
  _DEFAULT_MAX_TURNS: int = 6
48
49
  _DEFAULT_CONTEXT_WINDOW: int = 3
@@ -79,10 +80,13 @@ class VCacheSemantic(SemanticTier):
79
80
  config: "OptimizeConfig",
80
81
  *,
81
82
  embedder: Callable[[str], list[float] | np.ndarray | None] | None = None,
83
+ embedding_dimension: int | None = None,
82
84
  ) -> None:
83
85
  self._db = db
84
86
  self._config = config
85
87
  self._embedder = embedder
88
+ self._embed_dim = embedding_dimension
89
+ self._dimension_lock = threading.Lock()
86
90
  # TODO(v3.7): when entry_count > 10_000, promote to sqlite-vec. Config flag: semantic_use_vec.
87
91
 
88
92
  self._boundary_store = BoundaryStore(
@@ -94,7 +98,9 @@ class VCacheSemantic(SemanticTier):
94
98
  step=float(getattr(config, "semantic_boundary_step", 0.01)),
95
99
  epsilon=float(getattr(config, "semantic_error_target", _DEFAULT_ERROR_TARGET)),
96
100
  )
97
- self._centroid_store = CentroidStore()
101
+ self._centroid_store = CentroidStore(
102
+ embedding_dimension=embedding_dimension,
103
+ )
98
104
  self._context_key_builder = ContextKeyBuilder(
99
105
  window_turns=int(getattr(config, "semantic_context_window_turns", _DEFAULT_CONTEXT_WINDOW))
100
106
  )
@@ -145,10 +151,10 @@ class VCacheSemantic(SemanticTier):
145
151
  if embed is None:
146
152
  return None
147
153
  vec = np.asarray(embed, dtype=np.float32)
148
- if vec.shape[0] != _EMBED_DIM:
154
+ if not self._accept_dimension(vec):
149
155
  logger.debug(
150
- "VCacheSemantic.lookup: skip — embed dim=%d (expected %d)",
151
- vec.shape[0], _EMBED_DIM,
156
+ "VCacheSemantic.lookup: skip — embed dim=%s (expected %s)",
157
+ vec.shape, self._embed_dim,
152
158
  )
153
159
  return None
154
160
  return self._lookup_inner(req, tenant_id, vec)
@@ -267,6 +273,16 @@ class VCacheSemantic(SemanticTier):
267
273
  if callable(close):
268
274
  close()
269
275
 
276
+ def _accept_dimension(self, vector: np.ndarray) -> bool:
277
+ """Lock one configured/inferred vector width for this cache instance."""
278
+ if vector.ndim != 1 or vector.size == 0:
279
+ return False
280
+ with self._dimension_lock:
281
+ if self._embed_dim is None:
282
+ self._embed_dim = int(vector.shape[0])
283
+ self._centroid_store._embedding_dimension = self._embed_dim
284
+ return int(vector.shape[0]) == self._embed_dim
285
+
270
286
  # ------------------------------------------------------------------
271
287
  # Internal lookup
272
288
  # ------------------------------------------------------------------
@@ -387,7 +403,7 @@ class VCacheSemantic(SemanticTier):
387
403
  for entry_id, blob, ctx_fp in rows: # C-10: unpack persisted context_fp
388
404
  try:
389
405
  v = np.frombuffer(blob, dtype=np.float32).copy()
390
- if v.shape[0] == _EMBED_DIM:
406
+ if self._accept_dimension(v):
391
407
  entries.append((entry_id, ctx_fp, v))
392
408
  except Exception:
393
409
  continue
@@ -436,10 +452,11 @@ class VCacheSemantic(SemanticTier):
436
452
  ) -> None:
437
453
  """Persist vector + boundary record; update in-memory index + centroid."""
438
454
  vec = np.asarray(embed, dtype=np.float32)
439
- if vec.shape[0] != _EMBED_DIM:
455
+ if not self._accept_dimension(vec):
440
456
  logger.warning(
441
- "VCacheSemantic._set_inner: unexpected embedding dim %d (expected %d) "
442
- "for entry=%s — skipping", vec.shape[0], _EMBED_DIM, entry_id,
457
+ "VCacheSemantic._set_inner: unexpected embedding shape %s "
458
+ "(expected %s) for entry=%s — skipping",
459
+ vec.shape, self._embed_dim, entry_id,
443
460
  )
444
461
  return
445
462
 
@@ -450,8 +467,8 @@ class VCacheSemantic(SemanticTier):
450
467
  tenant_id=tenant_id,
451
468
  vector=vec_bytes,
452
469
  meta={
453
- "model": "nomic-ai/nomic-embed-text-v1.5",
454
- "dim": _EMBED_DIM,
470
+ "model": "configured-embedding-provider",
471
+ "dim": self._embed_dim,
455
472
  "context_fp": context_fp,
456
473
  },
457
474
  )
@@ -2,10 +2,13 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """SuperLocalMemory V3 — Retrieval Engine (6-Channel Orchestrator).
5
+ """SuperLocalMemory V3 — retrieval orchestration.
6
6
 
7
- 6 channels -> single RRF fusion -> optional cross-encoder rerank.
8
- Channels: semantic, BM25, entity_graph, temporal, spreading_activation, hopfield.
7
+ Five parallel candidate producers (semantic, BM25, temporal, spreading
8
+ activation, and Hopfield) feed single-pass RRF fusion; optional profile hits
9
+ can join that fusion input. The entity graph may then score and boost fused
10
+ candidates when enabled and within the recall time budget. It is not a sixth
11
+ parallel candidate producer. Optional cross-encoder reranking follows fusion.
9
12
  Replaces V1's broken 10-channel triple-re-fusion pipeline.
10
13
 
11
14
  Part of Qualixar | Author: Varun Pratap Bhardwaj
@@ -54,7 +57,12 @@ class EmbeddingProvider(Protocol):
54
57
 
55
58
 
56
59
  class RetrievalEngine:
57
- """Six-channel retrieval orchestrator.
60
+ """Retrieval orchestrator: five candidate producers -> RRF fusion.
61
+
62
+ Five parallel candidate producers (semantic, BM25, temporal,
63
+ spreading_activation, hopfield) feed single-pass RRF fusion, followed by
64
+ optional cross-encoder rerank and an optional entity-graph post-fusion
65
+ score enhancement. Entity graph is not a sixth parallel candidate producer.
58
66
 
59
67
  Usage::
60
68
  engine = RetrievalEngine(db, config, channels, embedder)
@@ -627,10 +635,10 @@ class RetrievalEngine:
627
635
  v3.4.53: channels run in PARALLEL via ThreadPoolExecutor. Industry
628
636
  standard (EverMemOS, szl-recall, ContentPilot 2026): all channels
629
637
  are independent after embedding; running them serially wastes time
630
- equal to the sum of all channel latencies. Parallel execution brings
631
- total channel time from sum(semantic+bm25+entity+temporal+hopfield+sa)
632
- down to max(semantic,bm25,entity,temporal,hopfield,sa) roughly a
633
- 3-5x speedup for the channel phase.
638
+ equal to the sum of all producer latencies. When multiple producers are
639
+ enabled and healthy, parallel dispatch generally bounds the producer
640
+ phase by the slowest submitted producer, plus serial embedding and
641
+ result-collection overhead.
634
642
  """
635
643
  import os as _os_e
636
644
  import time as _time_e