superlocalmemory 3.4.3 → 3.4.4
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/README.md +9 -12
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +29 -0
- package/src/superlocalmemory/cli/daemon.py +128 -68
- package/src/superlocalmemory/cli/main.py +15 -2
- package/src/superlocalmemory/cli/service_installer.py +367 -0
- package/src/superlocalmemory/cli/setup_wizard.py +13 -0
- package/src/superlocalmemory/mcp/server.py +32 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +249 -0
- package/src/superlocalmemory/server/routes/adapters.py +63 -0
- package/src/superlocalmemory/server/routes/entity.py +56 -0
- package/src/superlocalmemory/server/unified_daemon.py +2 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +1588 -0
- package/src/superlocalmemory/ui/index.html +134 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +28 -1
- package/src/superlocalmemory/ui/js/ng-entities.js +272 -0
- package/src/superlocalmemory/ui/js/ng-health.js +208 -0
- package/src/superlocalmemory/ui/js/ng-ingestion.js +203 -0
- package/src/superlocalmemory/ui/js/ng-mesh.js +311 -0
- package/src/superlocalmemory/ui/js/ng-shell.js +471 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +601 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +313 -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 +55 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -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",):
|