nexus-dev 3.2.0__py3-none-any.whl
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.
Potentially problematic release.
This version of nexus-dev might be problematic. Click here for more details.
- nexus_dev/__init__.py +4 -0
- nexus_dev/agent_templates/__init__.py +26 -0
- nexus_dev/agent_templates/api_designer.yaml +26 -0
- nexus_dev/agent_templates/code_reviewer.yaml +26 -0
- nexus_dev/agent_templates/debug_detective.yaml +26 -0
- nexus_dev/agent_templates/doc_writer.yaml +26 -0
- nexus_dev/agent_templates/performance_optimizer.yaml +26 -0
- nexus_dev/agent_templates/refactor_architect.yaml +26 -0
- nexus_dev/agent_templates/security_auditor.yaml +26 -0
- nexus_dev/agent_templates/test_engineer.yaml +26 -0
- nexus_dev/agents/__init__.py +20 -0
- nexus_dev/agents/agent_config.py +97 -0
- nexus_dev/agents/agent_executor.py +197 -0
- nexus_dev/agents/agent_manager.py +104 -0
- nexus_dev/agents/prompt_factory.py +91 -0
- nexus_dev/chunkers/__init__.py +168 -0
- nexus_dev/chunkers/base.py +202 -0
- nexus_dev/chunkers/docs_chunker.py +291 -0
- nexus_dev/chunkers/java_chunker.py +343 -0
- nexus_dev/chunkers/javascript_chunker.py +312 -0
- nexus_dev/chunkers/python_chunker.py +308 -0
- nexus_dev/cli.py +1673 -0
- nexus_dev/config.py +253 -0
- nexus_dev/database.py +558 -0
- nexus_dev/embeddings.py +585 -0
- nexus_dev/gateway/__init__.py +10 -0
- nexus_dev/gateway/connection_manager.py +348 -0
- nexus_dev/github_importer.py +247 -0
- nexus_dev/mcp_client.py +281 -0
- nexus_dev/mcp_config.py +184 -0
- nexus_dev/schemas/mcp_config_schema.json +166 -0
- nexus_dev/server.py +1866 -0
- nexus_dev/templates/pre-commit-hook +33 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/__init__.py +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/api_designer.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/code_reviewer.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/debug_detective.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/doc_writer.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/performance_optimizer.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/refactor_architect.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/security_auditor.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/agent_templates/test_engineer.yaml +26 -0
- nexus_dev-3.2.0.data/data/nexus_dev/templates/pre-commit-hook +33 -0
- nexus_dev-3.2.0.dist-info/METADATA +636 -0
- nexus_dev-3.2.0.dist-info/RECORD +48 -0
- nexus_dev-3.2.0.dist-info/WHEEL +4 -0
- nexus_dev-3.2.0.dist-info/entry_points.txt +12 -0
- nexus_dev-3.2.0.dist-info/licenses/LICENSE +21 -0
nexus_dev/server.py
ADDED
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
"""Nexus-Dev MCP Server.
|
|
2
|
+
|
|
3
|
+
This module implements the MCP server using FastMCP, exposing tools for:
|
|
4
|
+
- search_code: Semantic search across indexed code and documentation
|
|
5
|
+
- index_file: Index a file into the knowledge base
|
|
6
|
+
- record_lesson: Store a problem/solution pair
|
|
7
|
+
- get_project_context: Get recent discoveries for a project
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
from mcp.server.fastmcp.server import Context
|
|
21
|
+
|
|
22
|
+
from .agents import AgentConfig, AgentExecutor, AgentManager
|
|
23
|
+
from .chunkers import ChunkerRegistry, ChunkType, CodeChunk
|
|
24
|
+
from .config import NexusConfig
|
|
25
|
+
from .database import Document, DocumentType, NexusDatabase, generate_document_id
|
|
26
|
+
from .embeddings import EmbeddingProvider, create_embedder
|
|
27
|
+
from .gateway.connection_manager import ConnectionManager
|
|
28
|
+
from .github_importer import GitHubImporter
|
|
29
|
+
from .mcp_config import MCPConfig
|
|
30
|
+
|
|
31
|
+
# Initialize FastMCP server
|
|
32
|
+
mcp = FastMCP("nexus-dev")
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Global state (initialized on startup)
|
|
37
|
+
_config: NexusConfig | None = None
|
|
38
|
+
_embedder: EmbeddingProvider | None = None
|
|
39
|
+
_database: NexusDatabase | None = None
|
|
40
|
+
_mcp_config: MCPConfig | None = None
|
|
41
|
+
_connection_manager: ConnectionManager | None = None
|
|
42
|
+
_agent_manager: AgentManager | None = None
|
|
43
|
+
_project_root: Path | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_project_root() -> Path | None:
|
|
47
|
+
"""Find the project root by looking for nexus_config.json.
|
|
48
|
+
|
|
49
|
+
Walks up from the current directory to find nexus_config.json.
|
|
50
|
+
Also checks NEXUS_PROJECT_ROOT environment variable as a fallback.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to project root if found, None otherwise.
|
|
54
|
+
"""
|
|
55
|
+
global _project_root
|
|
56
|
+
if _project_root:
|
|
57
|
+
return _project_root
|
|
58
|
+
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
# First check environment variable
|
|
62
|
+
env_root = os.environ.get("NEXUS_PROJECT_ROOT")
|
|
63
|
+
if env_root:
|
|
64
|
+
env_path = Path(env_root)
|
|
65
|
+
if (env_path / "nexus_config.json").exists():
|
|
66
|
+
logger.debug("Found project root from NEXUS_PROJECT_ROOT: %s", env_path)
|
|
67
|
+
return env_path
|
|
68
|
+
|
|
69
|
+
current = Path.cwd().resolve()
|
|
70
|
+
logger.debug("Searching for project root from cwd: %s", current)
|
|
71
|
+
|
|
72
|
+
# Walk up the directory tree
|
|
73
|
+
for parent in [current] + list(current.parents):
|
|
74
|
+
if (parent / "nexus_config.json").exists():
|
|
75
|
+
logger.debug("Found project root: %s", parent)
|
|
76
|
+
_project_root = parent
|
|
77
|
+
return parent
|
|
78
|
+
# Stop at filesystem root
|
|
79
|
+
if parent == parent.parent:
|
|
80
|
+
logger.debug("Reached filesystem root without finding nexus_config.json")
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
logger.debug("No project root found (no nexus_config.json in directory tree)")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_config() -> NexusConfig | None:
|
|
88
|
+
"""Get or load configuration.
|
|
89
|
+
|
|
90
|
+
Returns None if no nexus_config.json exists in cwd.
|
|
91
|
+
This allows the MCP server to work without a project-specific config,
|
|
92
|
+
enabling cross-project searches.
|
|
93
|
+
"""
|
|
94
|
+
global _config
|
|
95
|
+
if _config is None:
|
|
96
|
+
root = _find_project_root()
|
|
97
|
+
config_path = (root if root else Path.cwd()) / "nexus_config.json"
|
|
98
|
+
if config_path.exists():
|
|
99
|
+
_config = NexusConfig.load(config_path)
|
|
100
|
+
# Don't create default - None means "all projects"
|
|
101
|
+
return _config
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _get_mcp_config() -> MCPConfig | None:
|
|
105
|
+
"""Get or load MCP configuration.
|
|
106
|
+
|
|
107
|
+
Returns None if no .nexus/mcp_config.json exists in cwd or project root.
|
|
108
|
+
"""
|
|
109
|
+
global _mcp_config
|
|
110
|
+
if _mcp_config is None:
|
|
111
|
+
root = _find_project_root()
|
|
112
|
+
config_path = (root if root else Path.cwd()) / ".nexus" / "mcp_config.json"
|
|
113
|
+
if config_path.exists():
|
|
114
|
+
try:
|
|
115
|
+
_mcp_config = MCPConfig.load(config_path)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.debug("Failed to load MCP config from %s: %s", config_path, e)
|
|
118
|
+
pass
|
|
119
|
+
return _mcp_config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_active_server_names() -> list[str]:
|
|
123
|
+
"""Get names of active MCP servers.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of active server names.
|
|
127
|
+
"""
|
|
128
|
+
mcp_config = _get_mcp_config()
|
|
129
|
+
if not mcp_config:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
# Find the name for each active server config
|
|
133
|
+
active_servers = mcp_config.get_active_servers()
|
|
134
|
+
active_names = []
|
|
135
|
+
for name, config in mcp_config.servers.items():
|
|
136
|
+
if config in active_servers:
|
|
137
|
+
active_names.append(name)
|
|
138
|
+
return active_names
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_connection_manager() -> ConnectionManager:
|
|
142
|
+
"""Get or create connection manager singleton.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
ConnectionManager instance for managing MCP server connections.
|
|
146
|
+
"""
|
|
147
|
+
global _connection_manager
|
|
148
|
+
if _connection_manager is None:
|
|
149
|
+
_connection_manager = ConnectionManager()
|
|
150
|
+
return _connection_manager
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_embedder() -> EmbeddingProvider:
|
|
154
|
+
"""Get or create embedding provider."""
|
|
155
|
+
global _embedder
|
|
156
|
+
if _embedder is None:
|
|
157
|
+
config = _get_config()
|
|
158
|
+
if config is None:
|
|
159
|
+
# Create minimal config for embeddings only
|
|
160
|
+
config = NexusConfig.create_new("default")
|
|
161
|
+
_embedder = create_embedder(config)
|
|
162
|
+
return _embedder
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _get_database() -> NexusDatabase:
|
|
166
|
+
"""Get or create database connection."""
|
|
167
|
+
global _database
|
|
168
|
+
if _database is None:
|
|
169
|
+
config = _get_config()
|
|
170
|
+
if config is None:
|
|
171
|
+
# Create minimal config for database access
|
|
172
|
+
config = NexusConfig.create_new("default")
|
|
173
|
+
embedder = _get_embedder()
|
|
174
|
+
_database = NexusDatabase(config, embedder)
|
|
175
|
+
_database.connect()
|
|
176
|
+
return _database
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _index_chunks(
|
|
180
|
+
chunks: list[CodeChunk],
|
|
181
|
+
project_id: str,
|
|
182
|
+
doc_type: DocumentType,
|
|
183
|
+
) -> list[str]:
|
|
184
|
+
"""Index a list of chunks into the database.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
chunks: Code chunks to index.
|
|
188
|
+
project_id: Project identifier.
|
|
189
|
+
doc_type: Type of document.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of document IDs.
|
|
193
|
+
"""
|
|
194
|
+
if not chunks:
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
embedder = _get_embedder()
|
|
198
|
+
database = _get_database()
|
|
199
|
+
|
|
200
|
+
# Generate embeddings for all chunks
|
|
201
|
+
texts = [chunk.get_searchable_text() for chunk in chunks]
|
|
202
|
+
embeddings = await embedder.embed_batch(texts)
|
|
203
|
+
|
|
204
|
+
# Create documents
|
|
205
|
+
documents = []
|
|
206
|
+
for chunk, embedding in zip(chunks, embeddings, strict=True):
|
|
207
|
+
doc_id = generate_document_id(
|
|
208
|
+
project_id,
|
|
209
|
+
chunk.file_path,
|
|
210
|
+
chunk.name,
|
|
211
|
+
chunk.start_line,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
doc = Document(
|
|
215
|
+
id=doc_id,
|
|
216
|
+
text=chunk.get_searchable_text(),
|
|
217
|
+
vector=embedding,
|
|
218
|
+
project_id=project_id,
|
|
219
|
+
file_path=chunk.file_path,
|
|
220
|
+
doc_type=doc_type,
|
|
221
|
+
chunk_type=chunk.chunk_type.value,
|
|
222
|
+
language=chunk.language,
|
|
223
|
+
name=chunk.name,
|
|
224
|
+
start_line=chunk.start_line,
|
|
225
|
+
end_line=chunk.end_line,
|
|
226
|
+
)
|
|
227
|
+
documents.append(doc)
|
|
228
|
+
|
|
229
|
+
# Upsert documents
|
|
230
|
+
return await database.upsert_documents(documents)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@mcp.tool()
|
|
234
|
+
async def search_knowledge(
|
|
235
|
+
query: str,
|
|
236
|
+
content_type: str = "all",
|
|
237
|
+
project_id: str | None = None,
|
|
238
|
+
limit: int = 5,
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Search all indexed knowledge including code, documentation, and lessons.
|
|
241
|
+
|
|
242
|
+
This is the main search tool that can find relevant information across all
|
|
243
|
+
indexed content types. Use the content_type parameter to filter results.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
query: Natural language search query describing what you're looking for.
|
|
247
|
+
Examples: "function that handles user authentication",
|
|
248
|
+
"how to configure the database", "error with null pointer".
|
|
249
|
+
content_type: Filter by content type. Options:
|
|
250
|
+
- "all": Search everything (default)
|
|
251
|
+
- "code": Only search code (functions, classes, methods)
|
|
252
|
+
- "documentation": Only search docs (markdown, rst, txt)
|
|
253
|
+
- "lesson": Only search recorded lessons
|
|
254
|
+
project_id: Optional project identifier to limit search scope.
|
|
255
|
+
If not provided, searches across all projects.
|
|
256
|
+
limit: Maximum number of results to return (default: 5, max: 20).
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Formatted search results with file paths, content, and relevance info.
|
|
260
|
+
"""
|
|
261
|
+
database = _get_database()
|
|
262
|
+
|
|
263
|
+
# Only filter by project if explicitly specified
|
|
264
|
+
# None = search across all projects
|
|
265
|
+
|
|
266
|
+
# Clamp limit
|
|
267
|
+
limit = min(max(1, limit), 20)
|
|
268
|
+
|
|
269
|
+
# Map content_type to DocumentType
|
|
270
|
+
doc_type_filter = None
|
|
271
|
+
if content_type == "code":
|
|
272
|
+
doc_type_filter = DocumentType.CODE
|
|
273
|
+
elif content_type == "documentation":
|
|
274
|
+
doc_type_filter = DocumentType.DOCUMENTATION
|
|
275
|
+
elif content_type == "lesson":
|
|
276
|
+
doc_type_filter = DocumentType.LESSON
|
|
277
|
+
# "all" means no filter
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
results = await database.search(
|
|
281
|
+
query=query,
|
|
282
|
+
project_id=project_id, # None = all projects
|
|
283
|
+
doc_type=doc_type_filter,
|
|
284
|
+
limit=limit,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if not results:
|
|
288
|
+
return f"No results found for query: '{query}'" + (
|
|
289
|
+
f" (filtered by {content_type})" if content_type != "all" else ""
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Format results
|
|
293
|
+
content_label = f" [{content_type.upper()}]" if content_type != "all" else ""
|
|
294
|
+
output_parts = [f"## Search Results{content_label}: '{query}'", ""]
|
|
295
|
+
|
|
296
|
+
for i, result in enumerate(results, 1):
|
|
297
|
+
type_badge = f"[{result.doc_type.upper()}]"
|
|
298
|
+
output_parts.append(f"### Result {i}: {type_badge} {result.name}")
|
|
299
|
+
output_parts.append(f"**File:** `{result.file_path}`")
|
|
300
|
+
output_parts.append(f"**Type:** {result.chunk_type} ({result.language})")
|
|
301
|
+
if result.start_line > 0:
|
|
302
|
+
output_parts.append(f"**Lines:** {result.start_line}-{result.end_line}")
|
|
303
|
+
output_parts.append("")
|
|
304
|
+
output_parts.append("```" + result.language)
|
|
305
|
+
output_parts.append(result.text[:2000]) # Truncate long content
|
|
306
|
+
if len(result.text) > 2000:
|
|
307
|
+
output_parts.append("... (truncated)")
|
|
308
|
+
output_parts.append("```")
|
|
309
|
+
output_parts.append("")
|
|
310
|
+
|
|
311
|
+
return "\n".join(output_parts)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return f"Search failed: {e!s}"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@mcp.tool()
|
|
318
|
+
async def search_docs(
|
|
319
|
+
query: str,
|
|
320
|
+
project_id: str | None = None,
|
|
321
|
+
limit: int = 5,
|
|
322
|
+
) -> str:
|
|
323
|
+
"""Search specifically in documentation (Markdown, RST, text files).
|
|
324
|
+
|
|
325
|
+
Use this tool when you need to find information in project documentation,
|
|
326
|
+
README files, or other text documentation. This is more targeted than
|
|
327
|
+
search_knowledge when you know the answer is in the docs.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
query: Natural language search query.
|
|
331
|
+
Examples: "how to install", "API configuration", "usage examples".
|
|
332
|
+
project_id: Optional project identifier. Searches all projects if not specified.
|
|
333
|
+
limit: Maximum number of results (default: 5, max: 20).
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Formatted documentation search results.
|
|
337
|
+
"""
|
|
338
|
+
database = _get_database()
|
|
339
|
+
limit = min(max(1, limit), 20)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
results = await database.search(
|
|
343
|
+
query=query,
|
|
344
|
+
project_id=project_id, # None = all projects
|
|
345
|
+
doc_type=DocumentType.DOCUMENTATION,
|
|
346
|
+
limit=limit,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if not results:
|
|
350
|
+
return f"No documentation found for: '{query}'"
|
|
351
|
+
|
|
352
|
+
output_parts = [f"## Documentation Search: '{query}'", ""]
|
|
353
|
+
|
|
354
|
+
for i, result in enumerate(results, 1):
|
|
355
|
+
output_parts.append(f"### {i}. {result.name}")
|
|
356
|
+
output_parts.append(f"**Source:** `{result.file_path}`")
|
|
357
|
+
output_parts.append("")
|
|
358
|
+
# For docs, render as markdown directly
|
|
359
|
+
output_parts.append(result.text[:2500])
|
|
360
|
+
if len(result.text) > 2500:
|
|
361
|
+
output_parts.append("\n... (truncated)")
|
|
362
|
+
output_parts.append("")
|
|
363
|
+
output_parts.append("---")
|
|
364
|
+
output_parts.append("")
|
|
365
|
+
|
|
366
|
+
return "\n".join(output_parts)
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
return f"Documentation search failed: {e!s}"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@mcp.tool()
|
|
373
|
+
async def search_lessons(
|
|
374
|
+
query: str,
|
|
375
|
+
project_id: str | None = None,
|
|
376
|
+
limit: int = 5,
|
|
377
|
+
) -> str:
|
|
378
|
+
"""Search in recorded lessons (problems and solutions).
|
|
379
|
+
|
|
380
|
+
Use this tool when you encounter an error or problem that might have been
|
|
381
|
+
solved before. Lessons contain problem descriptions and their solutions,
|
|
382
|
+
making them ideal for troubleshooting similar issues.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
query: Description of the problem or error you're facing.
|
|
386
|
+
Examples: "TypeError with None", "database connection timeout",
|
|
387
|
+
"how to fix import error".
|
|
388
|
+
project_id: Optional project identifier. Searches all projects if not specified,
|
|
389
|
+
enabling cross-project learning.
|
|
390
|
+
limit: Maximum number of results (default: 5, max: 20).
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Relevant lessons with problems and solutions.
|
|
394
|
+
"""
|
|
395
|
+
database = _get_database()
|
|
396
|
+
limit = min(max(1, limit), 20)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
results = await database.search(
|
|
400
|
+
query=query,
|
|
401
|
+
project_id=project_id, # None = all projects (cross-project learning)
|
|
402
|
+
doc_type=DocumentType.LESSON,
|
|
403
|
+
limit=limit,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if not results:
|
|
407
|
+
return (
|
|
408
|
+
f"No lessons found matching: '{query}'\n\n"
|
|
409
|
+
"Tip: Use record_lesson to save problems and solutions for future reference."
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
output_parts = [f"## Lessons Found: '{query}'", ""]
|
|
413
|
+
|
|
414
|
+
for i, result in enumerate(results, 1):
|
|
415
|
+
output_parts.append(f"### Lesson {i}")
|
|
416
|
+
output_parts.append(f"**ID:** {result.name}")
|
|
417
|
+
output_parts.append(f"**Project:** {result.project_id}")
|
|
418
|
+
output_parts.append("")
|
|
419
|
+
output_parts.append(result.text)
|
|
420
|
+
output_parts.append("")
|
|
421
|
+
output_parts.append("---")
|
|
422
|
+
output_parts.append("")
|
|
423
|
+
|
|
424
|
+
return "\n".join(output_parts)
|
|
425
|
+
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return f"Lesson search failed: {e!s}"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@mcp.tool()
|
|
431
|
+
async def search_code(
|
|
432
|
+
query: str,
|
|
433
|
+
project_id: str | None = None,
|
|
434
|
+
limit: int = 5,
|
|
435
|
+
) -> str:
|
|
436
|
+
"""Search specifically in indexed code (functions, classes, methods).
|
|
437
|
+
|
|
438
|
+
Use this tool when you need to find code implementations, function definitions,
|
|
439
|
+
or class structures. This is more targeted than search_knowledge when you
|
|
440
|
+
specifically need code, not documentation.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
query: Description of the code you're looking for.
|
|
444
|
+
Examples: "function that handles authentication",
|
|
445
|
+
"class for database connections", "method to validate input".
|
|
446
|
+
project_id: Optional project identifier. Searches all projects if not specified.
|
|
447
|
+
limit: Maximum number of results (default: 5, max: 20).
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Relevant code snippets with file locations.
|
|
451
|
+
"""
|
|
452
|
+
database = _get_database()
|
|
453
|
+
limit = min(max(1, limit), 20)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
results = await database.search(
|
|
457
|
+
query=query,
|
|
458
|
+
project_id=project_id, # None = all projects
|
|
459
|
+
doc_type=DocumentType.CODE,
|
|
460
|
+
limit=limit,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if not results:
|
|
464
|
+
return f"No code found for: '{query}'"
|
|
465
|
+
|
|
466
|
+
output_parts = [f"## Code Search: '{query}'", ""]
|
|
467
|
+
|
|
468
|
+
for i, result in enumerate(results, 1):
|
|
469
|
+
output_parts.append(f"### {i}. {result.chunk_type}: {result.name}")
|
|
470
|
+
output_parts.append(f"**File:** `{result.file_path}`")
|
|
471
|
+
output_parts.append(f"**Lines:** {result.start_line}-{result.end_line}")
|
|
472
|
+
output_parts.append(f"**Language:** {result.language}")
|
|
473
|
+
output_parts.append("")
|
|
474
|
+
output_parts.append("```" + result.language)
|
|
475
|
+
output_parts.append(result.text[:2000])
|
|
476
|
+
if len(result.text) > 2000:
|
|
477
|
+
output_parts.append("... (truncated)")
|
|
478
|
+
output_parts.append("```")
|
|
479
|
+
output_parts.append("")
|
|
480
|
+
|
|
481
|
+
return "\n".join(output_parts)
|
|
482
|
+
|
|
483
|
+
except Exception as e:
|
|
484
|
+
return f"Code search failed: {e!s}"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@mcp.tool()
|
|
488
|
+
async def search_tools(
|
|
489
|
+
query: str,
|
|
490
|
+
server: str | None = None,
|
|
491
|
+
limit: int = 5,
|
|
492
|
+
) -> str:
|
|
493
|
+
"""Search for MCP tools matching a description.
|
|
494
|
+
|
|
495
|
+
Use this tool to find other MCP tools when you need to perform an action
|
|
496
|
+
but don't know which tool to use. Returns tool names, descriptions, and
|
|
497
|
+
parameter schemas.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
query: Natural language description of what you want to do.
|
|
501
|
+
Examples: "create a GitHub issue", "list files in directory",
|
|
502
|
+
"send a notification to Home Assistant"
|
|
503
|
+
server: Optional server name to filter results (e.g., "github").
|
|
504
|
+
limit: Maximum results to return (default: 5, max: 10).
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Matching tools with server, name, description, and parameters.
|
|
508
|
+
"""
|
|
509
|
+
database = _get_database()
|
|
510
|
+
limit = min(max(1, limit), 10)
|
|
511
|
+
|
|
512
|
+
# Search for tools
|
|
513
|
+
results = await database.search(
|
|
514
|
+
query=query,
|
|
515
|
+
doc_type=DocumentType.TOOL,
|
|
516
|
+
limit=limit,
|
|
517
|
+
)
|
|
518
|
+
logger.debug("[%s] Searching tools with query='%s'", "nexus-dev", query)
|
|
519
|
+
try:
|
|
520
|
+
logger.debug("[%s] DB Path in use: %s", "nexus-dev", database.config.get_db_path())
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.debug("[%s] Could not print DB path: %s", "nexus-dev", e)
|
|
523
|
+
|
|
524
|
+
logger.debug("[%s] Results found: %d", "nexus-dev", len(results))
|
|
525
|
+
if results:
|
|
526
|
+
logger.debug("[%s] First result: %s (%s)", "nexus-dev", results[0].name, results[0].score)
|
|
527
|
+
|
|
528
|
+
# Filter by server if specified
|
|
529
|
+
if server and results:
|
|
530
|
+
results = [r for r in results if r.server_name == server]
|
|
531
|
+
|
|
532
|
+
if not results:
|
|
533
|
+
if server:
|
|
534
|
+
return f"No tools found matching: '{query}' in server: '{server}'"
|
|
535
|
+
return f"No tools found matching: '{query}'"
|
|
536
|
+
|
|
537
|
+
# Format output
|
|
538
|
+
output_parts = [f"## MCP Tools matching: '{query}'", ""]
|
|
539
|
+
|
|
540
|
+
for i, result in enumerate(results, 1):
|
|
541
|
+
# Parse parameters schema from stored JSON
|
|
542
|
+
params = json.loads(result.parameters_schema) if result.parameters_schema else {}
|
|
543
|
+
|
|
544
|
+
output_parts.append(f"### {i}. {result.server_name}.{result.name}")
|
|
545
|
+
output_parts.append(f"**Description:** {result.text}")
|
|
546
|
+
output_parts.append("")
|
|
547
|
+
if params:
|
|
548
|
+
output_parts.append("**Parameters:**")
|
|
549
|
+
output_parts.append("```json")
|
|
550
|
+
output_parts.append(json.dumps(params, indent=2))
|
|
551
|
+
output_parts.append("```")
|
|
552
|
+
output_parts.append("")
|
|
553
|
+
|
|
554
|
+
return "\n".join(output_parts)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@mcp.tool()
|
|
558
|
+
async def list_servers() -> str:
|
|
559
|
+
"""List all configured MCP servers and their status.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
List of MCP servers with connection status.
|
|
563
|
+
"""
|
|
564
|
+
mcp_config = _get_mcp_config()
|
|
565
|
+
if not mcp_config:
|
|
566
|
+
return "No MCP config. Run 'nexus-mcp init' first."
|
|
567
|
+
|
|
568
|
+
output = ["## MCP Servers", ""]
|
|
569
|
+
|
|
570
|
+
active = mcp_config.get_active_servers()
|
|
571
|
+
active_names = {name for name, cfg in mcp_config.servers.items() if cfg in active}
|
|
572
|
+
|
|
573
|
+
output.append("### Active")
|
|
574
|
+
if active_names:
|
|
575
|
+
for name in sorted(active_names):
|
|
576
|
+
server = mcp_config.servers[name]
|
|
577
|
+
details = ""
|
|
578
|
+
if server.transport in ("sse", "http"):
|
|
579
|
+
details = f"{server.transport.upper()}: {server.url}"
|
|
580
|
+
else:
|
|
581
|
+
details = f"Command: {server.command} {' '.join(server.args)}"
|
|
582
|
+
output.append(f"- **{name}**: `{details}`")
|
|
583
|
+
else:
|
|
584
|
+
output.append("*No active servers*")
|
|
585
|
+
|
|
586
|
+
output.append("")
|
|
587
|
+
output.append("### Disabled")
|
|
588
|
+
disabled = [name for name, server in mcp_config.servers.items() if name not in active_names]
|
|
589
|
+
if disabled:
|
|
590
|
+
for name in sorted(disabled):
|
|
591
|
+
server = mcp_config.servers[name]
|
|
592
|
+
status = "disabled" if not server.enabled else "not in profile"
|
|
593
|
+
output.append(f"- {name} ({status})")
|
|
594
|
+
else:
|
|
595
|
+
output.append("*No disabled servers*")
|
|
596
|
+
|
|
597
|
+
return "\n".join(output)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@mcp.tool()
|
|
601
|
+
async def get_tool_schema(server: str, tool: str) -> str:
|
|
602
|
+
"""Get the full JSON schema for a specific MCP tool.
|
|
603
|
+
|
|
604
|
+
Use this after search_tools to get complete parameter details
|
|
605
|
+
before calling invoke_tool.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
server: Server name (e.g., "github")
|
|
609
|
+
tool: Tool name (e.g., "create_pull_request")
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
Full JSON schema with parameter types and descriptions.
|
|
613
|
+
"""
|
|
614
|
+
mcp_config = _get_mcp_config()
|
|
615
|
+
if not mcp_config:
|
|
616
|
+
return "No MCP config. Run 'nexus-mcp init' first."
|
|
617
|
+
|
|
618
|
+
if server not in mcp_config.servers:
|
|
619
|
+
available = ", ".join(sorted(mcp_config.servers.keys()))
|
|
620
|
+
return f"Server not found: {server}. Available: {available}"
|
|
621
|
+
|
|
622
|
+
server_config = mcp_config.servers[server]
|
|
623
|
+
if not server_config.enabled:
|
|
624
|
+
return f"Server is disabled: {server}"
|
|
625
|
+
|
|
626
|
+
conn_manager = _get_connection_manager()
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
session = await conn_manager.get_connection(server, server_config)
|
|
630
|
+
tools_result = await session.list_tools()
|
|
631
|
+
|
|
632
|
+
for t in tools_result.tools:
|
|
633
|
+
if t.name == tool:
|
|
634
|
+
return json.dumps(
|
|
635
|
+
{
|
|
636
|
+
"server": server,
|
|
637
|
+
"tool": tool,
|
|
638
|
+
"description": t.description or "",
|
|
639
|
+
"parameters": t.inputSchema or {},
|
|
640
|
+
},
|
|
641
|
+
indent=2,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
available_tools = [t.name for t in tools_result.tools[:10]]
|
|
645
|
+
hint = f" Available: {', '.join(available_tools)}..." if available_tools else ""
|
|
646
|
+
return f"Tool not found: {server}.{tool}.{hint}"
|
|
647
|
+
|
|
648
|
+
except Exception as e:
|
|
649
|
+
return f"Error connecting to {server}: {e}"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@mcp.tool()
|
|
653
|
+
async def invoke_tool(
|
|
654
|
+
server: str,
|
|
655
|
+
tool: str,
|
|
656
|
+
arguments: dict[str, Any] | None = None,
|
|
657
|
+
) -> str:
|
|
658
|
+
"""Invoke a tool on a backend MCP server.
|
|
659
|
+
|
|
660
|
+
Use search_tools first to find the right tool, then use this
|
|
661
|
+
to execute it.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
server: MCP server name (e.g., "github", "homeassistant")
|
|
665
|
+
tool: Tool name (e.g., "create_issue", "turn_on_light")
|
|
666
|
+
arguments: Tool arguments as dictionary
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Tool execution result.
|
|
670
|
+
|
|
671
|
+
Example:
|
|
672
|
+
invoke_tool(
|
|
673
|
+
server="github",
|
|
674
|
+
tool="create_issue",
|
|
675
|
+
arguments={
|
|
676
|
+
"owner": "myorg",
|
|
677
|
+
"repo": "myrepo",
|
|
678
|
+
"title": "Bug fix",
|
|
679
|
+
"body": "Fixed the thing"
|
|
680
|
+
}
|
|
681
|
+
)
|
|
682
|
+
"""
|
|
683
|
+
mcp_config = _get_mcp_config()
|
|
684
|
+
if not mcp_config:
|
|
685
|
+
return "No MCP config. Run 'nexus-mcp init' first."
|
|
686
|
+
|
|
687
|
+
if server not in mcp_config.servers:
|
|
688
|
+
available = ", ".join(sorted(mcp_config.servers.keys()))
|
|
689
|
+
return f"Server not found: {server}. Available: {available}"
|
|
690
|
+
|
|
691
|
+
server_config = mcp_config.servers[server]
|
|
692
|
+
|
|
693
|
+
if not server_config.enabled:
|
|
694
|
+
return f"Server is disabled: {server}"
|
|
695
|
+
|
|
696
|
+
conn_manager = _get_connection_manager()
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
result = await conn_manager.invoke_tool(
|
|
700
|
+
server,
|
|
701
|
+
server_config,
|
|
702
|
+
tool,
|
|
703
|
+
arguments or {},
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Format result for AI consumption
|
|
707
|
+
if hasattr(result, "content"):
|
|
708
|
+
# MCP CallToolResult object
|
|
709
|
+
contents = []
|
|
710
|
+
for item in result.content:
|
|
711
|
+
if hasattr(item, "text"):
|
|
712
|
+
contents.append(item.text)
|
|
713
|
+
else:
|
|
714
|
+
contents.append(str(item))
|
|
715
|
+
return "\n".join(contents) if contents else "Tool executed successfully (no output)"
|
|
716
|
+
|
|
717
|
+
return str(result)
|
|
718
|
+
|
|
719
|
+
except Exception as e:
|
|
720
|
+
return f"Tool invocation failed: {e}"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@mcp.tool()
|
|
724
|
+
async def index_file(
|
|
725
|
+
file_path: str,
|
|
726
|
+
content: str | None = None,
|
|
727
|
+
project_id: str | None = None,
|
|
728
|
+
) -> str:
|
|
729
|
+
"""Index a file into the knowledge base.
|
|
730
|
+
|
|
731
|
+
Parses the file using language-aware chunking (extracting functions, classes,
|
|
732
|
+
methods) and stores it in the vector database for semantic search.
|
|
733
|
+
|
|
734
|
+
Supported file types:
|
|
735
|
+
- Python (.py, .pyw)
|
|
736
|
+
- JavaScript (.js, .jsx, .mjs, .cjs)
|
|
737
|
+
- TypeScript (.ts, .tsx, .mts, .cts)
|
|
738
|
+
- Java (.java)
|
|
739
|
+
- Markdown (.md, .markdown)
|
|
740
|
+
- RST (.rst)
|
|
741
|
+
- Plain text (.txt)
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
file_path: Path to the file (relative or absolute). The file must exist
|
|
745
|
+
unless content is provided.
|
|
746
|
+
content: Optional file content. If not provided, reads from disk.
|
|
747
|
+
project_id: Optional project identifier. Uses current project if not specified.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
Summary of indexed chunks including count and types.
|
|
751
|
+
"""
|
|
752
|
+
config = _get_config()
|
|
753
|
+
if project_id:
|
|
754
|
+
effective_project_id = project_id
|
|
755
|
+
elif config:
|
|
756
|
+
effective_project_id = config.project_id
|
|
757
|
+
else:
|
|
758
|
+
return (
|
|
759
|
+
"Error: No project_id specified and no nexus_config.json found. "
|
|
760
|
+
"Please provide project_id or run 'nexus-init' first."
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Resolve file path
|
|
764
|
+
path = Path(file_path)
|
|
765
|
+
if not path.is_absolute():
|
|
766
|
+
path = Path.cwd() / path
|
|
767
|
+
|
|
768
|
+
# Get content
|
|
769
|
+
if content is None:
|
|
770
|
+
if not path.exists():
|
|
771
|
+
return f"Error: File not found: {path}"
|
|
772
|
+
try:
|
|
773
|
+
content = path.read_text(encoding="utf-8")
|
|
774
|
+
except Exception as e:
|
|
775
|
+
return f"Error reading file: {e!s}"
|
|
776
|
+
|
|
777
|
+
# Determine document type
|
|
778
|
+
doc_type = DocumentType.CODE
|
|
779
|
+
ext = path.suffix.lower()
|
|
780
|
+
if ext in (".md", ".markdown", ".rst", ".txt"):
|
|
781
|
+
doc_type = DocumentType.DOCUMENTATION
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
# Delete existing chunks for this file
|
|
785
|
+
database = _get_database()
|
|
786
|
+
await database.delete_by_file(str(path), effective_project_id)
|
|
787
|
+
|
|
788
|
+
# Special handling for lessons to preserve them as single atomic units
|
|
789
|
+
# Check if file is in .nexus/lessons or has lesson frontmatter
|
|
790
|
+
is_lesson = ".nexus/lessons" in str(path) or (
|
|
791
|
+
content.startswith("---") and "problem:" in content[:200]
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
if is_lesson:
|
|
795
|
+
doc_type = DocumentType.LESSON
|
|
796
|
+
chunks = [
|
|
797
|
+
CodeChunk(
|
|
798
|
+
content=content,
|
|
799
|
+
chunk_type=ChunkType.LESSON,
|
|
800
|
+
name=path.stem,
|
|
801
|
+
start_line=1,
|
|
802
|
+
end_line=content.count("\n") + 1,
|
|
803
|
+
language="markdown",
|
|
804
|
+
file_path=str(path),
|
|
805
|
+
)
|
|
806
|
+
]
|
|
807
|
+
else:
|
|
808
|
+
# Chunk the file normally
|
|
809
|
+
chunks = ChunkerRegistry.chunk_file(path, content)
|
|
810
|
+
|
|
811
|
+
if not chunks:
|
|
812
|
+
return f"No indexable content found in: {file_path}"
|
|
813
|
+
|
|
814
|
+
# Index chunks
|
|
815
|
+
await _index_chunks(chunks, effective_project_id, doc_type)
|
|
816
|
+
|
|
817
|
+
# Summarize by chunk type
|
|
818
|
+
type_counts: dict[str, int] = {}
|
|
819
|
+
for chunk in chunks:
|
|
820
|
+
ctype = chunk.chunk_type.value
|
|
821
|
+
type_counts[ctype] = type_counts.get(ctype, 0) + 1
|
|
822
|
+
|
|
823
|
+
except Exception as e:
|
|
824
|
+
return f"Error indexing {path.name}: {e}"
|
|
825
|
+
|
|
826
|
+
return f"Indexed {len(chunks)} chunks from {path.name}: {type_counts}"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@mcp.tool()
|
|
830
|
+
async def import_github_issues(
|
|
831
|
+
repo: str,
|
|
832
|
+
owner: str,
|
|
833
|
+
limit: int = 10,
|
|
834
|
+
state: str = "all",
|
|
835
|
+
) -> str:
|
|
836
|
+
"""Import GitHub issues and pull requests into the knowledge base.
|
|
837
|
+
|
|
838
|
+
Imports issues from the specified repository using the 'github' MCP server.
|
|
839
|
+
Items are indexed for semantic search (search_knowledge) and can be filtered
|
|
840
|
+
by 'github_issue' or 'github_pr' content types.
|
|
841
|
+
|
|
842
|
+
Args:
|
|
843
|
+
repo: Repository name (e.g., "nexus-dev").
|
|
844
|
+
owner: Repository owner (e.g., "mmornati").
|
|
845
|
+
limit: Maximum number of issues to import (default: 10).
|
|
846
|
+
state: Issue state filter: "open" (default), "closed", or "all".
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
Summary of imported items.
|
|
850
|
+
"""
|
|
851
|
+
database = _get_database()
|
|
852
|
+
config = _get_config()
|
|
853
|
+
|
|
854
|
+
if not config:
|
|
855
|
+
return "Error: No project configuration found. Run 'nexus-init' first."
|
|
856
|
+
|
|
857
|
+
from .mcp_client import MCPClientManager
|
|
858
|
+
|
|
859
|
+
client_manager = MCPClientManager()
|
|
860
|
+
mcp_config = _get_mcp_config()
|
|
861
|
+
|
|
862
|
+
importer = GitHubImporter(database, config.project_id, client_manager, mcp_config)
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
count = await importer.import_issues(owner, repo, limit, state)
|
|
866
|
+
return f"Successfully imported {count} issues/PRs from {owner}/{repo}."
|
|
867
|
+
except Exception as e:
|
|
868
|
+
return f"Failed to import issues: {e!s}"
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
@mcp.tool()
|
|
872
|
+
async def record_lesson(
|
|
873
|
+
problem: str,
|
|
874
|
+
solution: str,
|
|
875
|
+
context: str | None = None,
|
|
876
|
+
code_snippet: str | None = None,
|
|
877
|
+
problem_code: str | None = None,
|
|
878
|
+
solution_code: str | None = None,
|
|
879
|
+
project_id: str | None = None,
|
|
880
|
+
) -> str:
|
|
881
|
+
"""Record a learned lesson from debugging or problem-solving.
|
|
882
|
+
|
|
883
|
+
Use this tool to store problems you've encountered and their solutions.
|
|
884
|
+
These lessons will be searchable and can help with similar issues in the future,
|
|
885
|
+
both in this project and across other projects.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
problem: Clear description of the problem encountered.
|
|
889
|
+
Example: "TypeError when passing None to user_service.get_user()"
|
|
890
|
+
solution: How the problem was resolved.
|
|
891
|
+
Example: "Added null check before calling get_user() and return early if None"
|
|
892
|
+
context: Optional additional context like file path, library, error message.
|
|
893
|
+
code_snippet: Optional code snippet that demonstrates the problem or solution.
|
|
894
|
+
(Deprecated: use problem_code and solution_code for better structure)
|
|
895
|
+
problem_code: Code snippet showing the problematic code.
|
|
896
|
+
solution_code: Code snippet showing the fixed code.
|
|
897
|
+
project_id: Optional project identifier. Uses current project if not specified.
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
Confirmation with lesson ID and a summary.
|
|
901
|
+
"""
|
|
902
|
+
import yaml
|
|
903
|
+
|
|
904
|
+
config = _get_config()
|
|
905
|
+
if project_id:
|
|
906
|
+
effective_project_id = project_id
|
|
907
|
+
elif config:
|
|
908
|
+
effective_project_id = config.project_id
|
|
909
|
+
else:
|
|
910
|
+
return (
|
|
911
|
+
"Error: No project_id specified and no nexus_config.json found. "
|
|
912
|
+
"Please provide project_id or run 'nexus-init' first."
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
# Create lesson text (with YAML frontmatter)
|
|
916
|
+
frontmatter = {
|
|
917
|
+
"problem": problem,
|
|
918
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
919
|
+
"project_id": effective_project_id,
|
|
920
|
+
"context": context or "",
|
|
921
|
+
"problem_code": problem_code or "",
|
|
922
|
+
"solution_code": solution_code or "",
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
lesson_parts = [
|
|
926
|
+
"---",
|
|
927
|
+
yaml.dump(frontmatter, sort_keys=False).strip(),
|
|
928
|
+
"---",
|
|
929
|
+
"",
|
|
930
|
+
"# Lesson: " + (problem[:50] + "..." if len(problem) > 50 else problem),
|
|
931
|
+
"",
|
|
932
|
+
"## Problem",
|
|
933
|
+
problem,
|
|
934
|
+
"",
|
|
935
|
+
"## Solution",
|
|
936
|
+
solution,
|
|
937
|
+
]
|
|
938
|
+
|
|
939
|
+
if context:
|
|
940
|
+
lesson_parts.extend(["", "## Context", context])
|
|
941
|
+
|
|
942
|
+
if problem_code:
|
|
943
|
+
lesson_parts.extend(["", "## Problem Code", "```", problem_code, "```"])
|
|
944
|
+
|
|
945
|
+
if solution_code:
|
|
946
|
+
lesson_parts.extend(["", "## Solution Code", "```", solution_code, "```"])
|
|
947
|
+
|
|
948
|
+
# Legacy support
|
|
949
|
+
if code_snippet and not (problem_code or solution_code):
|
|
950
|
+
lesson_parts.extend(["", "## Code", "```", code_snippet, "```"])
|
|
951
|
+
|
|
952
|
+
lesson_text = "\n".join(lesson_parts)
|
|
953
|
+
|
|
954
|
+
# Create a unique ID for this lesson
|
|
955
|
+
lesson_id = str(uuid.uuid4())[:8]
|
|
956
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
957
|
+
|
|
958
|
+
try:
|
|
959
|
+
embedder = _get_embedder()
|
|
960
|
+
database = _get_database()
|
|
961
|
+
|
|
962
|
+
# Generate embedding
|
|
963
|
+
embedding = await embedder.embed(lesson_text)
|
|
964
|
+
|
|
965
|
+
# Create document
|
|
966
|
+
doc = Document(
|
|
967
|
+
id=generate_document_id(effective_project_id, "lessons", lesson_id, 0),
|
|
968
|
+
text=lesson_text,
|
|
969
|
+
vector=embedding,
|
|
970
|
+
project_id=effective_project_id,
|
|
971
|
+
file_path=f".nexus/lessons/{lesson_id}.md",
|
|
972
|
+
doc_type=DocumentType.LESSON,
|
|
973
|
+
chunk_type="lesson",
|
|
974
|
+
language="markdown",
|
|
975
|
+
name=f"lesson_{lesson_id}",
|
|
976
|
+
start_line=0,
|
|
977
|
+
end_line=0,
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
await database.upsert_document(doc)
|
|
981
|
+
|
|
982
|
+
# Also save to .nexus/lessons directory if it exists
|
|
983
|
+
lessons_dir = Path.cwd() / ".nexus" / "lessons"
|
|
984
|
+
if lessons_dir.exists():
|
|
985
|
+
lesson_file = lessons_dir / f"{lesson_id}_{timestamp[:10]}.md"
|
|
986
|
+
try:
|
|
987
|
+
lesson_file.write_text(lesson_text, encoding="utf-8")
|
|
988
|
+
except Exception:
|
|
989
|
+
pass # Silently fail if we can't write to disk
|
|
990
|
+
|
|
991
|
+
return (
|
|
992
|
+
f"✅ Lesson recorded!\n"
|
|
993
|
+
f"- ID: {lesson_id}\n"
|
|
994
|
+
f"- Project: {effective_project_id}\n"
|
|
995
|
+
f"- Problem: {problem[:100]}{'...' if len(problem) > 100 else ''}"
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
except Exception as e:
|
|
999
|
+
return f"Failed to record lesson: {e!s}"
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@mcp.tool()
|
|
1003
|
+
async def record_insight(
|
|
1004
|
+
category: str,
|
|
1005
|
+
description: str,
|
|
1006
|
+
reasoning: str,
|
|
1007
|
+
correction: str | None = None,
|
|
1008
|
+
files_affected: list[str] | None = None,
|
|
1009
|
+
project_id: str | None = None,
|
|
1010
|
+
) -> str:
|
|
1011
|
+
"""Record an insight from LLM reasoning during development.
|
|
1012
|
+
|
|
1013
|
+
Use this tool to capture:
|
|
1014
|
+
- Mistakes made (wrong version, incompatible library, bad approach)
|
|
1015
|
+
- Discoveries during exploration (useful patterns, gotchas)
|
|
1016
|
+
- Backtracking decisions and their reasons
|
|
1017
|
+
- Optimization opportunities found
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
category: Type of insight - "mistake", "discovery", "backtrack", or "optimization"
|
|
1021
|
+
description: What happened (e.g., "Used httpx 0.23 which is incompatible with Python 3.13")
|
|
1022
|
+
reasoning: Why it happened / what you were thinking
|
|
1023
|
+
(e.g., "Assumed latest version would work, didn't check compatibility")
|
|
1024
|
+
correction: How it was fixed (for mistakes/backtracking)
|
|
1025
|
+
files_affected: Optional list of affected file paths
|
|
1026
|
+
project_id: Optional project identifier. Uses current project if not specified.
|
|
1027
|
+
|
|
1028
|
+
Returns:
|
|
1029
|
+
Confirmation with insight ID and summary.
|
|
1030
|
+
"""
|
|
1031
|
+
import yaml
|
|
1032
|
+
|
|
1033
|
+
config = _get_config()
|
|
1034
|
+
if project_id:
|
|
1035
|
+
effective_project_id = project_id
|
|
1036
|
+
elif config:
|
|
1037
|
+
effective_project_id = config.project_id
|
|
1038
|
+
else:
|
|
1039
|
+
return (
|
|
1040
|
+
"Error: No project_id specified and no nexus_config.json found. "
|
|
1041
|
+
"Please provide project_id or run 'nexus-init' first."
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Validate category
|
|
1045
|
+
valid_categories = {"mistake", "discovery", "backtrack", "optimization"}
|
|
1046
|
+
if category not in valid_categories:
|
|
1047
|
+
return f"Error: category must be one of {valid_categories}, got '{category}'"
|
|
1048
|
+
|
|
1049
|
+
# Create insight text with YAML frontmatter
|
|
1050
|
+
frontmatter = {
|
|
1051
|
+
"category": category,
|
|
1052
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
1053
|
+
"project_id": effective_project_id,
|
|
1054
|
+
"files_affected": files_affected or [],
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
insight_parts = [
|
|
1058
|
+
"---",
|
|
1059
|
+
yaml.dump(frontmatter, sort_keys=False).strip(),
|
|
1060
|
+
"---",
|
|
1061
|
+
"",
|
|
1062
|
+
f"# Insight: {category.title()}",
|
|
1063
|
+
"",
|
|
1064
|
+
"## Description",
|
|
1065
|
+
description,
|
|
1066
|
+
"",
|
|
1067
|
+
"## Reasoning",
|
|
1068
|
+
reasoning,
|
|
1069
|
+
]
|
|
1070
|
+
|
|
1071
|
+
if correction:
|
|
1072
|
+
insight_parts.extend(["", "## Correction", correction])
|
|
1073
|
+
|
|
1074
|
+
if files_affected:
|
|
1075
|
+
insight_parts.extend(["", "## Affected Files", ""])
|
|
1076
|
+
for file_path in files_affected:
|
|
1077
|
+
insight_parts.append(f"- `{file_path}`")
|
|
1078
|
+
|
|
1079
|
+
insight_text = "\n".join(insight_parts)
|
|
1080
|
+
|
|
1081
|
+
# Create unique ID
|
|
1082
|
+
insight_id = str(uuid.uuid4())[:8]
|
|
1083
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
1084
|
+
|
|
1085
|
+
try:
|
|
1086
|
+
embedder = _get_embedder()
|
|
1087
|
+
database = _get_database()
|
|
1088
|
+
|
|
1089
|
+
# Generate embedding
|
|
1090
|
+
embedding = await embedder.embed(insight_text)
|
|
1091
|
+
|
|
1092
|
+
# Create document
|
|
1093
|
+
doc = Document(
|
|
1094
|
+
id=generate_document_id(effective_project_id, "insights", insight_id, 0),
|
|
1095
|
+
text=insight_text,
|
|
1096
|
+
vector=embedding,
|
|
1097
|
+
project_id=effective_project_id,
|
|
1098
|
+
file_path=f".nexus/insights/{insight_id}.md",
|
|
1099
|
+
doc_type=DocumentType.INSIGHT,
|
|
1100
|
+
chunk_type="insight",
|
|
1101
|
+
language="markdown",
|
|
1102
|
+
name=f"{category}_{insight_id}",
|
|
1103
|
+
start_line=0,
|
|
1104
|
+
end_line=0,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
await database.upsert_document(doc)
|
|
1108
|
+
|
|
1109
|
+
# Save to .nexus/insights directory if it exists
|
|
1110
|
+
insights_dir = Path.cwd() / ".nexus" / "insights"
|
|
1111
|
+
if insights_dir.exists():
|
|
1112
|
+
insight_file = insights_dir / f"{insight_id}_{timestamp[:10]}.md"
|
|
1113
|
+
try:
|
|
1114
|
+
insight_file.write_text(insight_text, encoding="utf-8")
|
|
1115
|
+
except Exception:
|
|
1116
|
+
pass
|
|
1117
|
+
|
|
1118
|
+
return (
|
|
1119
|
+
f"✅ Insight recorded!\n"
|
|
1120
|
+
f"- ID: {insight_id}\n"
|
|
1121
|
+
f"- Category: {category}\n"
|
|
1122
|
+
f"- Project: {effective_project_id}\n"
|
|
1123
|
+
f"- Description: {description[:100]}{'...' if len(description) > 100 else ''}"
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
except Exception as e:
|
|
1127
|
+
return f"Failed to record insight: {e!s}"
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@mcp.tool()
|
|
1131
|
+
async def search_insights(
|
|
1132
|
+
query: str,
|
|
1133
|
+
category: str | None = None,
|
|
1134
|
+
project_id: str | None = None,
|
|
1135
|
+
limit: int = 5,
|
|
1136
|
+
) -> str:
|
|
1137
|
+
"""Search recorded insights from past development sessions.
|
|
1138
|
+
|
|
1139
|
+
Use when:
|
|
1140
|
+
- Starting work on similar features
|
|
1141
|
+
- Debugging issues that might have been seen before
|
|
1142
|
+
- Looking for optimization patterns
|
|
1143
|
+
- Checking if a mistake was already made
|
|
1144
|
+
|
|
1145
|
+
Args:
|
|
1146
|
+
query: Description of what you're looking for.
|
|
1147
|
+
Examples: "httpx compatibility", "authentication mistakes",
|
|
1148
|
+
"database optimization patterns"
|
|
1149
|
+
category: Optional filter - "mistake", "discovery", "backtrack", or "optimization"
|
|
1150
|
+
project_id: Optional project identifier. Searches all projects if not specified.
|
|
1151
|
+
limit: Maximum number of results (default: 5, max: 20).
|
|
1152
|
+
|
|
1153
|
+
Returns:
|
|
1154
|
+
Relevant insights with category, description, and reasoning.
|
|
1155
|
+
"""
|
|
1156
|
+
database = _get_database()
|
|
1157
|
+
limit = min(max(1, limit), 20)
|
|
1158
|
+
|
|
1159
|
+
# Validate category if provided
|
|
1160
|
+
if category:
|
|
1161
|
+
valid_categories = {"mistake", "discovery", "backtrack", "optimization"}
|
|
1162
|
+
if category not in valid_categories:
|
|
1163
|
+
return f"Error: category must be one of {valid_categories}, got '{category}'"
|
|
1164
|
+
|
|
1165
|
+
try:
|
|
1166
|
+
results = await database.search(
|
|
1167
|
+
query=query,
|
|
1168
|
+
project_id=project_id,
|
|
1169
|
+
doc_type=DocumentType.INSIGHT,
|
|
1170
|
+
limit=limit,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
# Filter by category if specified
|
|
1174
|
+
if category and results:
|
|
1175
|
+
results = [r for r in results if category in r.name]
|
|
1176
|
+
|
|
1177
|
+
if not results:
|
|
1178
|
+
msg = f"No insights found for: '{query}'"
|
|
1179
|
+
if category:
|
|
1180
|
+
msg += f" (category: {category})"
|
|
1181
|
+
return msg + "\n\nTip: Use record_insight to save insights for future reference."
|
|
1182
|
+
|
|
1183
|
+
output_parts = [f"## Insights Found: '{query}'", ""]
|
|
1184
|
+
if category:
|
|
1185
|
+
output_parts[0] += f" (category: {category})"
|
|
1186
|
+
|
|
1187
|
+
for i, result in enumerate(results, 1):
|
|
1188
|
+
output_parts.append(f"### Insight {i}")
|
|
1189
|
+
output_parts.append(f"**ID:** {result.name}")
|
|
1190
|
+
output_parts.append(f"**Project:** {result.project_id}")
|
|
1191
|
+
output_parts.append("")
|
|
1192
|
+
output_parts.append(result.text)
|
|
1193
|
+
output_parts.append("")
|
|
1194
|
+
output_parts.append("---")
|
|
1195
|
+
output_parts.append("")
|
|
1196
|
+
|
|
1197
|
+
return "\n".join(output_parts)
|
|
1198
|
+
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
return f"Insight search failed: {e!s}"
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
@mcp.tool()
|
|
1204
|
+
async def record_implementation(
|
|
1205
|
+
title: str,
|
|
1206
|
+
summary: str,
|
|
1207
|
+
approach: str,
|
|
1208
|
+
design_decisions: list[str],
|
|
1209
|
+
files_changed: list[str],
|
|
1210
|
+
related_plan: str | None = None,
|
|
1211
|
+
project_id: str | None = None,
|
|
1212
|
+
) -> str:
|
|
1213
|
+
"""Record a completed implementation for future reference.
|
|
1214
|
+
|
|
1215
|
+
Use this tool after completing a feature or significant work to capture:
|
|
1216
|
+
- What was built and why
|
|
1217
|
+
- Technical approach used
|
|
1218
|
+
- Key design decisions
|
|
1219
|
+
- Files involved
|
|
1220
|
+
|
|
1221
|
+
Args:
|
|
1222
|
+
title: Short title (e.g., "Add user authentication", "Refactor database layer")
|
|
1223
|
+
summary: What was implemented (1-3 sentences)
|
|
1224
|
+
approach: How it was done - technical approach/architecture used
|
|
1225
|
+
design_decisions: List of key decisions with rationale
|
|
1226
|
+
(e.g., ["Used JWT over sessions for stateless auth",
|
|
1227
|
+
"Chose Redis for session cache due to speed requirements"])
|
|
1228
|
+
files_changed: List of files modified/created
|
|
1229
|
+
related_plan: Optional path or URL to implementation plan
|
|
1230
|
+
project_id: Optional project identifier. Uses current project if not specified.
|
|
1231
|
+
|
|
1232
|
+
Returns:
|
|
1233
|
+
Confirmation with implementation ID and summary.
|
|
1234
|
+
"""
|
|
1235
|
+
import yaml
|
|
1236
|
+
|
|
1237
|
+
config = _get_config()
|
|
1238
|
+
if project_id:
|
|
1239
|
+
effective_project_id = project_id
|
|
1240
|
+
elif config:
|
|
1241
|
+
effective_project_id = config.project_id
|
|
1242
|
+
else:
|
|
1243
|
+
return (
|
|
1244
|
+
"Error: No project_id specified and no nexus_config.json found. "
|
|
1245
|
+
"Please provide project_id or run 'nexus-init' first."
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
# Create implementation text with YAML frontmatter
|
|
1249
|
+
frontmatter = {
|
|
1250
|
+
"title": title,
|
|
1251
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
1252
|
+
"project_id": effective_project_id,
|
|
1253
|
+
"files_changed": files_changed,
|
|
1254
|
+
"related_plan": related_plan or "",
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
impl_parts = [
|
|
1258
|
+
"---",
|
|
1259
|
+
yaml.dump(frontmatter, sort_keys=False).strip(),
|
|
1260
|
+
"---",
|
|
1261
|
+
"",
|
|
1262
|
+
f"# Implementation: {title}",
|
|
1263
|
+
"",
|
|
1264
|
+
"## Summary",
|
|
1265
|
+
summary,
|
|
1266
|
+
"",
|
|
1267
|
+
"## Technical Approach",
|
|
1268
|
+
approach,
|
|
1269
|
+
"",
|
|
1270
|
+
"## Design Decisions",
|
|
1271
|
+
]
|
|
1272
|
+
|
|
1273
|
+
for decision in design_decisions:
|
|
1274
|
+
impl_parts.append(f"- {decision}")
|
|
1275
|
+
|
|
1276
|
+
impl_parts.extend(["", "## Files Changed", ""])
|
|
1277
|
+
for file_path in files_changed:
|
|
1278
|
+
impl_parts.append(f"- `{file_path}`")
|
|
1279
|
+
|
|
1280
|
+
impl_text = "\n".join(impl_parts)
|
|
1281
|
+
|
|
1282
|
+
# Create unique ID
|
|
1283
|
+
impl_id = str(uuid.uuid4())[:8]
|
|
1284
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
1285
|
+
|
|
1286
|
+
try:
|
|
1287
|
+
embedder = _get_embedder()
|
|
1288
|
+
database = _get_database()
|
|
1289
|
+
|
|
1290
|
+
# Generate embedding
|
|
1291
|
+
embedding = await embedder.embed(impl_text)
|
|
1292
|
+
|
|
1293
|
+
# Create document
|
|
1294
|
+
doc = Document(
|
|
1295
|
+
id=generate_document_id(effective_project_id, "implementations", impl_id, 0),
|
|
1296
|
+
text=impl_text,
|
|
1297
|
+
vector=embedding,
|
|
1298
|
+
project_id=effective_project_id,
|
|
1299
|
+
file_path=f".nexus/implementations/{impl_id}.md",
|
|
1300
|
+
doc_type=DocumentType.IMPLEMENTATION,
|
|
1301
|
+
chunk_type="implementation",
|
|
1302
|
+
language="markdown",
|
|
1303
|
+
name=f"impl_{impl_id}",
|
|
1304
|
+
start_line=0,
|
|
1305
|
+
end_line=0,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
await database.upsert_document(doc)
|
|
1309
|
+
|
|
1310
|
+
# Save to .nexus/implementations directory if it exists
|
|
1311
|
+
impl_dir = Path.cwd() / ".nexus" / "implementations"
|
|
1312
|
+
if impl_dir.exists():
|
|
1313
|
+
impl_file = impl_dir / f"{impl_id}_{timestamp[:10]}.md"
|
|
1314
|
+
try:
|
|
1315
|
+
impl_file.write_text(impl_text, encoding="utf-8")
|
|
1316
|
+
except Exception:
|
|
1317
|
+
pass
|
|
1318
|
+
|
|
1319
|
+
return (
|
|
1320
|
+
f"✅ Implementation recorded!\n"
|
|
1321
|
+
f"- ID: {impl_id}\n"
|
|
1322
|
+
f"- Title: {title}\n"
|
|
1323
|
+
f"- Project: {effective_project_id}\n"
|
|
1324
|
+
f"- Files: {len(files_changed)} changed"
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
except Exception as e:
|
|
1328
|
+
return f"Failed to record implementation: {e!s}"
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
@mcp.tool()
|
|
1332
|
+
async def search_implementations(
|
|
1333
|
+
query: str,
|
|
1334
|
+
project_id: str | None = None,
|
|
1335
|
+
limit: int = 5,
|
|
1336
|
+
) -> str:
|
|
1337
|
+
"""Search recorded implementations.
|
|
1338
|
+
|
|
1339
|
+
Use to find:
|
|
1340
|
+
- How similar features were built
|
|
1341
|
+
- Design patterns used in the project
|
|
1342
|
+
- Past technical approaches
|
|
1343
|
+
- Implementation history
|
|
1344
|
+
|
|
1345
|
+
Args:
|
|
1346
|
+
query: What you're looking for.
|
|
1347
|
+
Examples: "authentication implementation", "database refactor",
|
|
1348
|
+
"API design patterns"
|
|
1349
|
+
project_id: Optional project identifier. Searches all projects if not specified.
|
|
1350
|
+
limit: Maximum number of results (default: 5, max: 20).
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
Relevant implementations with summaries and design decisions.
|
|
1354
|
+
"""
|
|
1355
|
+
database = _get_database()
|
|
1356
|
+
limit = min(max(1, limit), 20)
|
|
1357
|
+
|
|
1358
|
+
try:
|
|
1359
|
+
results = await database.search(
|
|
1360
|
+
query=query,
|
|
1361
|
+
project_id=project_id,
|
|
1362
|
+
doc_type=DocumentType.IMPLEMENTATION,
|
|
1363
|
+
limit=limit,
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
if not results:
|
|
1367
|
+
return (
|
|
1368
|
+
f"No implementations found for: '{query}'\n\n"
|
|
1369
|
+
"Tip: Use record_implementation after completing features to save them."
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
output_parts = [f"## Implementations Found: '{query}'", ""]
|
|
1373
|
+
|
|
1374
|
+
for i, result in enumerate(results, 1):
|
|
1375
|
+
output_parts.append(f"### Implementation {i}")
|
|
1376
|
+
output_parts.append(f"**ID:** {result.name}")
|
|
1377
|
+
output_parts.append(f"**Project:** {result.project_id}")
|
|
1378
|
+
output_parts.append("")
|
|
1379
|
+
# Truncate long content
|
|
1380
|
+
output_parts.append(result.text[:3000])
|
|
1381
|
+
if len(result.text) > 3000:
|
|
1382
|
+
output_parts.append("\n... (truncated)")
|
|
1383
|
+
output_parts.append("")
|
|
1384
|
+
output_parts.append("---")
|
|
1385
|
+
output_parts.append("")
|
|
1386
|
+
|
|
1387
|
+
return "\n".join(output_parts)
|
|
1388
|
+
|
|
1389
|
+
except Exception as e:
|
|
1390
|
+
return f"Implementation search failed: {e!s}"
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
@mcp.resource("mcp://nexus-dev/active-tools")
|
|
1394
|
+
async def get_active_tools_resource() -> str:
|
|
1395
|
+
"""List MCP tools from active profile servers.
|
|
1396
|
+
|
|
1397
|
+
Returns a list of tools that are available based on the current
|
|
1398
|
+
profile configuration in .nexus/mcp_config.json.
|
|
1399
|
+
"""
|
|
1400
|
+
mcp_config = _get_mcp_config()
|
|
1401
|
+
if not mcp_config:
|
|
1402
|
+
return "No MCP config found. Run 'nexus-mcp init' first."
|
|
1403
|
+
|
|
1404
|
+
database = _get_database()
|
|
1405
|
+
active_servers = _get_active_server_names()
|
|
1406
|
+
|
|
1407
|
+
if not active_servers:
|
|
1408
|
+
return f"No active servers in profile: {mcp_config.active_profile}"
|
|
1409
|
+
|
|
1410
|
+
# Query all tools once from the database
|
|
1411
|
+
all_tools = await database.search(
|
|
1412
|
+
query="",
|
|
1413
|
+
doc_type=DocumentType.TOOL,
|
|
1414
|
+
limit=1000, # Get all tools
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
# Filter tools by active servers
|
|
1418
|
+
tools = [t for t in all_tools if t.server_name in active_servers]
|
|
1419
|
+
|
|
1420
|
+
# Format output
|
|
1421
|
+
output = [f"# Active Tools (profile: {mcp_config.active_profile})", ""]
|
|
1422
|
+
|
|
1423
|
+
for server in active_servers:
|
|
1424
|
+
server_tools = [t for t in tools if t.server_name == server]
|
|
1425
|
+
output.append(f"## {server}")
|
|
1426
|
+
if server_tools:
|
|
1427
|
+
for tool in server_tools:
|
|
1428
|
+
# Truncate description to 100 chars
|
|
1429
|
+
desc = tool.text[:100] + "..." if len(tool.text) > 100 else tool.text
|
|
1430
|
+
output.append(f"- {tool.name}: {desc}")
|
|
1431
|
+
else:
|
|
1432
|
+
output.append("*No tools found*")
|
|
1433
|
+
output.append("")
|
|
1434
|
+
|
|
1435
|
+
return "\n".join(output)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
@mcp.tool()
|
|
1439
|
+
async def get_project_context(
|
|
1440
|
+
project_id: str | None = None,
|
|
1441
|
+
limit: int = 10,
|
|
1442
|
+
) -> str:
|
|
1443
|
+
"""Get recent lessons and discoveries for a project.
|
|
1444
|
+
|
|
1445
|
+
Returns a summary of recent lessons learned and indexed content for the
|
|
1446
|
+
specified project. Useful for getting up to speed on a project or
|
|
1447
|
+
reviewing what the AI assistant has learned.
|
|
1448
|
+
|
|
1449
|
+
Args:
|
|
1450
|
+
project_id: Project identifier. Uses current project if not specified.
|
|
1451
|
+
limit: Maximum number of recent items to return (default: 10).
|
|
1452
|
+
|
|
1453
|
+
Returns:
|
|
1454
|
+
Summary of project knowledge including statistics and recent lessons.
|
|
1455
|
+
"""
|
|
1456
|
+
config = _get_config()
|
|
1457
|
+
database = _get_database()
|
|
1458
|
+
|
|
1459
|
+
# If no project specified and no config, show stats for all projects
|
|
1460
|
+
if project_id is None and config is None:
|
|
1461
|
+
project_name = "All Projects"
|
|
1462
|
+
effective_project_id = None # Will get stats for all
|
|
1463
|
+
elif project_id is not None:
|
|
1464
|
+
project_name = f"Project {project_id[:8]}..."
|
|
1465
|
+
effective_project_id = project_id
|
|
1466
|
+
else:
|
|
1467
|
+
# config is guaranteed not None here (checked at line 595)
|
|
1468
|
+
assert config is not None
|
|
1469
|
+
project_name = config.project_name
|
|
1470
|
+
effective_project_id = config.project_id
|
|
1471
|
+
|
|
1472
|
+
limit = min(max(1, limit), 50)
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
# Get project statistics (None = all projects)
|
|
1476
|
+
stats = await database.get_project_stats(effective_project_id)
|
|
1477
|
+
|
|
1478
|
+
# Get recent lessons (None = all projects)
|
|
1479
|
+
recent_lessons = await database.get_recent_lessons(effective_project_id, limit)
|
|
1480
|
+
|
|
1481
|
+
# Format output
|
|
1482
|
+
output_parts = [
|
|
1483
|
+
f"## Project Context: {project_name}",
|
|
1484
|
+
f"**Project ID:** `{effective_project_id or 'all'}`",
|
|
1485
|
+
"",
|
|
1486
|
+
"### Statistics",
|
|
1487
|
+
f"- Total indexed chunks: {stats.get('total', 0)}",
|
|
1488
|
+
f"- Code chunks: {stats.get('code', 0)}",
|
|
1489
|
+
f"- Documentation chunks: {stats.get('documentation', 0)}",
|
|
1490
|
+
f"- Lessons: {stats.get('lesson', 0)}",
|
|
1491
|
+
"",
|
|
1492
|
+
]
|
|
1493
|
+
|
|
1494
|
+
if recent_lessons:
|
|
1495
|
+
output_parts.append("### Recent Lessons")
|
|
1496
|
+
output_parts.append("")
|
|
1497
|
+
|
|
1498
|
+
for lesson in recent_lessons:
|
|
1499
|
+
import yaml
|
|
1500
|
+
|
|
1501
|
+
output_parts.append(f"#### {lesson.name}")
|
|
1502
|
+
# Extract just the problem summary
|
|
1503
|
+
# Extract problem from frontmatter or text
|
|
1504
|
+
problem = ""
|
|
1505
|
+
if lesson.text.startswith("---"):
|
|
1506
|
+
try:
|
|
1507
|
+
# Extract between first and second ---
|
|
1508
|
+
parts = lesson.text.split("---", 2)
|
|
1509
|
+
if len(parts) >= 3:
|
|
1510
|
+
fm = yaml.safe_load(parts[1])
|
|
1511
|
+
problem = fm.get("problem", "")
|
|
1512
|
+
except Exception:
|
|
1513
|
+
pass
|
|
1514
|
+
|
|
1515
|
+
if not problem:
|
|
1516
|
+
lines = lesson.text.split("\n")
|
|
1517
|
+
for i, line in enumerate(lines):
|
|
1518
|
+
if line.strip() == "## Problem" and i + 1 < len(lines):
|
|
1519
|
+
problem = lines[i + 1].strip()
|
|
1520
|
+
break
|
|
1521
|
+
|
|
1522
|
+
if problem:
|
|
1523
|
+
output_parts.append(f"**Problem:** {problem[:200]}...")
|
|
1524
|
+
output_parts.append("")
|
|
1525
|
+
|
|
1526
|
+
else:
|
|
1527
|
+
output_parts.append("*No lessons recorded yet.*")
|
|
1528
|
+
|
|
1529
|
+
return "\n".join(output_parts)
|
|
1530
|
+
|
|
1531
|
+
except Exception as e:
|
|
1532
|
+
return f"Failed to get project context: {e!s}"
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
async def _get_project_root_from_session(ctx: Context[Any, Any]) -> Path | None:
|
|
1536
|
+
"""Get the project root from MCP session roots.
|
|
1537
|
+
|
|
1538
|
+
Uses session.list_roots() to query the IDE for workspace folders.
|
|
1539
|
+
|
|
1540
|
+
Args:
|
|
1541
|
+
ctx: FastMCP Context with session access.
|
|
1542
|
+
|
|
1543
|
+
Returns:
|
|
1544
|
+
Path to the project root if found, None otherwise.
|
|
1545
|
+
"""
|
|
1546
|
+
try:
|
|
1547
|
+
# Query the IDE for workspace roots
|
|
1548
|
+
roots_result = await ctx.session.list_roots()
|
|
1549
|
+
|
|
1550
|
+
if not roots_result.roots:
|
|
1551
|
+
logger.debug("No roots returned from session.list_roots()")
|
|
1552
|
+
return None
|
|
1553
|
+
|
|
1554
|
+
# Look for a root that contains nexus_config.json (indicates a nexus project)
|
|
1555
|
+
for root in roots_result.roots:
|
|
1556
|
+
uri = str(root.uri)
|
|
1557
|
+
# Handle file:// URIs
|
|
1558
|
+
path = Path(uri[7:]) if uri.startswith("file://") else Path(uri)
|
|
1559
|
+
|
|
1560
|
+
if path.exists() and (path / "nexus_config.json").exists():
|
|
1561
|
+
logger.debug("Found nexus project root from session: %s", path)
|
|
1562
|
+
return path
|
|
1563
|
+
|
|
1564
|
+
# Fall back to first root if none have nexus_config.json
|
|
1565
|
+
first_uri = str(roots_result.roots[0].uri)
|
|
1566
|
+
path = Path(first_uri[7:]) if first_uri.startswith("file://") else Path(first_uri)
|
|
1567
|
+
|
|
1568
|
+
if path.exists():
|
|
1569
|
+
logger.debug("Using first root from session: %s", path)
|
|
1570
|
+
return path
|
|
1571
|
+
|
|
1572
|
+
except Exception as e:
|
|
1573
|
+
logger.debug("Failed to get roots from session: %s", e)
|
|
1574
|
+
|
|
1575
|
+
return None
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
@mcp.tool()
|
|
1579
|
+
async def list_agents(ctx: Context[Any, Any]) -> str:
|
|
1580
|
+
"""List available agents in the current workspace.
|
|
1581
|
+
|
|
1582
|
+
Discovers agents from the agents/ directory in the IDE's current workspace.
|
|
1583
|
+
Use ask_agent tool to execute tasks with a specific agent.
|
|
1584
|
+
|
|
1585
|
+
Returns:
|
|
1586
|
+
List of available agents with names and descriptions.
|
|
1587
|
+
"""
|
|
1588
|
+
# Try to get project root from session (MCP roots)
|
|
1589
|
+
project_root = await _get_project_root_from_session(ctx)
|
|
1590
|
+
|
|
1591
|
+
# Fall back to environment variable or cwd
|
|
1592
|
+
if not project_root:
|
|
1593
|
+
project_root = _find_project_root()
|
|
1594
|
+
|
|
1595
|
+
if not project_root:
|
|
1596
|
+
return "No project root found. Make sure you have a nexus_config.json in your workspace."
|
|
1597
|
+
|
|
1598
|
+
agents_dir = project_root / "agents"
|
|
1599
|
+
if not agents_dir.exists():
|
|
1600
|
+
return f"No agents directory found at {agents_dir}. Create it and add agent YAML files."
|
|
1601
|
+
|
|
1602
|
+
# Load agents from directory
|
|
1603
|
+
agent_manager = AgentManager(agents_dir=agents_dir)
|
|
1604
|
+
|
|
1605
|
+
if len(agent_manager) == 0:
|
|
1606
|
+
return f"No agents found in {agents_dir}. Add YAML agent configuration files."
|
|
1607
|
+
|
|
1608
|
+
lines = ["# Available Agents", ""]
|
|
1609
|
+
for agent in agent_manager:
|
|
1610
|
+
lines.append(f"## {agent.display_name or agent.name}")
|
|
1611
|
+
lines.append(f"- **Name:** `{agent.name}`")
|
|
1612
|
+
lines.append(f"- **Description:** {agent.description}")
|
|
1613
|
+
if agent.profile:
|
|
1614
|
+
lines.append(f"- **Role:** {agent.profile.role}")
|
|
1615
|
+
lines.append("")
|
|
1616
|
+
|
|
1617
|
+
lines.append("Use `ask_agent` tool with the agent name to execute a task.")
|
|
1618
|
+
return "\n".join(lines)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
@mcp.tool()
|
|
1622
|
+
async def ask_agent(agent_name: str, task: str, ctx: Context[Any, Any]) -> str:
|
|
1623
|
+
"""Execute a task using a custom agent from the current workspace.
|
|
1624
|
+
|
|
1625
|
+
Loads the specified agent from the workspace's agents/ directory and
|
|
1626
|
+
executes the given task.
|
|
1627
|
+
|
|
1628
|
+
Args:
|
|
1629
|
+
agent_name: Name of the agent to use (e.g., 'nexus_architect').
|
|
1630
|
+
task: The task description to execute.
|
|
1631
|
+
|
|
1632
|
+
Returns:
|
|
1633
|
+
Agent's response.
|
|
1634
|
+
"""
|
|
1635
|
+
# Get database
|
|
1636
|
+
database = _get_database()
|
|
1637
|
+
if database is None:
|
|
1638
|
+
return "Database not initialized. Run nexus-init first."
|
|
1639
|
+
|
|
1640
|
+
# Try to get project root from session (MCP roots)
|
|
1641
|
+
project_root = await _get_project_root_from_session(ctx)
|
|
1642
|
+
|
|
1643
|
+
# Fall back to environment variable or cwd
|
|
1644
|
+
if not project_root:
|
|
1645
|
+
project_root = _find_project_root()
|
|
1646
|
+
|
|
1647
|
+
if not project_root:
|
|
1648
|
+
return "No project root found. Make sure you have a nexus_config.json in your workspace."
|
|
1649
|
+
|
|
1650
|
+
agents_dir = project_root / "agents"
|
|
1651
|
+
if not agents_dir.exists():
|
|
1652
|
+
return f"No agents directory found at {agents_dir}."
|
|
1653
|
+
|
|
1654
|
+
# Load agents from directory
|
|
1655
|
+
agent_manager = AgentManager(agents_dir=agents_dir)
|
|
1656
|
+
agent_config = agent_manager.get_agent(agent_name)
|
|
1657
|
+
|
|
1658
|
+
if not agent_config:
|
|
1659
|
+
available = [a.name for a in agent_manager]
|
|
1660
|
+
return f"Agent '{agent_name}' not found. Available agents: {available}"
|
|
1661
|
+
|
|
1662
|
+
# Execute the task
|
|
1663
|
+
try:
|
|
1664
|
+
executor = AgentExecutor(agent_config, database, mcp)
|
|
1665
|
+
config = _get_config()
|
|
1666
|
+
project_id = config.project_id if config else None
|
|
1667
|
+
return await executor.execute(task, project_id)
|
|
1668
|
+
except Exception as e:
|
|
1669
|
+
logger.error("Agent execution failed: %s", e, exc_info=True)
|
|
1670
|
+
return f"Agent execution failed: {e!s}"
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
@mcp.tool()
|
|
1674
|
+
async def refresh_agents(ctx: Context[Any, Any]) -> str:
|
|
1675
|
+
"""Discovers and registers individual agent tools from the current workspace.
|
|
1676
|
+
|
|
1677
|
+
This tool:
|
|
1678
|
+
1. Queries the IDE for the current workspace root.
|
|
1679
|
+
2. Scans the 'agents/' directory for agent configurations.
|
|
1680
|
+
3. Dynamically registers 'ask_<agent_name>' tools for each agent found.
|
|
1681
|
+
4. Notifies the IDE that the tool list has changed.
|
|
1682
|
+
|
|
1683
|
+
Returns:
|
|
1684
|
+
A report of registered agents or an error message.
|
|
1685
|
+
"""
|
|
1686
|
+
project_root = await _get_project_root_from_session(ctx)
|
|
1687
|
+
if not project_root:
|
|
1688
|
+
return "No nexus project root found in workspace (nexus_config.json missing)."
|
|
1689
|
+
|
|
1690
|
+
# Persist the root globally so other tools find it
|
|
1691
|
+
global _project_root
|
|
1692
|
+
_project_root = project_root
|
|
1693
|
+
|
|
1694
|
+
# Reload other configs if they were initialized lazily from /
|
|
1695
|
+
global _config, _mcp_config, _database
|
|
1696
|
+
_config = None
|
|
1697
|
+
_mcp_config = None
|
|
1698
|
+
_database = None
|
|
1699
|
+
|
|
1700
|
+
database = _get_database()
|
|
1701
|
+
if database is None:
|
|
1702
|
+
return "Database not initialized."
|
|
1703
|
+
|
|
1704
|
+
agents_dir = project_root / "agents"
|
|
1705
|
+
if not agents_dir.exists():
|
|
1706
|
+
return f"No agents directory found at {agents_dir}."
|
|
1707
|
+
|
|
1708
|
+
global _agent_manager
|
|
1709
|
+
_agent_manager = AgentManager(agents_dir=agents_dir)
|
|
1710
|
+
|
|
1711
|
+
if len(_agent_manager) == 0:
|
|
1712
|
+
return "No agents found in agents/ directory."
|
|
1713
|
+
|
|
1714
|
+
# Register the tools
|
|
1715
|
+
_register_agent_tools(database, _agent_manager)
|
|
1716
|
+
|
|
1717
|
+
# Notify the client that the tool list has changed
|
|
1718
|
+
try:
|
|
1719
|
+
await ctx.session.send_tool_list_changed()
|
|
1720
|
+
except Exception as e:
|
|
1721
|
+
logger.warning("Failed to send tool_list_changed notification: %s", e)
|
|
1722
|
+
|
|
1723
|
+
agent_names = [a.name for a in _agent_manager]
|
|
1724
|
+
return f"Successfully registered {len(agent_names)} agent tools: {', '.join(agent_names)}"
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def _register_agent_tools(database: NexusDatabase, agent_manager: AgentManager | None) -> None:
|
|
1728
|
+
"""Register dynamic tools for each loaded agent.
|
|
1729
|
+
|
|
1730
|
+
Each agent becomes an MCP tool named `ask_<agent_name>`.
|
|
1731
|
+
"""
|
|
1732
|
+
if agent_manager is None:
|
|
1733
|
+
return
|
|
1734
|
+
|
|
1735
|
+
for agent_config in agent_manager:
|
|
1736
|
+
|
|
1737
|
+
def create_agent_tool(cfg: AgentConfig) -> Any:
|
|
1738
|
+
"""Create a closure to capture the agent config."""
|
|
1739
|
+
|
|
1740
|
+
async def agent_tool(task: str) -> str:
|
|
1741
|
+
"""Execute a task using the configured agent.
|
|
1742
|
+
|
|
1743
|
+
Args:
|
|
1744
|
+
task: The task description to execute.
|
|
1745
|
+
|
|
1746
|
+
Returns:
|
|
1747
|
+
Agent's response.
|
|
1748
|
+
"""
|
|
1749
|
+
logger.info("Agent tool called: ask_%s for task: %s", cfg.name, task[:100])
|
|
1750
|
+
executor = AgentExecutor(cfg, database, mcp)
|
|
1751
|
+
config = _get_config()
|
|
1752
|
+
project_id = config.project_id if config else None
|
|
1753
|
+
return await executor.execute(task, project_id)
|
|
1754
|
+
|
|
1755
|
+
# Set the docstring dynamically
|
|
1756
|
+
agent_tool.__doc__ = cfg.description
|
|
1757
|
+
return agent_tool
|
|
1758
|
+
|
|
1759
|
+
tool_name = f"ask_{agent_config.name}"
|
|
1760
|
+
tool_func = create_agent_tool(agent_config)
|
|
1761
|
+
|
|
1762
|
+
# We use mcp.add_tool directly to allow dynamic registration at runtime
|
|
1763
|
+
# FastMCP.tool is a decorator, add_tool is the underlying method
|
|
1764
|
+
mcp.add_tool(fn=tool_func, name=tool_name, description=agent_config.description)
|
|
1765
|
+
logger.info("Registered agent tool: %s", tool_name)
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def main() -> None:
|
|
1769
|
+
"""Run the MCP server."""
|
|
1770
|
+
import argparse
|
|
1771
|
+
import signal
|
|
1772
|
+
import sys
|
|
1773
|
+
from types import FrameType
|
|
1774
|
+
|
|
1775
|
+
# Parse command-line arguments
|
|
1776
|
+
parser = argparse.ArgumentParser(description="Nexus-Dev MCP Server")
|
|
1777
|
+
parser.add_argument(
|
|
1778
|
+
"--transport",
|
|
1779
|
+
choices=["stdio", "sse"],
|
|
1780
|
+
default="stdio",
|
|
1781
|
+
help="Transport mode: stdio (default) or sse for Docker/network deployment",
|
|
1782
|
+
)
|
|
1783
|
+
parser.add_argument(
|
|
1784
|
+
"--port",
|
|
1785
|
+
type=int,
|
|
1786
|
+
default=8080,
|
|
1787
|
+
help="Port for SSE transport (default: 8080)",
|
|
1788
|
+
)
|
|
1789
|
+
parser.add_argument(
|
|
1790
|
+
"--host",
|
|
1791
|
+
default="0.0.0.0",
|
|
1792
|
+
help="Host for SSE transport (default: 0.0.0.0)",
|
|
1793
|
+
)
|
|
1794
|
+
args = parser.parse_args()
|
|
1795
|
+
|
|
1796
|
+
# Configure logging to always use stderr and a debug file
|
|
1797
|
+
root_logger = logging.getLogger()
|
|
1798
|
+
for handler in root_logger.handlers[:]:
|
|
1799
|
+
root_logger.removeHandler(handler)
|
|
1800
|
+
|
|
1801
|
+
# Stderr handler
|
|
1802
|
+
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
1803
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
1804
|
+
stderr_handler.setFormatter(logging.Formatter(log_format))
|
|
1805
|
+
root_logger.addHandler(stderr_handler)
|
|
1806
|
+
|
|
1807
|
+
# File handler for persistent debugging
|
|
1808
|
+
try:
|
|
1809
|
+
file_handler = logging.FileHandler("/tmp/nexus-dev-debug.log")
|
|
1810
|
+
file_handler.setFormatter(logging.Formatter(log_format))
|
|
1811
|
+
file_handler.setLevel(logging.DEBUG)
|
|
1812
|
+
root_logger.addHandler(file_handler)
|
|
1813
|
+
except Exception:
|
|
1814
|
+
pass # Fallback if /tmp is not writable
|
|
1815
|
+
|
|
1816
|
+
root_logger.setLevel(logging.DEBUG)
|
|
1817
|
+
|
|
1818
|
+
# Also ensure the module-specific logger is at INFO
|
|
1819
|
+
logger.setLevel(logging.DEBUG)
|
|
1820
|
+
|
|
1821
|
+
def handle_signal(sig: int, frame: FrameType | None) -> None:
|
|
1822
|
+
logger.info("Received signal %s, shutting down...", sig)
|
|
1823
|
+
sys.exit(0)
|
|
1824
|
+
|
|
1825
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
1826
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
1827
|
+
|
|
1828
|
+
# Initialize on startup
|
|
1829
|
+
try:
|
|
1830
|
+
logger.info("Starting Nexus-Dev MCP server...")
|
|
1831
|
+
_get_config()
|
|
1832
|
+
database = _get_database()
|
|
1833
|
+
_get_mcp_config()
|
|
1834
|
+
|
|
1835
|
+
# Load and register custom agents
|
|
1836
|
+
# Find project root and look for agents directory
|
|
1837
|
+
logger.debug("Current working directory: %s", Path.cwd())
|
|
1838
|
+
project_root = _find_project_root()
|
|
1839
|
+
agents_dir = project_root / "agents" if project_root else None
|
|
1840
|
+
logger.debug("Project root: %s", project_root)
|
|
1841
|
+
logger.debug("Agents directory: %s", agents_dir)
|
|
1842
|
+
|
|
1843
|
+
global _agent_manager
|
|
1844
|
+
_agent_manager = AgentManager(agents_dir=agents_dir)
|
|
1845
|
+
_register_agent_tools(database, _agent_manager)
|
|
1846
|
+
|
|
1847
|
+
# Run server with selected transport
|
|
1848
|
+
if args.transport == "sse":
|
|
1849
|
+
logger.info(
|
|
1850
|
+
"Server initialization complete, running SSE transport on %s:%d",
|
|
1851
|
+
args.host,
|
|
1852
|
+
args.port,
|
|
1853
|
+
)
|
|
1854
|
+
mcp.run(transport="sse", host=args.host, port=args.port) # type: ignore
|
|
1855
|
+
else:
|
|
1856
|
+
logger.info("Server initialization complete, running stdio transport")
|
|
1857
|
+
mcp.run(transport="stdio")
|
|
1858
|
+
except Exception as e:
|
|
1859
|
+
logger.critical("Fatal error in MCP server: %s", e, exc_info=True)
|
|
1860
|
+
sys.exit(1)
|
|
1861
|
+
finally:
|
|
1862
|
+
logger.info("MCP server shutdown complete")
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
if __name__ == "__main__":
|
|
1866
|
+
main()
|