superlocalmemory 3.4.3 → 3.4.5

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,249 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the Elastic License 2.0 - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SLM Mesh MCP Tools — P2P agent communication via the unified daemon.
6
+
7
+ v3.4.4: These tools ship WITH SuperLocalMemory, no separate slm-mesh install needed.
8
+ End users get full mesh functionality from `pip install superlocalmemory`.
9
+
10
+ All tools communicate with the daemon's Python mesh broker on port 8765.
11
+ Auto-heartbeat keeps the session alive as long as the MCP server is running.
12
+
13
+ 8 tools: mesh_summary, mesh_peers, mesh_send, mesh_inbox,
14
+ mesh_state, mesh_lock, mesh_events, mesh_status
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from typing import Callable
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Unique peer ID for this MCP server session
30
+ _PEER_ID = str(uuid.uuid4())[:12]
31
+ _SESSION_SUMMARY = ""
32
+ _HEARTBEAT_INTERVAL = 25 # seconds (broker marks stale at 30s, dead at 60s)
33
+ _HEARTBEAT_THREAD: threading.Thread | None = None
34
+ _REGISTERED = False
35
+
36
+
37
+ def _daemon_url() -> str:
38
+ """Get the daemon base URL."""
39
+ port = 8765
40
+ try:
41
+ port_file = os.path.join(os.path.expanduser("~"), ".superlocalmemory", "daemon.port")
42
+ if os.path.exists(port_file):
43
+ port = int(open(port_file).read().strip())
44
+ except Exception:
45
+ pass
46
+ return f"http://127.0.0.1:{port}"
47
+
48
+
49
+ def _mesh_request(method: str, path: str, body: dict | None = None) -> dict | None:
50
+ """Send request to daemon mesh broker."""
51
+ import urllib.request
52
+ url = f"{_daemon_url()}/mesh{path}"
53
+ try:
54
+ data = json.dumps(body).encode() if body else None
55
+ headers = {"Content-Type": "application/json"} if data else {}
56
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
57
+ resp = urllib.request.urlopen(req, timeout=10)
58
+ return json.loads(resp.read().decode())
59
+ except Exception as exc:
60
+ logger.debug("Mesh request failed: %s %s — %s", method, path, exc)
61
+ return None
62
+
63
+
64
+ def _ensure_registered() -> None:
65
+ """Register this session with the mesh broker if not already."""
66
+ global _REGISTERED
67
+ if _REGISTERED:
68
+ return
69
+
70
+ result = _mesh_request("POST", "/register", {
71
+ "peer_id": _PEER_ID,
72
+ "session_id": os.environ.get("CLAUDE_SESSION_ID", _PEER_ID),
73
+ "summary": _SESSION_SUMMARY or "SLM MCP session",
74
+ })
75
+ if result:
76
+ _REGISTERED = True
77
+ _start_heartbeat()
78
+
79
+
80
+ def _start_heartbeat() -> None:
81
+ """Background thread that sends heartbeat to keep session alive."""
82
+ global _HEARTBEAT_THREAD
83
+ if _HEARTBEAT_THREAD is not None:
84
+ return
85
+
86
+ def heartbeat_loop():
87
+ while True:
88
+ time.sleep(_HEARTBEAT_INTERVAL)
89
+ try:
90
+ _mesh_request("POST", "/heartbeat", {"peer_id": _PEER_ID})
91
+ except Exception:
92
+ pass
93
+
94
+ _HEARTBEAT_THREAD = threading.Thread(target=heartbeat_loop, daemon=True, name="mesh-heartbeat")
95
+ _HEARTBEAT_THREAD.start()
96
+ logger.info("Mesh heartbeat started (peer_id=%s, interval=%ds)", _PEER_ID, _HEARTBEAT_INTERVAL)
97
+
98
+
99
+ def register_mesh_tools(server, get_engine: Callable) -> None:
100
+ """Register all 8 mesh MCP tools."""
101
+
102
+ @server.tool()
103
+ async def mesh_summary(summary: str = "") -> dict:
104
+ """Register this session and describe what you're working on.
105
+
106
+ Call this at the start of every session. Other agents can see your summary
107
+ and send you messages. The session stays alive via automatic heartbeat.
108
+
109
+ Args:
110
+ summary: What this session is working on (e.g. "Fixing auth bug in api.py")
111
+ """
112
+ global _SESSION_SUMMARY
113
+ _SESSION_SUMMARY = summary or "Active session"
114
+
115
+ _ensure_registered()
116
+
117
+ # Update summary
118
+ result = _mesh_request("POST", "/summary", {
119
+ "peer_id": _PEER_ID,
120
+ "summary": _SESSION_SUMMARY,
121
+ })
122
+
123
+ return {
124
+ "peer_id": _PEER_ID,
125
+ "summary": _SESSION_SUMMARY,
126
+ "registered": True,
127
+ "heartbeat_active": _HEARTBEAT_THREAD is not None,
128
+ "broker_response": result,
129
+ }
130
+
131
+ @server.tool()
132
+ async def mesh_peers() -> dict:
133
+ """List all active peer sessions on this machine.
134
+
135
+ Shows other Claude Code, Cursor, or AI agent sessions that are
136
+ connected to the same SLM mesh network.
137
+ """
138
+ _ensure_registered()
139
+ result = _mesh_request("GET", "/peers")
140
+ peers = (result or {}).get("peers", [])
141
+ return {
142
+ "peers": peers,
143
+ "count": len(peers),
144
+ "my_peer_id": _PEER_ID,
145
+ }
146
+
147
+ @server.tool()
148
+ async def mesh_send(to: str, message: str) -> dict:
149
+ """Send a message to another peer session.
150
+
151
+ Args:
152
+ to: The peer_id of the recipient (from mesh_peers)
153
+ message: The message content to send
154
+ """
155
+ _ensure_registered()
156
+ result = _mesh_request("POST", "/send", {
157
+ "from_peer": _PEER_ID,
158
+ "to_peer": to,
159
+ "content": message,
160
+ })
161
+ return result or {"error": "Failed to send message"}
162
+
163
+ @server.tool()
164
+ async def mesh_inbox() -> dict:
165
+ """Read messages sent to this session.
166
+
167
+ Returns unread messages from other peer sessions.
168
+ Messages are marked as read after retrieval.
169
+ """
170
+ _ensure_registered()
171
+ messages = _mesh_request("GET", f"/inbox/{_PEER_ID}")
172
+ if messages:
173
+ # Mark as read
174
+ _mesh_request("POST", f"/inbox/{_PEER_ID}/read")
175
+ return messages or {"messages": [], "count": 0}
176
+
177
+ @server.tool()
178
+ async def mesh_state(key: str = "", value: str = "", action: str = "get") -> dict:
179
+ """Get or set shared state across all sessions.
180
+
181
+ Shared state is visible to all peers. Use for coordinating work:
182
+ server IPs, API keys, feature flags, task assignments.
183
+
184
+ Args:
185
+ key: State key name
186
+ value: Value to set (only for action="set")
187
+ action: "get" (read all or one key), "set" (write a key)
188
+ """
189
+ _ensure_registered()
190
+
191
+ if action == "set" and key:
192
+ result = _mesh_request("POST", "/state", {
193
+ "key": key,
194
+ "value": value,
195
+ "set_by": _PEER_ID,
196
+ })
197
+ return result or {"error": "Failed to set state"}
198
+
199
+ if key:
200
+ result = _mesh_request("GET", f"/state/{key}")
201
+ return result or {"key": key, "value": None}
202
+
203
+ result = _mesh_request("GET", "/state")
204
+ return result or {"state": {}}
205
+
206
+ @server.tool()
207
+ async def mesh_lock(
208
+ file_path: str,
209
+ action: str = "query",
210
+ ) -> dict:
211
+ """Manage file locks across sessions.
212
+
213
+ Before editing a shared file, check if another session has it locked.
214
+
215
+ Args:
216
+ file_path: Path to the file
217
+ action: "query" (check lock), "acquire" (lock file), "release" (unlock)
218
+ """
219
+ _ensure_registered()
220
+ result = _mesh_request("POST", "/lock", {
221
+ "file_path": file_path,
222
+ "action": action,
223
+ "locked_by": _PEER_ID,
224
+ })
225
+ return result or {"error": "Lock operation failed"}
226
+
227
+ @server.tool()
228
+ async def mesh_events() -> dict:
229
+ """Get recent mesh events (peer joins, leaves, messages, state changes).
230
+
231
+ Shows the activity log of the mesh network.
232
+ """
233
+ result = _mesh_request("GET", "/events")
234
+ return result or {"events": []}
235
+
236
+ @server.tool()
237
+ async def mesh_status() -> dict:
238
+ """Get mesh broker health and statistics.
239
+
240
+ Shows broker uptime, peer count, and connection status.
241
+ """
242
+ result = _mesh_request("GET", "/status")
243
+ if result:
244
+ result["my_peer_id"] = _PEER_ID
245
+ result["heartbeat_active"] = _HEARTBEAT_THREAD is not None
246
+ return result or {
247
+ "broker_up": False,
248
+ "error": "Cannot reach mesh broker. Is the daemon running? (slm serve start)",
249
+ }
@@ -0,0 +1,63 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the Elastic License 2.0 - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Ingestion adapter management API — enable/disable/start/stop from dashboard.
6
+
7
+ v3.4.4: Users can manage Gmail, Calendar, Transcript adapters entirely from
8
+ the dashboard UI — no CLI needed. The best product experience.
9
+
10
+ Endpoints:
11
+ GET /api/adapters — list all adapters with status
12
+ POST /api/adapters/enable — enable an adapter
13
+ POST /api/adapters/disable — disable an adapter
14
+ POST /api/adapters/start — start a running adapter
15
+ POST /api/adapters/stop — stop a running adapter
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from fastapi import APIRouter, Request
21
+ from pydantic import BaseModel
22
+
23
+ router = APIRouter(tags=["adapters"])
24
+
25
+
26
+ class AdapterAction(BaseModel):
27
+ name: str
28
+
29
+
30
+ @router.get("/api/adapters")
31
+ async def list_adapters_api():
32
+ """List all adapters with their enabled/running status."""
33
+ from superlocalmemory.ingestion.adapter_manager import list_adapters
34
+ adapters = list_adapters()
35
+ return {"adapters": adapters}
36
+
37
+
38
+ @router.post("/api/adapters/enable")
39
+ async def enable_adapter_api(body: AdapterAction):
40
+ """Enable an adapter (doesn't start it yet)."""
41
+ from superlocalmemory.ingestion.adapter_manager import enable_adapter
42
+ return enable_adapter(body.name)
43
+
44
+
45
+ @router.post("/api/adapters/disable")
46
+ async def disable_adapter_api(body: AdapterAction):
47
+ """Disable and stop an adapter."""
48
+ from superlocalmemory.ingestion.adapter_manager import disable_adapter
49
+ return disable_adapter(body.name)
50
+
51
+
52
+ @router.post("/api/adapters/start")
53
+ async def start_adapter_api(body: AdapterAction):
54
+ """Start a running adapter subprocess."""
55
+ from superlocalmemory.ingestion.adapter_manager import start_adapter
56
+ return start_adapter(body.name)
57
+
58
+
59
+ @router.post("/api/adapters/stop")
60
+ async def stop_adapter_api(body: AdapterAction):
61
+ """Stop a running adapter."""
62
+ from superlocalmemory.ingestion.adapter_manager import stop_adapter
63
+ return stop_adapter(body.name)
@@ -11,6 +11,62 @@ from fastapi import APIRouter, HTTPException, Request, Query
11
11
  router = APIRouter(prefix="/api/entity", tags=["entity"])
