nexo-brain 3.1.8 → 3.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.
@@ -0,0 +1,419 @@
1
+ from __future__ import annotations
2
+ """Live system catalog / ontology derived from canonical NEXO sources."""
3
+
4
+ import ast
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from db import get_db, list_skills, sync_skill_directories
12
+ from plugin_loader import PERSONAL_PLUGINS_DIR, PLUGINS_DIR, list_plugins
13
+ from script_registry import list_scripts
14
+
15
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
16
+ NEXO_CODE = Path(__file__).resolve().parent
17
+ SERVER_PATH = NEXO_CODE / "server.py"
18
+ MANIFEST_PATHS = [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]
19
+ ATLAS_PATH = NEXO_HOME / "brain" / "project-atlas.json"
20
+
21
+ SECTION_ORDER = (
22
+ "core_tools",
23
+ "plugin_tools",
24
+ "skills",
25
+ "scripts",
26
+ "crons",
27
+ "projects",
28
+ "artifacts",
29
+ )
30
+
31
+
32
+ def _normalize_text(text: str | None) -> str:
33
+ return str(text or "").strip().lower()
34
+
35
+
36
+ def _tokenize(text: str | None) -> set[str]:
37
+ import re
38
+ normalized = _normalize_text(text)
39
+ return {
40
+ token
41
+ for token in re.findall(r"[a-z0-9][a-z0-9._:-]{1,}", normalized)
42
+ if len(token) >= 3
43
+ }
44
+
45
+
46
+ def _score(query_tokens: set[str], haystack: str) -> float:
47
+ if not query_tokens:
48
+ return 0.0
49
+ haystack_tokens = _tokenize(haystack)
50
+ if not haystack_tokens:
51
+ return 0.0
52
+ overlap = query_tokens & haystack_tokens
53
+ if not overlap:
54
+ return 0.0
55
+ return len(overlap) / max(1, min(len(query_tokens), len(haystack_tokens)))
56
+
57
+
58
+ def _truncate(text: str | None, limit: int = 180) -> str:
59
+ clean = str(text or "").strip()
60
+ if len(clean) <= limit:
61
+ return clean
62
+ return clean[: limit - 3] + "..."
63
+
64
+
65
+ def _tool_category(name: str) -> str:
66
+ if name.startswith("nexo_recent_context") or name.startswith("nexo_pre_action_context") or name.startswith("nexo_hot_context"):
67
+ return "recent_memory"
68
+ if name.startswith("nexo_transcript"):
69
+ return "transcripts"
70
+ if name.startswith("nexo_session") or name.startswith("nexo_checkpoint"):
71
+ return "sessions"
72
+ if name.startswith("nexo_followup") or name.startswith("nexo_reminder"):
73
+ return "reminders"
74
+ if name.startswith("nexo_skill"):
75
+ return "skills"
76
+ if name.startswith("nexo_plugin"):
77
+ return "plugins"
78
+ if name.startswith("nexo_goal") or name.startswith("nexo_workflow"):
79
+ return "workflow"
80
+ if name.startswith("nexo_learning"):
81
+ return "learnings"
82
+ if name.startswith("nexo_guard") or name.startswith("nexo_task") or name.startswith("nexo_cortex"):
83
+ return "protocol"
84
+ return "general"
85
+
86
+
87
+ def _parse_core_tools() -> list[dict]:
88
+ if not SERVER_PATH.is_file():
89
+ return []
90
+ try:
91
+ tree = ast.parse(SERVER_PATH.read_text())
92
+ except Exception:
93
+ return []
94
+
95
+ entries: list[dict] = []
96
+ for node in tree.body:
97
+ if not isinstance(node, ast.FunctionDef):
98
+ continue
99
+ if not any(
100
+ isinstance(dec, ast.Attribute) and getattr(dec.value, "id", "") == "mcp" and dec.attr == "tool"
101
+ for dec in node.decorator_list
102
+ ):
103
+ continue
104
+ doc = ast.get_docstring(node) or ""
105
+ first_line = doc.strip().splitlines()[0].strip() if doc.strip() else ""
106
+ entries.append(
107
+ {
108
+ "kind": "core_tool",
109
+ "name": node.name,
110
+ "description": first_line,
111
+ "category": _tool_category(node.name),
112
+ "path": str(SERVER_PATH),
113
+ "line": int(getattr(node, "lineno", 0) or 0),
114
+ "source": "core",
115
+ }
116
+ )
117
+ return entries
118
+
119
+
120
+ def _plugin_module_tools(filename: str, created_by: str) -> dict[str, str]:
121
+ module_name = f"plugins.{filename[:-3]}"
122
+ module = sys.modules.get(module_name)
123
+ if module is None:
124
+ plugin_dir = PLUGINS_DIR if created_by == "repo" else PERSONAL_PLUGINS_DIR
125
+ path = Path(plugin_dir) / filename
126
+ if not path.is_file():
127
+ return {}
128
+ try:
129
+ spec = importlib.util.spec_from_file_location(module_name, path)
130
+ if spec is None or spec.loader is None:
131
+ return {}
132
+ module = importlib.util.module_from_spec(spec)
133
+ spec.loader.exec_module(module)
134
+ except Exception:
135
+ return {}
136
+ tools = getattr(module, "TOOLS", []) or []
137
+ result: dict[str, str] = {}
138
+ for item in tools:
139
+ try:
140
+ _, name, description = item
141
+ except Exception:
142
+ continue
143
+ result[str(name)] = str(description or "")
144
+ return result
145
+
146
+
147
+ def _plugin_entries() -> list[dict]:
148
+ rows = list_plugins()
149
+ entries: list[dict] = []
150
+ for row in rows:
151
+ filename = str(row.get("filename") or "")
152
+ created_by = str(row.get("created_by") or row.get("source") or "repo")
153
+ descriptions = _plugin_module_tools(filename, created_by)
154
+ names = str(row.get("tool_names") or "").split(",")
155
+ for name in [n.strip() for n in names if n.strip()]:
156
+ entries.append(
157
+ {
158
+ "kind": "plugin_tool",
159
+ "name": name,
160
+ "description": descriptions.get(name, ""),
161
+ "plugin": filename,
162
+ "source": created_by,
163
+ "category": _tool_category(name),
164
+ }
165
+ )
166
+ return entries
167
+
168
+
169
+ def _skill_entries() -> list[dict]:
170
+ try:
171
+ sync_skill_directories()
172
+ except Exception:
173
+ pass
174
+ entries: list[dict] = []
175
+ for row in list_skills():
176
+ entries.append(
177
+ {
178
+ "kind": "skill",
179
+ "name": row.get("id", ""),
180
+ "display_name": row.get("name", ""),
181
+ "description": row.get("description", "") or "",
182
+ "source": row.get("source_kind", "") or "",
183
+ "level": row.get("level", "") or "",
184
+ "mode": row.get("mode", "") or "",
185
+ "execution_level": row.get("execution_level", "") or "",
186
+ "trust_score": row.get("trust_score", 0),
187
+ "tags": row.get("tags", "[]"),
188
+ }
189
+ )
190
+ return entries
191
+
192
+
193
+ def _script_entries() -> list[dict]:
194
+ entries: list[dict] = []
195
+ for row in list_scripts(include_core=True):
196
+ entries.append(
197
+ {
198
+ "kind": "script",
199
+ "name": row.get("name", ""),
200
+ "description": row.get("description", "") or "",
201
+ "runtime": row.get("runtime", "") or "",
202
+ "path": row.get("path", "") or "",
203
+ "source": "core" if row.get("core") else "personal",
204
+ "classification": row.get("classification", "") or "",
205
+ "declared_schedule": row.get("declared_schedule", {}) or {},
206
+ }
207
+ )
208
+ return entries
209
+
210
+
211
+ def _cron_entries() -> list[dict]:
212
+ manifest = None
213
+ for path in MANIFEST_PATHS:
214
+ if path.is_file():
215
+ try:
216
+ manifest = json.loads(path.read_text())
217
+ break
218
+ except Exception:
219
+ continue
220
+ if not isinstance(manifest, dict):
221
+ return []
222
+ entries: list[dict] = []
223
+ for cron in manifest.get("crons", []) or []:
224
+ entries.append(
225
+ {
226
+ "kind": "cron",
227
+ "name": cron.get("id", ""),
228
+ "description": cron.get("description", "") or "",
229
+ "script": cron.get("script", "") or "",
230
+ "schedule": cron.get("schedule", {}) or {},
231
+ "optional": bool(cron.get("optional", False)),
232
+ }
233
+ )
234
+ return entries
235
+
236
+
237
+ def _project_entries() -> list[dict]:
238
+ if not ATLAS_PATH.is_file():
239
+ return []
240
+ try:
241
+ payload = json.loads(ATLAS_PATH.read_text())
242
+ except Exception:
243
+ return []
244
+ entries: list[dict] = []
245
+ if isinstance(payload, dict):
246
+ for key, value in payload.items():
247
+ if str(key).startswith("_"):
248
+ continue
249
+ if not isinstance(value, dict):
250
+ continue
251
+ entries.append(
252
+ {
253
+ "kind": "project",
254
+ "name": key,
255
+ "path": value.get("path", "") or "",
256
+ "domain": value.get("domain", "") or "",
257
+ "aliases": value.get("aliases", []) or [],
258
+ "services": value.get("services", {}) or {},
259
+ "plugins": value.get("plugins", "") or value.get("plugin_path", "") or "",
260
+ }
261
+ )
262
+ return entries
263
+
264
+
265
+ def _artifact_entries() -> list[dict]:
266
+ conn = get_db()
267
+ try:
268
+ rows = conn.execute(
269
+ "SELECT canonical_name, kind, domain, state, uri, paths, ports, aliases FROM artifact_registry ORDER BY last_touched_at DESC LIMIT 100"
270
+ ).fetchall()
271
+ except Exception:
272
+ return []
273
+ return [
274
+ {
275
+ "kind": "artifact",
276
+ "name": row["canonical_name"],
277
+ "artifact_kind": row["kind"],
278
+ "domain": row["domain"],
279
+ "state": row["state"],
280
+ "uri": row["uri"],
281
+ "paths": row["paths"],
282
+ "ports": row["ports"],
283
+ "aliases": row["aliases"],
284
+ }
285
+ for row in rows
286
+ ]
287
+
288
+
289
+ def build_system_catalog() -> dict:
290
+ catalog = {
291
+ "core_tools": _parse_core_tools(),
292
+ "plugin_tools": _plugin_entries(),
293
+ "skills": _skill_entries(),
294
+ "scripts": _script_entries(),
295
+ "crons": _cron_entries(),
296
+ "projects": _project_entries(),
297
+ "artifacts": _artifact_entries(),
298
+ }
299
+ catalog["summary"] = {
300
+ section: len(catalog.get(section) or [])
301
+ for section in SECTION_ORDER
302
+ }
303
+ return catalog
304
+
305
+
306
+ def search_system_catalog(query: str, *, section: str = "", limit: int = 20) -> list[dict]:
307
+ catalog = build_system_catalog()
308
+ query_tokens = _tokenize(query)
309
+ sections = [section] if section in SECTION_ORDER else list(SECTION_ORDER)
310
+ matches: list[dict] = []
311
+ for section_name in sections:
312
+ for entry in catalog.get(section_name) or []:
313
+ haystack = " ".join(
314
+ [
315
+ section_name,
316
+ str(entry.get("name", "") or ""),
317
+ str(entry.get("display_name", "") or ""),
318
+ str(entry.get("description", "") or ""),
319
+ str(entry.get("source", "") or ""),
320
+ str(entry.get("category", "") or ""),
321
+ str(entry.get("plugin", "") or ""),
322
+ str(entry.get("domain", "") or ""),
323
+ str(entry.get("path", "") or ""),
324
+ json.dumps(entry, ensure_ascii=False),
325
+ ]
326
+ )
327
+ score = _score(query_tokens, haystack) if query_tokens else 0.5
328
+ if query_tokens and score <= 0:
329
+ continue
330
+ row = dict(entry)
331
+ row["_section"] = section_name
332
+ row["_score"] = round(score, 4)
333
+ matches.append(row)
334
+ matches.sort(key=lambda row: (row["_score"], row.get("name", "")), reverse=True)
335
+ return matches[: max(1, int(limit or 20))]
336
+
337
+
338
+ def explain_tool(name: str) -> dict | None:
339
+ clean = _normalize_text(name)
340
+ if not clean:
341
+ return None
342
+ exact = search_system_catalog(clean, limit=200)
343
+ for row in exact:
344
+ if _normalize_text(row.get("name")) == clean:
345
+ return row
346
+ for row in exact:
347
+ if clean in _normalize_text(row.get("name")):
348
+ return row
349
+ return None
350
+
351
+
352
+ def format_catalog(catalog: dict, *, section: str = "", query: str = "", limit: int = 20) -> str:
353
+ summary = catalog.get("summary") or {}
354
+ if query:
355
+ matches = search_system_catalog(query, section=section, limit=limit)
356
+ if not matches:
357
+ scope = section or "all sections"
358
+ return f"No system-catalog matches for '{query}' in {scope}."
359
+ lines = [f"SYSTEM CATALOG SEARCH — '{query}' ({len(matches)} match(es))"]
360
+ for row in matches:
361
+ label = row.get("_section", "")
362
+ title = row.get("display_name") or row.get("name") or "(unnamed)"
363
+ desc = _truncate(row.get("description") or row.get("path") or row.get("script") or "", 180)
364
+ suffix = f" — {desc}" if desc else ""
365
+ lines.append(f"- [{label}] {title}{suffix}")
366
+ return "\n".join(lines)
367
+
368
+ if section in SECTION_ORDER:
369
+ entries = catalog.get(section) or []
370
+ if not entries:
371
+ return f"SYSTEM CATALOG — {section}: empty"
372
+ lines = [f"SYSTEM CATALOG — {section} ({len(entries)})"]
373
+ for row in entries[: max(1, int(limit or 20))]:
374
+ title = row.get("display_name") or row.get("name") or "(unnamed)"
375
+ desc = _truncate(row.get("description") or row.get("path") or row.get("script") or "", 180)
376
+ suffix = f" — {desc}" if desc else ""
377
+ lines.append(f"- {title}{suffix}")
378
+ return "\n".join(lines)
379
+
380
+ lines = ["SYSTEM CATALOG SUMMARY"]
381
+ for name in SECTION_ORDER:
382
+ lines.append(f"- {name}: {summary.get(name, 0)}")
383
+ return "\n".join(lines)
384
+
385
+
386
+ def format_tool_explanation(entry: dict | None) -> str:
387
+ if not entry:
388
+ return "Tool/capability not found in the live system catalog."
389
+ lines = [
390
+ f"CATALOG ENTRY — {entry.get('name') or entry.get('display_name')}",
391
+ f"Section: {entry.get('_section') or entry.get('kind')}",
392
+ ]
393
+ if entry.get("display_name"):
394
+ lines.append(f"Display name: {entry['display_name']}")
395
+ if entry.get("description"):
396
+ lines.append(f"Description: {entry['description']}")
397
+ if entry.get("category"):
398
+ lines.append(f"Category: {entry['category']}")
399
+ if entry.get("source"):
400
+ lines.append(f"Source: {entry['source']}")
401
+ if entry.get("plugin"):
402
+ lines.append(f"Plugin: {entry['plugin']}")
403
+ if entry.get("path"):
404
+ lines.append(f"Path: {entry['path']}")
405
+ if entry.get("line"):
406
+ lines.append(f"Line: {entry['line']}")
407
+ if entry.get("script"):
408
+ lines.append(f"Script: {entry['script']}")
409
+ if entry.get("runtime"):
410
+ lines.append(f"Runtime: {entry['runtime']}")
411
+ if entry.get("level"):
412
+ lines.append(f"Level: {entry['level']}")
413
+ if entry.get("mode"):
414
+ lines.append(f"Mode: {entry['mode']}")
415
+ if entry.get("execution_level"):
416
+ lines.append(f"Execution level: {entry['execution_level']}")
417
+ if entry.get("domain"):
418
+ lines.append(f"Domain: {entry['domain']}")
419
+ return "\n".join(lines)
@@ -0,0 +1,19 @@
1
+ """Public MCP tools for the live NEXO system catalog / ontology."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from system_catalog import build_system_catalog, explain_tool, format_catalog, format_tool_explanation
6
+
7
+
8
+ def handle_system_catalog(section: str = "", query: str = "", limit: int = 20) -> str:
9
+ catalog = build_system_catalog()
10
+ return format_catalog(
11
+ catalog,
12
+ section=(section or "").strip(),
13
+ query=(query or "").strip(),
14
+ limit=max(1, int(limit or 20)),
15
+ )
16
+
17
+
18
+ def handle_tool_explain(name: str) -> str:
19
+ return format_tool_explanation(explain_tool(name))
@@ -0,0 +1,98 @@
1
+ """Public MCP tools for transcript fallback access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from transcript_utils import (
6
+ clamp_transcript_hours,
7
+ list_recent_transcripts,
8
+ load_transcript,
9
+ search_transcripts,
10
+ )
11
+
12
+
13
+ def handle_transcript_search(query: str = "", hours: int = 24, client: str = "", limit: int = 10) -> str:
14
+ """Search recent Claude Code / Codex transcripts as a fallback when memory is insufficient."""
15
+ window = clamp_transcript_hours(hours)
16
+ rows = search_transcripts(query or "", hours=window, client=(client or "").strip(), limit=limit)
17
+ if not rows:
18
+ scope = f"query='{query}'" if query else "recent transcripts"
19
+ return f"No transcript matches for {scope} in the last {window}h."
20
+
21
+ lines = [f"TRANSCRIPTS ({len(rows)}) — last {window}h"]
22
+ for item in rows:
23
+ lines.append(
24
+ f"- {item.get('session_file')}: [{item.get('client')}] {item.get('display_name')} "
25
+ f"(modified={item.get('modified')}, messages={item.get('message_count')}, user={item.get('user_message_count')})"
26
+ )
27
+ if item.get("cwd"):
28
+ lines.append(f" cwd: {item['cwd']}")
29
+ if item.get("session_uid"):
30
+ lines.append(f" session_uid: {item['session_uid']}")
31
+ for snippet in item.get("matched_messages") or []:
32
+ lines.append(
33
+ f" [{snippet.get('role')}#{snippet.get('index')}] {snippet.get('snippet')}"
34
+ )
35
+ return "\n".join(lines)
36
+
37
+
38
+ def handle_transcript_recent(hours: int = 24, client: str = "", limit: int = 10) -> str:
39
+ """List recent transcripts without searching full text."""
40
+ window = clamp_transcript_hours(hours)
41
+ rows = list_recent_transcripts(hours=window, client=(client or "").strip(), limit=limit)
42
+ if not rows:
43
+ return f"No transcripts found in the last {window}h."
44
+
45
+ lines = [f"RECENT TRANSCRIPTS ({len(rows)}) — last {window}h"]
46
+ for item in rows:
47
+ lines.append(
48
+ f"- {item.get('session_file')}: [{item.get('client')}] {item.get('display_name')} "
49
+ f"(modified={item.get('modified')}, messages={item.get('message_count')}, user={item.get('user_message_count')})"
50
+ )
51
+ return "\n".join(lines)
52
+
53
+
54
+ def handle_transcript_read(
55
+ session_ref: str = "",
56
+ transcript_path: str = "",
57
+ client: str = "",
58
+ max_messages: int = 80,
59
+ ) -> str:
60
+ """Read a transcript in fallback mode. Accepts session_file, display name, session_uid or exact path."""
61
+ transcript = load_transcript(
62
+ session_ref=(session_ref or "").strip(),
63
+ transcript_path=(transcript_path or "").strip(),
64
+ client=(client or "").strip(),
65
+ )
66
+ if not transcript:
67
+ target = session_ref or transcript_path or "(empty ref)"
68
+ return f"Transcript not found for {target}."
69
+
70
+ limit = max(1, min(int(max_messages or 80), 200))
71
+ messages = transcript.get("messages") or []
72
+ truncated = len(messages) > limit
73
+ visible = messages[-limit:] if truncated else messages
74
+
75
+ lines = [
76
+ f"TRANSCRIPT {transcript.get('session_file')}",
77
+ f"Client: {transcript.get('client')}",
78
+ f"Display: {transcript.get('display_name')}",
79
+ f"Path: {transcript.get('session_path')}",
80
+ f"Modified: {transcript.get('modified')}",
81
+ f"Messages: {transcript.get('message_count')} (user={transcript.get('user_message_count')}, tools={transcript.get('tool_use_count')})",
82
+ ]
83
+ if transcript.get("cwd"):
84
+ lines.append(f"CWD: {transcript.get('cwd')}")
85
+ if transcript.get("session_uid"):
86
+ lines.append(f"Session UID: {transcript.get('session_uid')}")
87
+ if truncated:
88
+ lines.append(f"Showing last {limit} messages.")
89
+
90
+ for message in visible:
91
+ role = str(message.get("role") or "?").upper()
92
+ index = message.get("index", "?")
93
+ text = str(message.get("text") or "").strip()
94
+ lines.append("")
95
+ lines.append(f"[{role} #{index}]")
96
+ lines.append(text)
97
+
98
+ return "\n".join(lines)