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.
- package/CHANGELOG.md +27 -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/skills/slm-recall/SKILL.md +4 -3
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +4 -3
- package/pyproject.toml +6 -6
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +169 -9
- package/src/superlocalmemory/cli/setup_wizard.py +53 -2
- package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
- package/src/superlocalmemory/core/config.py +1 -1
- package/src/superlocalmemory/core/engine.py +3 -2
- package/src/superlocalmemory/core/engine_wiring.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +9 -1
- package/src/superlocalmemory/core/store_pipeline.py +1 -1
- package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -1
- package/src/superlocalmemory/infra/auth_middleware.py +5 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_active.py +11 -7
- package/src/superlocalmemory/mcp/tools_core.py +178 -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/retrieval/engine.py +16 -8
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +13 -7
- package/src/superlocalmemory/server/routes/chat.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +97 -20
- package/src/superlocalmemory/server/unified_daemon.py +261 -60
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- package/src/superlocalmemory/ui/js/memory-chat.js +2 -2
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
- 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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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": "
|
|
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
|
|
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
|
|
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:
|
|
190
|
-
spreading_activation + hopfield)
|
|
191
|
-
|
|
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
|
|
219
|
-
# + Hopfield + spreading-activation
|
|
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(
|
|
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,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
|
-
|
|
584
|
+
from superlocalmemory.cli.daemon import (
|
|
585
|
+
daemon_request,
|
|
586
|
+
is_daemon_running,
|
|
587
|
+
)
|
|
524
588
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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 =
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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__(
|
|
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
|
)
|
|
@@ -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 —
|
|
5
|
+
"""SuperLocalMemory V3 — retrieval orchestration.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
"""
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|