12
12
 
13
13
 
14
+ @router.get("/list")
15
+ async def list_entities(
16
+ request: Request,
17
+ profile: str = Query(default="default"),
18
+ limit: int = Query(default=100, ge=1, le=1000),
19
+ offset: int = Query(default=0, ge=0),
20
+ ):
21
+ """List all entities with basic info (canonical name, type, fact count)."""
22
+ engine = request.app.state.engine
23
+ if engine is None:
24
+ raise HTTPException(503, detail="Engine not initialized")
25
+
26
+ import sqlite3
27
+ import json
28
+ conn = sqlite3.connect(str(engine._config.db_path))
29
+ conn.row_factory = sqlite3.Row
30
+ try:
31
+ total = conn.execute(
32
+ "SELECT COUNT(*) FROM canonical_entities WHERE profile_id = ?",
33
+ (profile,),
34
+ ).fetchone()[0]
35
+
36
+ rows = conn.execute("""
37
+ SELECT ce.entity_id, ce.canonical_name, ce.entity_type,
38
+ ce.fact_count, ce.first_seen, ce.last_seen,
39
+ ep.knowledge_summary, ep.compiled_truth,
40
+ ep.compilation_confidence, ep.last_compiled_at
41
+ FROM canonical_entities ce
42
+ LEFT JOIN entity_profiles ep
43
+ ON ce.entity_id = ep.entity_id AND ep.profile_id = ce.profile_id
44
+ WHERE ce.profile_id = ?
45
+ ORDER BY ce.fact_count DESC
46
+ LIMIT ? OFFSET ?
47
+ """, (profile, limit, offset)).fetchall()
48
+
49
+ entities = []
50
+ for r in rows:
51
+ summary = r["knowledge_summary"] or ""
52
+ entities.append({
53
+ "entity_id": r["entity_id"],
54
+ "name": r["canonical_name"],
55
+ "type": r["entity_type"] or "unknown",
56
+ "fact_count": r["fact_count"] or 0,
57
+ "first_seen": r["first_seen"],
58
+ "last_seen": r["last_seen"],
59
+ "summary_preview": summary[:200] if summary else "",
60
+ "has_compiled_truth": bool(r["compiled_truth"]),
61
+ "confidence": r["compilation_confidence"] or 0.5,
62
+ "last_compiled_at": r["last_compiled_at"],
63
+ })
64
+
65
+ return {"entities": entities, "total": total, "limit": limit, "offset": offset}
66
+ finally:
67
+ conn.close()
68
+
69
+
14
70
  @router.get("/{entity_name}")
15
71
  async def get_entity(
16
72
  entity_name: str,
@@ -465,6 +465,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
465
465
  from superlocalmemory.server.routes.agents import router as agents_router
466
466
  from superlocalmemory.server.routes.ws import router as ws_router, manager as ws_manager
467
467
  from superlocalmemory.server.routes.v3_api import router as v3_router
468
+ from superlocalmemory.server.routes.adapters import router as adapters_router
468
469
 
469
470
  application.include_router(memories_router)
470
471
  application.include_router(stats_router)
@@ -475,6 +476,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
475
476
  application.include_router(agents_router)
476
477
  application.include_router(ws_router)
477
478
  application.include_router(v3_router)
479
+ application.include_router(adapters_router)
478
480
 
479
481
  # v3.4.1 chat SSE
480
482
  for _mod_name in ("chat",):