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.
@@ -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
- return {"peers": broker.list_peers()}
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 as exc:
539
- logger.warning("Engine init failed: %s", exc)
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)