superlocalmemory 3.7.6 → 3.7.8

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 (34) hide show
  1. package/CHANGELOG.md +30 -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-src/manifest.json +1 -1
  7. package/plugin-src/requirements.txt +1 -1
  8. package/pyproject.toml +6 -7
  9. package/src/superlocalmemory/__init__.py +1 -1
  10. package/src/superlocalmemory/cli/commands.py +171 -11
  11. package/src/superlocalmemory/cli/setup_wizard.py +18 -1
  12. package/src/superlocalmemory/infra/auth_middleware.py +33 -5
  13. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  14. package/src/superlocalmemory/mcp/server.py +1 -0
  15. package/src/superlocalmemory/mcp/tools_core.py +216 -20
  16. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  17. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  18. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  19. package/src/superlocalmemory/server/api.py +17 -0
  20. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  21. package/src/superlocalmemory/server/recall_health.py +12 -6
  22. package/src/superlocalmemory/server/routes/chat.py +63 -12
  23. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  24. package/src/superlocalmemory/server/routes/memories.py +58 -11
  25. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  26. package/src/superlocalmemory/server/routes/v3_api.py +128 -52
  27. package/src/superlocalmemory/server/ui.py +10 -0
  28. package/src/superlocalmemory/server/unified_daemon.py +290 -74
  29. package/src/superlocalmemory/storage/migration_runner.py +17 -3
  30. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  31. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  32. package/src/superlocalmemory/ui/index.html +32 -1
  33. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  34. package/src/superlocalmemory/ui/js/profiles.js +11 -2
@@ -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,95 @@ 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
+ # Only the profile_id explicitly confirmed by this process
591
+ # (daemon-acknowledged + locally-validated, or locally
592
+ # validated directly) is ever synced into engine state.
593
+ confirmed_profile_id = None
594
+ if await asyncio.to_thread(is_daemon_running):
595
+ result = await asyncio.to_thread(
596
+ daemon_request,
597
+ "POST",
598
+ f"/api/profiles/{profile_id}/switch",
599
+ )
600
+ if not result or not result.get("success"):
601
+ return {
602
+ "success": False,
603
+ "error": "resident daemon rejected the profile switch",
604
+ }
605
+ acknowledged = str(result.get("active_profile", ""))
606
+ if not acknowledged or acknowledged != profile_id:
607
+ return {
608
+ "success": False,
609
+ "error": "resident daemon acknowledged a different profile",
610
+ }
611
+ # Local consistency guard (SEC-H-01): the daemon's HTTP
612
+ # acknowledgement alone is not sufficient — this MCP
613
+ # process must also confirm the profile exists in its
614
+ # own local DB handle before syncing local state to it.
615
+ # Mirrors the existence check the no-daemon branch already
616
+ # performs below.
617
+ local_rows = engine._db.execute(
618
+ "SELECT 1 FROM profiles WHERE profile_id = ?",
619
+ (profile_id,),
620
+ )
621
+ if not local_rows:
622
+ return {
623
+ "success": False,
624
+ "error": (
625
+ f"resident daemon acknowledged profile "
626
+ f"'{acknowledged}' but it does not exist in "
627
+ f"this process's local profile store"
628
+ ),
629
+ }
630
+ generation = int(result.get("generation", 0))
631
+ # Sync target is the DAEMON-CONFIRMED value, never the raw
632
+ # caller-supplied profile_id, even though they are equal
633
+ # here by construction (checked above).
634
+ confirmed_profile_id = acknowledged
635
+ else:
636
+ rows = engine._db.execute(
637
+ "SELECT 1 FROM profiles WHERE profile_id = ?",
638
+ (profile_id,),
639
+ )
640
+ if not rows:
641
+ return {
642
+ "success": False,
643
+ "error": f"Profile '{profile_id}' does not exist.",
644
+ }
645
+ from superlocalmemory.server.profile_runtime import (
646
+ persist_active_profile,
529
647
  )
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")
648
+
649
+ persistence = persist_active_profile(profile_id)
650
+ try:
651
+ engine.profile_id = profile_id
652
+ engine._config.active_profile = profile_id
653
+ except BaseException:
654
+ engine.profile_id = old
655
+ engine._config.active_profile = old
656
+ persistence.rollback()
657
+ raise
658
+ confirmed_profile_id = profile_id
659
+
660
+ if not confirmed_profile_id:
661
+ # Defensive: should be unreachable — every path above
662
+ # either returns an error or sets confirmed_profile_id.
663
+ return {
664
+ "success": False,
665
+ "error": "profile switch could not be confirmed",
666
+ }
667
+
668
+ # Synchronize this MCP process only after confirmation
669
+ # (daemon-acknowledged + locally-validated, or directly
670
+ # locally-validated in the no-daemon branch above).
671
+ engine.profile_id = confirmed_profile_id
672
+ engine._config.active_profile = confirmed_profile_id
535
673
 
536
674
  # v3.6.12 (search-3): recall/delete run in a separate worker
537
675
  # subprocess that caches its engine (and profile_id) at init. Recycle
@@ -547,6 +685,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
547
685
  "success": True,
548
686
  "previous_profile": old,
549
687
  "current_profile": profile_id,
688
+ "generation": generation,
550
689
  }
551
690
  except Exception as exc:
552
691
  logger.exception("switch_profile failed")
@@ -569,7 +708,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
569
708
  """Get memory usage breakdown by fact type and lifecycle state."""
570
709
  try:
571
710
  engine = get_engine()
572
- pid = engine.profile_id
711
+ pid = await _runtime_profile(get_engine)
573
712
  facts = engine._db.get_all_facts(pid)
574
713
  by_type: dict[str, int] = {}
575
714
  by_lifecycle: dict[str, int] = {}
