nexo-brain 0.1.2 → 0.2.0
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 +61 -0
- package/bin/nexo-brain 2.js +610 -0
- package/package.json +4 -2
- package/scripts/pre-commit-check 2.sh +55 -0
- package/src/cognitive 2.py +1224 -0
- package/src/db 2.py +2283 -0
- package/src/plugin_loader 2.py +136 -0
- package/src/server 2.py +560 -0
- package/src/tools_coordination 2.py +102 -0
- package/src/tools_credentials 2.py +64 -0
- package/src/tools_learnings 2.py +180 -0
- package/src/tools_menu 2.py +208 -0
- package/src/tools_reminders 2.py +80 -0
- package/src/tools_reminders_crud 2.py +157 -0
- package/src/tools_sessions 2.py +169 -0
- package/src/tools_task_history 2.py +57 -0
- package/templates/CLAUDE.md 2.template +89 -0
- package/templates/openclaw.json +13 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Dynamic plugin loader for NEXO MCP server."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from db import get_db
|
|
10
|
+
from fastmcp.tools import Tool
|
|
11
|
+
|
|
12
|
+
PLUGINS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plugins")
|
|
13
|
+
|
|
14
|
+
PLUGIN_LOAD_TIMEOUT = 10 # seconds per plugin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _PluginTimeout(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _timeout_handler(signum, frame):
|
|
22
|
+
raise _PluginTimeout("Plugin loading timed out")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_all_plugins(mcp) -> int:
|
|
26
|
+
"""Load all plugins from plugins/ directory at startup. Returns total tools loaded."""
|
|
27
|
+
if not os.path.isdir(PLUGINS_DIR):
|
|
28
|
+
return 0
|
|
29
|
+
total = 0
|
|
30
|
+
for f in sorted(os.listdir(PLUGINS_DIR)):
|
|
31
|
+
if f.endswith(".py") and f != "__init__.py":
|
|
32
|
+
try:
|
|
33
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
|
34
|
+
signal.alarm(PLUGIN_LOAD_TIMEOUT)
|
|
35
|
+
try:
|
|
36
|
+
n = load_plugin(mcp, f)
|
|
37
|
+
total += n
|
|
38
|
+
finally:
|
|
39
|
+
signal.alarm(0)
|
|
40
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
41
|
+
except _PluginTimeout:
|
|
42
|
+
print(f"[PLUGIN TIMEOUT] {f}: skipped after {PLUGIN_LOAD_TIMEOUT}s", file=sys.stderr)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"[PLUGIN ERROR] {f}: {e}", file=sys.stderr)
|
|
45
|
+
return total
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_plugin(mcp, filename: str) -> int:
|
|
49
|
+
"""Load or reload a single plugin. Returns number of tools registered."""
|
|
50
|
+
if not filename.endswith(".py"):
|
|
51
|
+
filename += ".py"
|
|
52
|
+
|
|
53
|
+
filepath = os.path.join(PLUGINS_DIR, filename)
|
|
54
|
+
if not os.path.isfile(filepath):
|
|
55
|
+
raise FileNotFoundError(f"Plugin not found: {filepath}")
|
|
56
|
+
|
|
57
|
+
module_name = f"plugins.{filename[:-3]}"
|
|
58
|
+
|
|
59
|
+
if module_name in sys.modules:
|
|
60
|
+
mod = importlib.reload(sys.modules[module_name])
|
|
61
|
+
else:
|
|
62
|
+
mod = importlib.import_module(module_name)
|
|
63
|
+
|
|
64
|
+
tools_list = getattr(mod, "TOOLS", [])
|
|
65
|
+
tool_names = []
|
|
66
|
+
|
|
67
|
+
for func, name, description in tools_list:
|
|
68
|
+
try:
|
|
69
|
+
mcp.local_provider.remove_tool(name)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
t = Tool.from_function(func, name=name, description=description)
|
|
73
|
+
mcp.add_tool(t)
|
|
74
|
+
tool_names.append(name)
|
|
75
|
+
|
|
76
|
+
_update_registry(filename, len(tool_names), ",".join(tool_names), "manual")
|
|
77
|
+
|
|
78
|
+
return len(tool_names)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def remove_plugin(mcp, filename: str) -> list[str]:
|
|
82
|
+
"""Remove a plugin: unregister its tools, delete file, clean registry."""
|
|
83
|
+
if not filename.endswith(".py"):
|
|
84
|
+
filename += ".py"
|
|
85
|
+
|
|
86
|
+
conn = get_db()
|
|
87
|
+
row = conn.execute("SELECT tool_names FROM plugins WHERE filename = ?", (filename,)).fetchone()
|
|
88
|
+
|
|
89
|
+
removed = []
|
|
90
|
+
if row and row["tool_names"]:
|
|
91
|
+
for name in row["tool_names"].split(","):
|
|
92
|
+
name = name.strip()
|
|
93
|
+
if name:
|
|
94
|
+
try:
|
|
95
|
+
mcp.local_provider.remove_tool(name)
|
|
96
|
+
removed.append(name)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
module_name = f"plugins.{filename[:-3]}"
|
|
101
|
+
sys.modules.pop(module_name, None)
|
|
102
|
+
|
|
103
|
+
filepath = os.path.join(PLUGINS_DIR, filename)
|
|
104
|
+
if os.path.isfile(filepath):
|
|
105
|
+
os.remove(filepath)
|
|
106
|
+
|
|
107
|
+
conn = get_db()
|
|
108
|
+
conn.execute("DELETE FROM plugins WHERE filename = ?", (filename,))
|
|
109
|
+
conn.commit()
|
|
110
|
+
|
|
111
|
+
return removed
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_plugins() -> list[dict]:
|
|
115
|
+
"""List all registered plugins."""
|
|
116
|
+
conn = get_db()
|
|
117
|
+
rows = conn.execute(
|
|
118
|
+
"SELECT filename, tools_count, tool_names, loaded_at, created_by FROM plugins ORDER BY filename"
|
|
119
|
+
).fetchall()
|
|
120
|
+
return [dict(r) for r in rows]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _update_registry(filename: str, tools_count: int, tool_names: str, created_by: str):
|
|
124
|
+
"""Insert or update plugin registry entry. Non-fatal on lock — tools still work."""
|
|
125
|
+
now = time.time()
|
|
126
|
+
try:
|
|
127
|
+
conn = get_db()
|
|
128
|
+
conn.execute(
|
|
129
|
+
"INSERT INTO plugins (filename, tools_count, tool_names, loaded_at, created_by) "
|
|
130
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
131
|
+
"ON CONFLICT(filename) DO UPDATE SET tools_count=?, tool_names=?, loaded_at=?",
|
|
132
|
+
(filename, tools_count, tool_names, now, created_by, tools_count, tool_names, now),
|
|
133
|
+
)
|
|
134
|
+
conn.commit()
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f"[PLUGIN REGISTRY] Skipped update for {filename}: {e}")
|
package/src/server 2.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""NEXO MCP Server — Phase 4: Hot-Reload Plugin System."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
from db import init_db, rebuild_fts_index, get_db, close_db, fts_add_dir, fts_remove_dir, fts_list_dirs
|
|
9
|
+
from tools_sessions import handle_startup, handle_heartbeat, handle_status
|
|
10
|
+
from tools_coordination import (
|
|
11
|
+
handle_track, handle_untrack, handle_files,
|
|
12
|
+
handle_send, handle_ask, handle_answer, handle_check_answer,
|
|
13
|
+
)
|
|
14
|
+
from tools_reminders import handle_reminders
|
|
15
|
+
from tools_menu import handle_menu
|
|
16
|
+
from tools_reminders_crud import (
|
|
17
|
+
handle_reminder_create, handle_reminder_update,
|
|
18
|
+
handle_reminder_complete, handle_reminder_delete,
|
|
19
|
+
handle_followup_create, handle_followup_update,
|
|
20
|
+
handle_followup_complete, handle_followup_delete,
|
|
21
|
+
)
|
|
22
|
+
from tools_learnings import (
|
|
23
|
+
handle_learning_add, handle_learning_search,
|
|
24
|
+
handle_learning_update, handle_learning_delete, handle_learning_list,
|
|
25
|
+
)
|
|
26
|
+
from tools_credentials import (
|
|
27
|
+
handle_credential_get, handle_credential_create,
|
|
28
|
+
handle_credential_update, handle_credential_delete, handle_credential_list,
|
|
29
|
+
)
|
|
30
|
+
from tools_task_history import (
|
|
31
|
+
handle_task_log, handle_task_list, handle_task_frequency,
|
|
32
|
+
)
|
|
33
|
+
from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Graceful shutdown: close DB on any termination signal ──────────
|
|
37
|
+
def _shutdown_handler(signum, frame):
|
|
38
|
+
close_db()
|
|
39
|
+
sys.exit(0)
|
|
40
|
+
|
|
41
|
+
signal.signal(signal.SIGTERM, _shutdown_handler)
|
|
42
|
+
signal.signal(signal.SIGINT, _shutdown_handler)
|
|
43
|
+
|
|
44
|
+
# ── Write PID file for stale process detection ─────────────────────
|
|
45
|
+
_pid_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nexo.pid")
|
|
46
|
+
with open(_pid_file, "w") as f:
|
|
47
|
+
f.write(str(os.getpid()))
|
|
48
|
+
|
|
49
|
+
init_db()
|
|
50
|
+
|
|
51
|
+
mcp = FastMCP(
|
|
52
|
+
name="nexo",
|
|
53
|
+
instructions=(
|
|
54
|
+
"NEXO operational server. Provides session coordination, "
|
|
55
|
+
"reminders, followups, and menu for user operations.\n\n"
|
|
56
|
+
"When working with tool results, write down any important information "
|
|
57
|
+
"you might need later in your response, as the original tool result "
|
|
58
|
+
"may be cleared later."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_plugins_loaded = load_all_plugins(mcp)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Session management (3 tools) ──────────────────────────────────
|
|
66
|
+
|
|
67
|
+
@mcp.tool
|
|
68
|
+
def nexo_startup(task: str = "Startup") -> str:
|
|
69
|
+
"""Register new session, clean stale ones, return active sessions + alerts.
|
|
70
|
+
|
|
71
|
+
Call this ONCE at the start of every conversation.
|
|
72
|
+
Returns the session ID (SID) — store it for use in all other nexo_ tools.
|
|
73
|
+
"""
|
|
74
|
+
return handle_startup(task)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool
|
|
78
|
+
def nexo_heartbeat(sid: str, task: str) -> str:
|
|
79
|
+
"""Update session task, check inbox and pending questions.
|
|
80
|
+
|
|
81
|
+
Call this at the START of every user interaction (before doing work).
|
|
82
|
+
Args:
|
|
83
|
+
sid: Your session ID from nexo_startup.
|
|
84
|
+
task: Brief description of current work (5-10 words).
|
|
85
|
+
"""
|
|
86
|
+
return handle_heartbeat(sid, task)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool
|
|
90
|
+
def nexo_status(keyword: str = "") -> str:
|
|
91
|
+
"""List active sessions. Filter by keyword if provided."""
|
|
92
|
+
return handle_status(keyword if keyword else None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── File coordination (3 tools) ───────────────────────────────────
|
|
96
|
+
|
|
97
|
+
@mcp.tool
|
|
98
|
+
def nexo_track(sid: str, paths: list[str]) -> str:
|
|
99
|
+
"""Track files being edited. Detects conflicts with other sessions.
|
|
100
|
+
|
|
101
|
+
MUST call before editing any file outside ~/claude/.
|
|
102
|
+
Args:
|
|
103
|
+
sid: Your session ID.
|
|
104
|
+
paths: List of absolute file paths to track.
|
|
105
|
+
"""
|
|
106
|
+
return handle_track(sid, paths)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool
|
|
110
|
+
def nexo_untrack(sid: str, paths: list[str] | None = None) -> str:
|
|
111
|
+
"""Stop tracking files. If no paths given, releases all.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
sid: Your session ID.
|
|
115
|
+
paths: File paths to release. Omit to release all.
|
|
116
|
+
"""
|
|
117
|
+
return handle_untrack(sid, paths)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@mcp.tool
|
|
121
|
+
def nexo_files() -> str:
|
|
122
|
+
"""Show all tracked files across all active sessions with conflict detection."""
|
|
123
|
+
return handle_files()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Messaging (4 tools) ───────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
@mcp.tool
|
|
129
|
+
def nexo_send(from_sid: str, to_sid: str, text: str) -> str:
|
|
130
|
+
"""Send a fire-and-forget message to another session or broadcast.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
from_sid: Your session ID.
|
|
134
|
+
to_sid: Target session ID, or 'all' for broadcast.
|
|
135
|
+
text: Message content.
|
|
136
|
+
"""
|
|
137
|
+
return handle_send(from_sid, to_sid, text)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@mcp.tool
|
|
141
|
+
def nexo_ask(from_sid: str, to_sid: str, question: str) -> str:
|
|
142
|
+
"""Ask a question to another session (they see it on next heartbeat).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
from_sid: Your session ID.
|
|
146
|
+
to_sid: Target session ID.
|
|
147
|
+
question: The question text.
|
|
148
|
+
Returns: Question ID (qid) for checking the answer later.
|
|
149
|
+
"""
|
|
150
|
+
return handle_ask(from_sid, to_sid, question)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@mcp.tool
|
|
154
|
+
def nexo_answer(qid: str, answer: str) -> str:
|
|
155
|
+
"""Answer a pending question from another session.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
qid: The question ID shown in heartbeat output.
|
|
159
|
+
answer: Your response.
|
|
160
|
+
"""
|
|
161
|
+
return handle_answer(qid, answer)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool
|
|
165
|
+
def nexo_check_answer(qid: str) -> str:
|
|
166
|
+
"""Check if a question has been answered.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
qid: The question ID from nexo_ask.
|
|
170
|
+
"""
|
|
171
|
+
return handle_check_answer(qid)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Operations: Reminders + Menu (2 tools, read-only) ─────────────
|
|
175
|
+
|
|
176
|
+
@mcp.tool
|
|
177
|
+
def nexo_reminders(filter: str = "due") -> str:
|
|
178
|
+
"""Check reminders and followups.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
filter: 'due' (vencidos/hoy), 'all' (todos activos), 'followups' (solo NEXO followups)
|
|
182
|
+
"""
|
|
183
|
+
return handle_reminders(filter)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mcp.tool
|
|
187
|
+
def nexo_menu() -> str:
|
|
188
|
+
"""Generate the NEXO operations center menu with alerts and active sessions.
|
|
189
|
+
|
|
190
|
+
Shows: date, due alerts, all menu items by category, active sessions.
|
|
191
|
+
Uses box-drawing characters for formatting.
|
|
192
|
+
"""
|
|
193
|
+
return handle_menu()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Reminders CRUD (4 tools) ──────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
@mcp.tool
|
|
199
|
+
def nexo_reminder_create(id: str, description: str, date: str = "", category: str = "general") -> str:
|
|
200
|
+
"""Create a new reminder for the user.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
id: Unique ID starting with 'R' (e.g., R90).
|
|
204
|
+
description: What needs to be done.
|
|
205
|
+
date: Target date YYYY-MM-DD (optional).
|
|
206
|
+
category: One of: decisiones, tareas, esperando, ideas, general.
|
|
207
|
+
"""
|
|
208
|
+
return handle_reminder_create(id, description, date, category)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@mcp.tool
|
|
212
|
+
def nexo_reminder_update(id: str, description: str = "", date: str = "", status: str = "", category: str = "") -> str:
|
|
213
|
+
"""Update fields of an existing reminder. Only non-empty fields are changed.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
id: Reminder ID (e.g., R87).
|
|
217
|
+
description: New description (optional).
|
|
218
|
+
date: New date YYYY-MM-DD (optional).
|
|
219
|
+
status: New status (optional).
|
|
220
|
+
category: New category (optional).
|
|
221
|
+
"""
|
|
222
|
+
return handle_reminder_update(id, description, date, status, category)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@mcp.tool
|
|
226
|
+
def nexo_reminder_complete(id: str) -> str:
|
|
227
|
+
"""Mark a reminder as COMPLETADO with today's date.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
id: Reminder ID (e.g., R87).
|
|
231
|
+
"""
|
|
232
|
+
return handle_reminder_complete(id)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@mcp.tool
|
|
236
|
+
def nexo_reminder_delete(id: str) -> str:
|
|
237
|
+
"""Delete a reminder permanently.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
id: Reminder ID (e.g., R87).
|
|
241
|
+
"""
|
|
242
|
+
return handle_reminder_delete(id)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── Followups CRUD (4 tools) ──────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
@mcp.tool
|
|
248
|
+
def nexo_followup_create(id: str, description: str, date: str = "", verification: str = "", reasoning: str = "", recurrence: str = "") -> str:
|
|
249
|
+
"""Create a new NEXO followup (autonomous task).
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
id: Unique ID starting with 'NF' (e.g., NF-MCP2).
|
|
253
|
+
description: What to verify/do.
|
|
254
|
+
date: Target date YYYY-MM-DD (optional).
|
|
255
|
+
verification: How to verify completion (optional).
|
|
256
|
+
reasoning: WHY this followup exists — what decision/context led to it (optional).
|
|
257
|
+
recurrence: Auto-regenerate pattern (optional). Formats: 'weekly:monday', 'monthly:1', 'monthly:15', 'quarterly'.
|
|
258
|
+
When completed, a new followup is auto-created with the next date. The completed one is archived with date suffix.
|
|
259
|
+
"""
|
|
260
|
+
return handle_followup_create(id, description, date, verification, reasoning, recurrence)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@mcp.tool
|
|
264
|
+
def nexo_followup_update(id: str, description: str = "", date: str = "", verification: str = "", status: str = "") -> str:
|
|
265
|
+
"""Update fields of an existing followup. Only non-empty fields are changed.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
id: Followup ID (e.g., NF45).
|
|
269
|
+
description: New description (optional).
|
|
270
|
+
date: New date YYYY-MM-DD (optional).
|
|
271
|
+
verification: New verification text (optional).
|
|
272
|
+
status: New status (optional).
|
|
273
|
+
"""
|
|
274
|
+
return handle_followup_update(id, description, date, verification, status)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@mcp.tool
|
|
278
|
+
def nexo_followup_complete(id: str, result: str = "") -> str:
|
|
279
|
+
"""Mark a followup as COMPLETADO. Appends result to verification field.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
id: Followup ID (e.g., NF45).
|
|
283
|
+
result: What was found/done (optional).
|
|
284
|
+
"""
|
|
285
|
+
return handle_followup_complete(id, result)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@mcp.tool
|
|
289
|
+
def nexo_followup_delete(id: str) -> str:
|
|
290
|
+
"""Delete a followup permanently.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
id: Followup ID (e.g., NF45).
|
|
294
|
+
"""
|
|
295
|
+
return handle_followup_delete(id)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ── Learnings CRUD (5 tools) ──────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
@mcp.tool
|
|
301
|
+
def nexo_learning_add(category: str, title: str, content: str, reasoning: str = "") -> str:
|
|
302
|
+
"""Add a new learning (resolved error, pattern, gotcha).
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
category: One of: general, code, infrastructure, api, database, security, deployment, testing, performance, ux.
|
|
306
|
+
title: Short title for the learning.
|
|
307
|
+
content: Full description with context and solution.
|
|
308
|
+
reasoning: WHY this matters — what led to discovering this (optional).
|
|
309
|
+
"""
|
|
310
|
+
return handle_learning_add(category, title, content, reasoning)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@mcp.tool
|
|
314
|
+
def nexo_learning_search(query: str, category: str = "") -> str:
|
|
315
|
+
"""Search learnings by keyword. Searches title and content.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
query: Search term.
|
|
319
|
+
category: Filter by category (optional).
|
|
320
|
+
"""
|
|
321
|
+
return handle_learning_search(query, category)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@mcp.tool
|
|
325
|
+
def nexo_learning_update(id: int, title: str = "", content: str = "", category: str = "") -> str:
|
|
326
|
+
"""Update a learning entry. Only non-empty fields are changed.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
id: Learning ID number.
|
|
330
|
+
title: New title (optional).
|
|
331
|
+
content: New content (optional).
|
|
332
|
+
category: New category (optional).
|
|
333
|
+
"""
|
|
334
|
+
return handle_learning_update(id, title, content, category)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@mcp.tool
|
|
338
|
+
def nexo_learning_delete(id: int) -> str:
|
|
339
|
+
"""Delete a learning entry.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
id: Learning ID number.
|
|
343
|
+
"""
|
|
344
|
+
return handle_learning_delete(id)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@mcp.tool
|
|
348
|
+
def nexo_learning_list(category: str = "") -> str:
|
|
349
|
+
"""List all learnings, grouped by category.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
category: Filter by category (optional). If empty, shows all grouped.
|
|
353
|
+
"""
|
|
354
|
+
return handle_learning_list(category)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ── Search index ──────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
@mcp.tool
|
|
360
|
+
def nexo_reindex() -> str:
|
|
361
|
+
"""Force full rebuild of the FTS5 search index. Use after bulk changes or if search seems stale."""
|
|
362
|
+
conn = get_db()
|
|
363
|
+
rebuild_fts_index(conn)
|
|
364
|
+
count = conn.execute("SELECT COUNT(*) FROM unified_search").fetchone()[0]
|
|
365
|
+
sources = conn.execute("SELECT source, COUNT(*) as cnt FROM unified_search GROUP BY source ORDER BY cnt DESC").fetchall()
|
|
366
|
+
lines = [f"Index rebuilt: {count} documentos"]
|
|
367
|
+
for s in sources:
|
|
368
|
+
lines.append(f" {s[0]:12s} → {s[1]}")
|
|
369
|
+
return "\n".join(lines)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@mcp.tool
|
|
373
|
+
def nexo_index_add_dir(path: str, dir_type: str = "code",
|
|
374
|
+
patterns: str = "*.php,*.js,*.json,*.py,*.ts,*.tsx",
|
|
375
|
+
notes: str = "") -> str:
|
|
376
|
+
"""Register a new directory for FTS5 search indexing. Survives restarts.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
path: Absolute path to directory (supports ~).
|
|
380
|
+
dir_type: 'code' for source files, 'md' for markdown docs.
|
|
381
|
+
patterns: Comma-separated glob patterns (only for code type).
|
|
382
|
+
notes: Description of what this directory contains.
|
|
383
|
+
"""
|
|
384
|
+
result = fts_add_dir(path, dir_type, patterns, notes)
|
|
385
|
+
if "error" in result:
|
|
386
|
+
return f"ERROR: {result['error']}"
|
|
387
|
+
return f"Directorio registrado: {result['path']} ({result['dir_type']}, patterns: {result['patterns']})\nUsa nexo_reindex para indexar ahora."
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@mcp.tool
|
|
391
|
+
def nexo_index_remove_dir(path: str) -> str:
|
|
392
|
+
"""Remove a directory from FTS5 indexing and clean up its entries.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
path: Path to directory to remove.
|
|
396
|
+
"""
|
|
397
|
+
result = fts_remove_dir(path)
|
|
398
|
+
if "error" in result:
|
|
399
|
+
return f"ERROR: {result['error']}"
|
|
400
|
+
return f"Directorio eliminado del índice: {result['removed']}"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@mcp.tool
|
|
404
|
+
def nexo_index_dirs() -> str:
|
|
405
|
+
"""List all directories being indexed by FTS5 (builtin + dynamic)."""
|
|
406
|
+
dirs = fts_list_dirs()
|
|
407
|
+
if not dirs:
|
|
408
|
+
return "Sin directorios configurados."
|
|
409
|
+
lines = ["DIRECTORIOS INDEXADOS:"]
|
|
410
|
+
for d in dirs:
|
|
411
|
+
source_tag = "⚙️" if d["source"] == "builtin" else "➕"
|
|
412
|
+
notes = f" — {d['notes']}" if d.get("notes") else ""
|
|
413
|
+
lines.append(f" {source_tag} [{d['type']}] {d['path']}")
|
|
414
|
+
lines.append(f" patterns: {d['patterns']}{notes}")
|
|
415
|
+
return "\n".join(lines)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── Credentials CRUD (5 tools) ────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
@mcp.tool
|
|
421
|
+
def nexo_credential_get(service: str, key: str = "") -> str:
|
|
422
|
+
"""Get credential value(s) for a service.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
service: Service name (e.g., google-ads, meta-ads, shopify).
|
|
426
|
+
key: Specific key (optional). If empty, returns all for the service.
|
|
427
|
+
"""
|
|
428
|
+
return handle_credential_get(service, key)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@mcp.tool
|
|
432
|
+
def nexo_credential_create(service: str, key: str, value: str, notes: str = "") -> str:
|
|
433
|
+
"""Store a new credential.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
service: Service name (e.g., google-ads, cloudflare).
|
|
437
|
+
key: Key name (e.g., api_key, token, ssh).
|
|
438
|
+
value: The secret value.
|
|
439
|
+
notes: Description or context (optional).
|
|
440
|
+
"""
|
|
441
|
+
return handle_credential_create(service, key, value, notes)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@mcp.tool
|
|
445
|
+
def nexo_credential_update(service: str, key: str, value: str = "", notes: str = "") -> str:
|
|
446
|
+
"""Update a credential's value and/or notes.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
service: Service name.
|
|
450
|
+
key: Key name.
|
|
451
|
+
value: New value (optional).
|
|
452
|
+
notes: New notes (optional).
|
|
453
|
+
"""
|
|
454
|
+
return handle_credential_update(service, key, value, notes)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@mcp.tool
|
|
458
|
+
def nexo_credential_delete(service: str, key: str = "") -> str:
|
|
459
|
+
"""Delete credential(s). If no key, deletes all for the service.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
service: Service name.
|
|
463
|
+
key: Specific key (optional). If empty, deletes ALL for service.
|
|
464
|
+
"""
|
|
465
|
+
return handle_credential_delete(service, key)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@mcp.tool
|
|
469
|
+
def nexo_credential_list(service: str = "") -> str:
|
|
470
|
+
"""List credentials (names and notes only, no values).
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
service: Filter by service (optional). If empty, shows all.
|
|
474
|
+
"""
|
|
475
|
+
return handle_credential_list(service)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ── Task History (3 tools) ────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
@mcp.tool
|
|
481
|
+
def nexo_task_log(task_num: str, task_name: str, notes: str = "", reasoning: str = "") -> str:
|
|
482
|
+
"""Record that an operational task was executed.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
task_num: Task number from the checklist (e.g., '7', '7b').
|
|
486
|
+
task_name: Task name (e.g., 'Google Ads').
|
|
487
|
+
notes: Execution summary (optional).
|
|
488
|
+
reasoning: WHY this task was executed now — what triggered it (optional).
|
|
489
|
+
"""
|
|
490
|
+
return handle_task_log(task_num, task_name, notes, reasoning)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@mcp.tool
|
|
494
|
+
def nexo_task_list(task_num: str = "", days: int = 30) -> str:
|
|
495
|
+
"""Show execution history for operational tasks.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
task_num: Filter by task number (optional).
|
|
499
|
+
days: How many days back to show (default 30).
|
|
500
|
+
"""
|
|
501
|
+
return handle_task_list(task_num, days)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@mcp.tool
|
|
505
|
+
def nexo_task_frequency() -> str:
|
|
506
|
+
"""Check which operational tasks are overdue based on their frequency.
|
|
507
|
+
|
|
508
|
+
Compares last execution date vs configured frequency.
|
|
509
|
+
Returns overdue tasks or 'all tasks up to date'.
|
|
510
|
+
"""
|
|
511
|
+
return handle_task_frequency()
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ── Plugin Management (3 tools) ─────────────────────────────────
|
|
515
|
+
|
|
516
|
+
@mcp.tool
|
|
517
|
+
def nexo_plugin_load(filename: str) -> str:
|
|
518
|
+
"""Load or reload a plugin from the plugins/ directory.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
filename: Plugin filename (e.g., 'entities.py').
|
|
522
|
+
"""
|
|
523
|
+
try:
|
|
524
|
+
n = load_plugin(mcp, filename)
|
|
525
|
+
return f"Plugin {filename}: {n} tools registrados."
|
|
526
|
+
except Exception as e:
|
|
527
|
+
return f"Error cargando plugin {filename}: {e}"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@mcp.tool
|
|
531
|
+
def nexo_plugin_list() -> str:
|
|
532
|
+
"""List all loaded plugins and their tools."""
|
|
533
|
+
plugins = list_plugins()
|
|
534
|
+
if not plugins:
|
|
535
|
+
return "Sin plugins cargados."
|
|
536
|
+
lines = ["PLUGINS CARGADOS:"]
|
|
537
|
+
for p in plugins:
|
|
538
|
+
names = p["tool_names"] or "(sin tools)"
|
|
539
|
+
lines.append(f" {p['filename']} — {p['tools_count']} tools: {names}")
|
|
540
|
+
return "\n".join(lines)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@mcp.tool
|
|
544
|
+
def nexo_plugin_remove(filename: str) -> str:
|
|
545
|
+
"""Remove a plugin: unregister its tools and delete the file.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
filename: Plugin filename (e.g., 'entities.py').
|
|
549
|
+
"""
|
|
550
|
+
try:
|
|
551
|
+
removed = remove_plugin(mcp, filename)
|
|
552
|
+
if removed:
|
|
553
|
+
return f"Plugin {filename} eliminado. Tools quitados: {', '.join(removed)}"
|
|
554
|
+
return f"Plugin {filename} eliminado (no tenía tools registrados)."
|
|
555
|
+
except Exception as e:
|
|
556
|
+
return f"Error eliminando plugin {filename}: {e}"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
mcp.run(transport="stdio")
|