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.
- package/CHANGELOG.md +30 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/pyproject.toml +6 -7
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +171 -11
- package/src/superlocalmemory/cli/setup_wizard.py +18 -1
- package/src/superlocalmemory/infra/auth_middleware.py +33 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_core.py +216 -20
- package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
- package/src/superlocalmemory/optimize/cache/manager.py +7 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
- package/src/superlocalmemory/server/api.py +17 -0
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +12 -6
- package/src/superlocalmemory/server/routes/chat.py +63 -12
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/memories.py +58 -11
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +128 -52
- package/src/superlocalmemory/server/ui.py +10 -0
- package/src/superlocalmemory/server/unified_daemon.py +290 -74
- package/src/superlocalmemory/storage/migration_runner.py +17 -3
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/storage/schema_v32.py +0 -9
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- 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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
584
|
+
from superlocalmemory.cli.daemon import (
|
|
585
|
+
daemon_request,
|
|
586
|
+
is_daemon_running,
|
|
587
|
+
)
|
|
524
588
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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 =
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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__(
|
|
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
|
|
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
|
|
154
|
+
if not self._accept_dimension(vec):
|
|
149
155
|
logger.debug(
|
|
150
|
-
"VCacheSemantic.lookup: skip — embed dim=%
|
|
151
|
-
vec.shape
|
|
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
|
|
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
|
|
455
|
+
if not self._accept_dimension(vec):
|
|
440
456
|
logger.warning(
|
|
441
|
-
"VCacheSemantic._set_inner: unexpected embedding
|
|
442
|
-
"for entry=%s — skipping",
|
|
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": "
|
|
454
|
-
"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
|