@@ -594,11 +733,12 @@ def register_core_tools(server, get_engine: Callable) -> None:
594
733
  """Get learned behavioral patterns (interests, refinements, archival habits)."""
595
734
  try:
596
735
  engine = get_engine()
736
+ pid = await _runtime_profile(get_engine)
597
737
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
598
738
  store = BehavioralPatternStore(engine._db.db_path)
599
739
  ptype = pattern_type if pattern_type else None
600
740
  patterns = store.get_patterns(
601
- engine.profile_id, pattern_type=ptype, limit=limit,
741
+ pid, pattern_type=ptype, limit=limit,
602
742
  )
603
743
  return {"success": True, "patterns": patterns, "count": len(patterns)}
604
744
  except Exception as exc:
@@ -610,18 +750,19 @@ def register_core_tools(server, get_engine: Callable) -> None:
610
750
  """Correct or annotate a learned behavioral pattern to improve retrieval."""
611
751
  try:
612
752
  engine = get_engine()
753
+ pid = await _runtime_profile(get_engine)
613
754
  authorization = authorize_mcp_mutation(
614
755
  engine,
615
756
  "update",
616
757
  mutation_source="mcp-correct-pattern",
617
- profile_id=engine.profile_id,
758
+ profile_id=pid,
618
759
  fact_id=pattern_id,
619
760
  content_preview=correction,
620
761
  )
621
762
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
622
763
  store = BehavioralPatternStore(engine._db.db_path)
623
764
  store.record(
624
- engine.profile_id,
765
+ pid,
625
766
  pattern_type="correction",
626
767
  pattern_key=pattern_id,
627
768
  metadata={"correction": correction},
@@ -649,6 +790,34 @@ def register_core_tools(server, get_engine: Callable) -> None:
649
790
  from superlocalmemory.mcp.agent_context import get_current_agent_id
650
791
  agent_id = get_current_agent_id()
651
792
  try:
793
+ import asyncio
794
+ import urllib.parse
795
+
796
+ from superlocalmemory.cli.daemon import (
797
+ daemon_request,
798
+ is_daemon_running,
799
+ )
800
+
801
+ if await asyncio.to_thread(is_daemon_running):
802
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
803
+ result = await asyncio.to_thread(
804
+ daemon_request, "DELETE", path,
805
+ )
806
+ if isinstance(result, dict) and result.get("success"):
807
+ _emit_event("memory.deleted", {
808
+ "fact_id": fact_id,
809
+ "agent_id": agent_id,
810
+ }, source_agent=agent_id)
811
+ return {
812
+ "success": True, "deleted": fact_id,
813
+ "agent_id": agent_id,
814
+ }
815
+ return {
816
+ "success": False,
817
+ "retryable": True,
818
+ "error": "resident daemon rejected the delete operation",
819
+ }
820
+
652
821
  from superlocalmemory.core.worker_pool import WorkerPool
653
822
  pool = WorkerPool.shared()
654
823
  result = pool._send({
@@ -691,6 +860,33 @@ def register_core_tools(server, get_engine: Callable) -> None:
691
860
  try:
692
861
  if not content or not content.strip():
693
862
  return {"success": False, "error": "content cannot be empty"}
863
+ import asyncio
864
+ import urllib.parse
865
+
866
+ from superlocalmemory.cli.daemon import (
867
+ daemon_request,
868
+ is_daemon_running,
869
+ )
870
+
871
+ if await asyncio.to_thread(is_daemon_running):
872
+ path = "/api/memories/" + urllib.parse.quote(fact_id, safe="")
873
+ result = await asyncio.to_thread(
874
+ daemon_request,
875
+ "PATCH",
876
+ path,
877
+ {"content": content.strip()},
878
+ )
879
+ if isinstance(result, dict) and result.get("success"):
880
+ return {
881
+ "success": True, "fact_id": fact_id,
882
+ "content": content.strip(),
883
+ }
884
+ return {
885
+ "success": False,
886
+ "retryable": True,
887
+ "error": "resident daemon rejected the update operation",
888
+ }
889
+
694
890
  from superlocalmemory.core.worker_pool import WorkerPool
695
891
  pool = WorkerPool.shared()
696
892
  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
  )
@@ -6,6 +6,23 @@
6
6
  SuperLocalMemory V3 - FastAPI API Server
7
7
  Provides REST endpoints for memory visualization and exploration.
8
8
  Uses V3 MemoryEngine for all operations.
9
+
10
+ v3.7.8 (WS3 F4): ``create_app()`` in this module is a standalone/legacy app
11
+ factory. The running daemon serves ``superlocalmemory.server.unified_daemon:
12
+ create_app`` (see ``unified_daemon.py``'s uvicorn config); THIS factory is
13
+ reachable only via direct ``python -m superlocalmemory.server.api`` /
14
+ programmatic use and the tests that exercise it directly
15
+ (``tests/test_api/test_api_lifespan_contract.py``,
16
+ ``tests/test_security/test_rate_limit_e2e.py``). Its ``auth_middleware``
17
+ below intentionally keeps the older, simpler ``check_api_key``-only gate
18
+ (unconditional per-write check, no daemon-capability / install-token
19
+ identity layer) because it predates and is independent of the unified
20
+ daemon's richer ``write_identity.require_http_mutation_actor`` boundary and
21
+ the ``SLM_REQUIRE_API_KEY_LOOPBACK`` opt-in (see
22
+ ``infra/auth_middleware.py``). Do not treat this module's auth posture as
23
+ the production write boundary -- that is ``unified_daemon.py``'s
24
+ ``auth_middleware``. ``UI_DIR`` below is still imported by
25
+ ``unified_daemon.py`` and must not be removed.
9
26
  """
10
27
 
11
28
  import json