superlocalmemory 3.4.45 → 3.4.48
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 +61 -1
- package/README.md +62 -1
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/superlocalmemory/__init__.py +13 -2
- package/src/superlocalmemory/core/recall_worker.py +5 -3
- package/src/superlocalmemory/core/worker_pool.py +2 -1
- package/src/superlocalmemory/llm/backbone.py +12 -1
- package/src/superlocalmemory/mcp/_daemon_proxy.py +7 -3
- package/src/superlocalmemory/mcp/_pool_adapter.py +7 -2
- package/src/superlocalmemory/mcp/server.py +21 -3
- package/src/superlocalmemory/mcp/tools_active.py +31 -18
- package/src/superlocalmemory/mcp/tools_core.py +5 -6
- package/src/superlocalmemory/mcp/tools_v3.py +2 -2
- package/src/superlocalmemory/mesh/broker.py +64 -1
- package/src/superlocalmemory/mesh/remote_sync.py +266 -0
- package/src/superlocalmemory/server/routes/mesh.py +14 -1
- package/src/superlocalmemory/server/unified_daemon.py +2 -2
- package/src/superlocalmemory/storage/database.py +1 -1
- package/src/superlocalmemory/storage/schema.py +3 -3
- package/src/superlocalmemory.egg-info/PKG-INFO +771 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +457 -0
- package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
- package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
- package/src/superlocalmemory.egg-info/requires.txt +65 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM Mesh — Remote Sync Client.
|
|
6
|
+
|
|
7
|
+
HTTP-based synchronization with a remote SLM instance.
|
|
8
|
+
Populates broker._remote_peers from the remote /mesh/peers endpoint.
|
|
9
|
+
Proxies mesh_send to remote when the target peer lives on the remote machine.
|
|
10
|
+
Optional mDNS discovery via zeroconf.
|
|
11
|
+
|
|
12
|
+
Environment variables:
|
|
13
|
+
SLM_MESH_PEER_URL: Full URL of remote SLM (e.g. http://192.168.1.100:8765)
|
|
14
|
+
SLM_MESH_SHARED_SECRET: Shared auth secret for remote SLM
|
|
15
|
+
SLM_MESH_DISCOVERY: 'on'|'off' (default 'on') — enable mDNS discovery
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("superlocalmemory.mesh.remote_sync")
|
|
29
|
+
|
|
30
|
+
# Optional zeroconf for mDNS discovery
|
|
31
|
+
try:
|
|
32
|
+
from zeroconf import ServiceBrowser, ServiceInfo, Zeroconf
|
|
33
|
+
ZEROCONF_AVAILABLE = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
ZEROCONF_AVAILABLE = False
|
|
36
|
+
Zeroconf = None
|
|
37
|
+
ServiceBrowser = None
|
|
38
|
+
ServiceInfo = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RemoteSyncClient:
|
|
42
|
+
"""HTTP-based sync client for multi-machine mesh coordination.
|
|
43
|
+
|
|
44
|
+
Syncs remote peers from a peer SLM instance periodically.
|
|
45
|
+
Proxies mesh_send to remote when target peer lives on remote machine.
|
|
46
|
+
Optionally discovers remote SLM via mDNS.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, broker: Any) -> None:
|
|
50
|
+
"""Initialize sync client.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
broker: Reference to MeshBroker instance
|
|
54
|
+
"""
|
|
55
|
+
self._broker = broker
|
|
56
|
+
self._peer_url: str | None = os.environ.get("SLM_MESH_PEER_URL") or None
|
|
57
|
+
self._shared_secret: str | None = os.environ.get("SLM_MESH_SHARED_SECRET") or None
|
|
58
|
+
self._discovery_enabled: bool = (
|
|
59
|
+
os.environ.get("SLM_MESH_DISCOVERY", "on") != "off"
|
|
60
|
+
)
|
|
61
|
+
self._sync_thread: threading.Thread | None = None
|
|
62
|
+
self._discovery_thread: threading.Thread | None = None
|
|
63
|
+
self._stop_event = threading.Event()
|
|
64
|
+
self._zeroconf: Zeroconf | None = None
|
|
65
|
+
self._last_peers: dict[str, dict] = {}
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
"""Start background sync and discovery threads."""
|
|
69
|
+
if not self._peer_url and not self._discovery_enabled:
|
|
70
|
+
logger.debug(
|
|
71
|
+
"RemoteSyncClient: no peer URL and discovery disabled, skipping"
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Start sync thread
|
|
76
|
+
self._sync_thread = threading.Thread(
|
|
77
|
+
target=self._sync_loop, daemon=True, name="mesh-remote-sync"
|
|
78
|
+
)
|
|
79
|
+
self._sync_thread.start()
|
|
80
|
+
|
|
81
|
+
# Start discovery thread if enabled and zeroconf available
|
|
82
|
+
if self._discovery_enabled and ZEROCONF_AVAILABLE:
|
|
83
|
+
self._discovery_thread = threading.Thread(
|
|
84
|
+
target=self._discovery_loop, daemon=True, name="mesh-mdns-discovery"
|
|
85
|
+
)
|
|
86
|
+
self._discovery_thread.start()
|
|
87
|
+
logger.info("RemoteSyncClient: mDNS discovery enabled")
|
|
88
|
+
elif self._discovery_enabled and not ZEROCONF_AVAILABLE:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"RemoteSyncClient: mDNS discovery requested but zeroconf not available"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def stop(self) -> None:
|
|
94
|
+
"""Stop background threads cleanly."""
|
|
95
|
+
self._stop_event.set()
|
|
96
|
+
if self._zeroconf:
|
|
97
|
+
try:
|
|
98
|
+
self._zeroconf.close()
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.debug("RemoteSyncClient: error closing zeroconf: %s", e)
|
|
101
|
+
# Wait for threads to finish (up to 2s)
|
|
102
|
+
if self._sync_thread:
|
|
103
|
+
self._sync_thread.join(timeout=2)
|
|
104
|
+
if self._discovery_thread:
|
|
105
|
+
self._discovery_thread.join(timeout=2)
|
|
106
|
+
|
|
107
|
+
def _sync_loop(self) -> None:
|
|
108
|
+
"""Background thread: sync remote peers every 30s."""
|
|
109
|
+
while not self._stop_event.is_set():
|
|
110
|
+
try:
|
|
111
|
+
if self._peer_url:
|
|
112
|
+
self._sync_peers_from_remote()
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.debug("RemoteSyncClient: sync error: %s", exc)
|
|
115
|
+
|
|
116
|
+
# Wait 30s before next sync
|
|
117
|
+
if self._stop_event.wait(30):
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
def _sync_peers_from_remote(self) -> None:
|
|
121
|
+
"""Fetch peers from remote /mesh/peers and update broker."""
|
|
122
|
+
if not self._peer_url:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
with httpx.Client(timeout=5) as client:
|
|
127
|
+
headers = {}
|
|
128
|
+
if self._shared_secret:
|
|
129
|
+
headers["Authorization"] = f"Bearer {self._shared_secret}"
|
|
130
|
+
|
|
131
|
+
resp = client.get(
|
|
132
|
+
f"{self._peer_url}/mesh/peers", headers=headers, timeout=5
|
|
133
|
+
)
|
|
134
|
+
resp.raise_for_status()
|
|
135
|
+
|
|
136
|
+
data = resp.json()
|
|
137
|
+
remote_peers = data.get("peers", [])
|
|
138
|
+
|
|
139
|
+
# Convert list to dict by peer_id
|
|
140
|
+
current = {p.get("peer_id"): p for p in remote_peers}
|
|
141
|
+
|
|
142
|
+
# Add/update peers
|
|
143
|
+
for peer_id, peer_info in current.items():
|
|
144
|
+
self._broker.add_remote_peer(peer_id, peer_info)
|
|
145
|
+
|
|
146
|
+
# Remove stale peers (ones that disappeared from remote)
|
|
147
|
+
for peer_id in list(self._last_peers.keys()):
|
|
148
|
+
if peer_id not in current:
|
|
149
|
+
self._broker.remove_remote_peer(peer_id)
|
|
150
|
+
|
|
151
|
+
self._last_peers = current
|
|
152
|
+
logger.debug(
|
|
153
|
+
"RemoteSyncClient: synced %d remote peers from %s",
|
|
154
|
+
len(current),
|
|
155
|
+
self._peer_url,
|
|
156
|
+
)
|
|
157
|
+
except httpx.RequestError as e:
|
|
158
|
+
logger.debug("RemoteSyncClient: HTTP error during sync: %s", e)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.debug("RemoteSyncClient: unexpected error during sync: %s", e)
|
|
161
|
+
|
|
162
|
+
def send_to_remote(self, to_peer: str, message_data: dict) -> dict:
|
|
163
|
+
"""Proxy mesh_send to remote /mesh/send endpoint.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
to_peer: Target peer ID on remote machine
|
|
167
|
+
message_data: Dict with from_peer, content, type, etc.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dict with {"ok": True, ...} or {"ok": False, "error": "..."}
|
|
171
|
+
"""
|
|
172
|
+
if not self._peer_url:
|
|
173
|
+
return {"ok": False, "error": "no remote peer URL configured"}
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with httpx.Client(timeout=10) as client:
|
|
177
|
+
headers = {}
|
|
178
|
+
if self._shared_secret:
|
|
179
|
+
headers["Authorization"] = f"Bearer {self._shared_secret}"
|
|
180
|
+
|
|
181
|
+
payload = {
|
|
182
|
+
"from_peer": message_data.get("from_peer", ""),
|
|
183
|
+
"to_peer": to_peer,
|
|
184
|
+
"content": message_data.get("content", ""),
|
|
185
|
+
"type": message_data.get("type", "text"),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
resp = client.post(
|
|
189
|
+
f"{self._peer_url}/mesh/send",
|
|
190
|
+
json=payload,
|
|
191
|
+
headers=headers,
|
|
192
|
+
timeout=10,
|
|
193
|
+
)
|
|
194
|
+
resp.raise_for_status()
|
|
195
|
+
return resp.json()
|
|
196
|
+
except httpx.RequestError as e:
|
|
197
|
+
logger.debug(
|
|
198
|
+
"RemoteSyncClient: HTTP error sending to remote peer %s: %s",
|
|
199
|
+
to_peer,
|
|
200
|
+
e,
|
|
201
|
+
)
|
|
202
|
+
return {"ok": False, "error": f"remote send failed: {e}"}
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.debug(
|
|
205
|
+
"RemoteSyncClient: unexpected error sending to remote peer %s: %s",
|
|
206
|
+
to_peer,
|
|
207
|
+
e,
|
|
208
|
+
)
|
|
209
|
+
return {"ok": False, "error": f"remote send error: {e}"}
|
|
210
|
+
|
|
211
|
+
def _discovery_loop(self) -> None:
|
|
212
|
+
"""Background thread: discover remote SLM via mDNS."""
|
|
213
|
+
if not ZEROCONF_AVAILABLE:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
self._zeroconf = Zeroconf()
|
|
218
|
+
ServiceBrowser(self._zeroconf, "_slm-mesh._tcp.local.", self)
|
|
219
|
+
logger.info("RemoteSyncClient: mDNS browser started")
|
|
220
|
+
|
|
221
|
+
# Keep thread alive
|
|
222
|
+
while not self._stop_event.is_set():
|
|
223
|
+
time.sleep(1)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.debug("RemoteSyncClient: mDNS discovery error: %s", e)
|
|
226
|
+
finally:
|
|
227
|
+
if self._zeroconf:
|
|
228
|
+
try:
|
|
229
|
+
self._zeroconf.close()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
def add_service(self, zeroconf: Any, service_type: str, name: str) -> None:
|
|
234
|
+
"""Zeroconf callback: service discovered."""
|
|
235
|
+
try:
|
|
236
|
+
if not ZEROCONF_AVAILABLE:
|
|
237
|
+
return
|
|
238
|
+
info = zeroconf.get_service_info(service_type, name)
|
|
239
|
+
if info and info.addresses:
|
|
240
|
+
# Get first IPv4 address
|
|
241
|
+
for addr in info.addresses:
|
|
242
|
+
if isinstance(addr, str) and "." in addr: # IPv4
|
|
243
|
+
port = info.port or 8765
|
|
244
|
+
peer_url = f"http://{addr}:{port}"
|
|
245
|
+
self._update_peer_url(addr, port)
|
|
246
|
+
logger.info(
|
|
247
|
+
"RemoteSyncClient: discovered SLM at %s", peer_url
|
|
248
|
+
)
|
|
249
|
+
return
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.debug("RemoteSyncClient: mDNS add_service error: %s", e)
|
|
252
|
+
|
|
253
|
+
def remove_service(self, zeroconf: Any, service_type: str, name: str) -> None:
|
|
254
|
+
"""Zeroconf callback: service disappeared."""
|
|
255
|
+
logger.debug("RemoteSyncClient: service removed: %s", name)
|
|
256
|
+
|
|
257
|
+
def update_service(self, zeroconf: Any, service_type: str, name: str) -> None:
|
|
258
|
+
"""Zeroconf callback: service updated."""
|
|
259
|
+
self.add_service(zeroconf, service_type, name)
|
|
260
|
+
|
|
261
|
+
def _update_peer_url(self, host: str, port: int) -> None:
|
|
262
|
+
"""Update peer URL from discovery."""
|
|
263
|
+
new_url = f"http://{host}:{port}"
|
|
264
|
+
if self._peer_url != new_url:
|
|
265
|
+
self._peer_url = new_url
|
|
266
|
+
logger.info("RemoteSyncClient: updated peer URL to %s", new_url)
|
|
@@ -80,6 +80,18 @@ def _get_broker(request: Request):
|
|
|
80
80
|
return broker
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _validate_remote_auth(request: Request, broker) -> None:
|
|
84
|
+
"""Validate bearer token for cross-machine requests."""
|
|
85
|
+
if not broker._is_remote:
|
|
86
|
+
return # local mode — no auth needed
|
|
87
|
+
secret = broker._shared_secret
|
|
88
|
+
if not secret:
|
|
89
|
+
return
|
|
90
|
+
auth = request.headers.get("Authorization", "")
|
|
91
|
+
if auth != f"Bearer {secret}":
|
|
92
|
+
raise HTTPException(401, detail="Unauthorized")
|
|
93
|
+
|
|
94
|
+
|
|
83
95
|
# -- Routes --
|
|
84
96
|
|
|
85
97
|
@router.post("/register")
|
|
@@ -105,7 +117,8 @@ async def deregister(req: DeregisterRequest, request: Request):
|
|
|
105
117
|
@router.get("/peers")
|
|
106
118
|
async def peers(request: Request):
|
|
107
119
|
broker = _get_broker(request)
|
|
108
|
-
|
|
120
|
+
_validate_remote_auth(request, broker)
|
|
121
|
+
return {"peers": broker.list_all_peers()}
|
|
109
122
|
|
|
110
123
|
|
|
111
124
|
@router.post("/heartbeat")
|
|
@@ -535,8 +535,8 @@ async def lifespan(application: FastAPI):
|
|
|
535
535
|
application.state.queue_consumer = None
|
|
536
536
|
application.state.recall_queue = None
|
|
537
537
|
|
|
538
|
-
except Exception
|
|
539
|
-
logger.
|
|
538
|
+
except Exception:
|
|
539
|
+
logger.exception("Engine init failed") # auto-includes traceback
|
|
540
540
|
application.state.engine = None
|
|
541
541
|
application.state.config = None
|
|
542
542
|
|
|
@@ -66,8 +66,8 @@ class DatabaseManager:
|
|
|
66
66
|
def _enable_wal(self) -> None:
|
|
67
67
|
conn = sqlite3.connect(str(self.db_path))
|
|
68
68
|
try:
|
|
69
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # FIRST — so WAL pragma below uses configured timeout
|
|
69
70
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
70
|
-
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
71
71
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
72
72
|
conn.commit()
|
|
73
73
|
finally:
|
|
@@ -252,7 +252,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS atomic_facts_fts
|
|
|
252
252
|
-- left by V2 migration.
|
|
253
253
|
|
|
254
254
|
-- INSERT trigger
|
|
255
|
-
CREATE TRIGGER atomic_facts_fts_insert
|
|
255
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_insert
|
|
256
256
|
AFTER INSERT ON atomic_facts
|
|
257
257
|
BEGIN
|
|
258
258
|
INSERT INTO atomic_facts_fts (rowid, fact_id, content)
|
|
@@ -260,7 +260,7 @@ BEGIN
|
|
|
260
260
|
END;
|
|
261
261
|
|
|
262
262
|
-- DELETE trigger
|
|
263
|
-
CREATE TRIGGER atomic_facts_fts_delete
|
|
263
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_delete
|
|
264
264
|
AFTER DELETE ON atomic_facts
|
|
265
265
|
BEGIN
|
|
266
266
|
INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content)
|
|
@@ -268,7 +268,7 @@ BEGIN
|
|
|
268
268
|
END;
|
|
269
269
|
|
|
270
270
|
-- UPDATE trigger
|
|
271
|
-
CREATE TRIGGER atomic_facts_fts_update
|
|
271
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_update
|
|
272
272
|
AFTER UPDATE OF content ON atomic_facts
|
|
273
273
|
BEGIN
|
|
274
274
|
INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content)
|