Files
PromptX/src/lib/core/resource/discovery/ProjectDiscovery.js
sean 40e0c01c59 feat: 实现@tool协议完整功能 - JavaScript工具执行框架
 核心功能
- 新增ToolProtocol处理器,支持@tool://协议解析
- 实现PromptXToolCommand,统一MCP/CLI工具调用
- 完善ToolExecutor,支持工具实例化和参数验证
- 新增calculator和send-email示例工具

🔧 技术改进
- 优化PackageDiscovery统一资源扫描逻辑
- 增强CrossPlatformFileScanner支持.tool.js文件
- 完善ResourceManager集成ToolProtocol
- 更新MCP工具定义支持promptx_tool

📋 详细变更
Core:
- src/lib/core/resource/protocols/ToolProtocol.js: 新增工具协议处理器
- src/lib/commands/PromptXToolCommand.js: 新增工具命令处理器
- src/lib/tool/ToolExecutor.js: 增强工具执行器兼容性

Discovery:
- src/lib/core/resource/discovery/PackageDiscovery.js: 统一资源扫描
- src/lib/core/resource/discovery/CrossPlatformFileScanner.js: 支持tool文件
- src/lib/core/resource/discovery/ProjectDiscovery.js: 增加tool验证

Integration:
- src/lib/core/resource/resourceManager.js: 集成ToolProtocol
- src/lib/mcp/toolDefinitions.js: 新增promptx_tool定义
- src/lib/commands/MCPServerCommand.js: 支持tool参数转换
- src/bin/promptx.js: 新增tool命令行支持

Tools:
- prompt/tool/calculator.tool.js: 数学计算工具示例
- prompt/tool/send-email.tool.js: 邮件发送工具示例

Registry:
- src/package.registry.json: 自动生成包含2个tool资源

🧪 测试验证
-  @tool://calculator 数学计算: 25 + 37 = 62
-  @tool://send-email 邮件发送演示版本
-  CLI和MCP双模式支持
-  完整的错误处理和执行元数据
-  资源自动发现和注册

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-28 14:15:24 +08:00

516 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const BaseDiscovery = require('./BaseDiscovery')
const logger = require('../../../utils/logger')
const fs = require('fs-extra')
const path = require('path')
const CrossPlatformFileScanner = require('./CrossPlatformFileScanner')
const RegistryData = require('../RegistryData')
const ResourceData = require('../ResourceData')
/**
* ProjectDiscovery - 项目级资源发现器
*
* 负责发现项目本地的资源:
* 1. 优先从 project.registry.json 读取(构建时优化)
* 2. Fallback: 扫描 .promptx/resource/ 目录(动态发现)
* 3. 发现用户自定义的角色、执行模式、思维模式等
*
* 优先级2
*/
class ProjectDiscovery extends BaseDiscovery {
constructor() {
super('PROJECT', 2)
this.fileScanner = new CrossPlatformFileScanner()
this.registryData = null
}
/**
* 发现项目级资源注册表 (新架构方法)
* @returns {Promise<Map>} 发现的资源注册表 Map<resourceId, reference>
*/
async discoverRegistry() {
try {
// 1. 查找项目根目录
const projectRoot = await this._findProjectRoot()
// 2. 检查.promptx目录是否存在
const hasPrompxDir = await this._checkPrompxDirectory(projectRoot)
if (!hasPrompxDir) {
return new Map()
}
// 3. 优先尝试从注册表加载
const registryMap = await this._loadFromRegistry(projectRoot)
if (registryMap.size > 0) {
logger.debug(`ProjectDiscovery 从注册表加载 ${registryMap.size} 个资源`)
return registryMap
}
// 4. Fallback: 动态扫描
logger.debug('ProjectDiscovery 注册表不存在,使用动态扫描')
const resources = await this._scanProjectResources(projectRoot)
return this._buildRegistryFromResources(resources)
} catch (error) {
logger.warn(`[ProjectDiscovery] Registry discovery failed: ${error.message}`)
return new Map()
}
}
/**
* 从注册表文件加载资源
* @param {string} projectRoot - 项目根目录
* @returns {Promise<Map>} 资源注册表
*/
async _loadFromRegistry(projectRoot) {
try {
const registryPath = path.join(projectRoot, '.promptx', 'resource', 'project.registry.json')
// 检查注册表文件是否存在
if (!await this._fsExists(registryPath)) {
return new Map()
}
// 读取并解析注册表
this.registryData = await RegistryData.fromFile('project', registryPath)
// 获取分层级资源映射
return this.registryData.getResourceMap(true) // 带前缀
} catch (error) {
logger.warn(`[ProjectDiscovery] Failed to load registry: ${error.message}`)
return new Map()
}
}
/**
* 发现项目级资源 (旧版本兼容方法)
* @returns {Promise<Array>} 发现的资源列表
*/
async discover() {
try {
// 使用新的注册表方法
const registryMap = await this.discoverRegistry()
// 转换为旧格式
const resources = []
for (const [id, reference] of registryMap.entries()) {
resources.push({
id: id.replace(/^project:/, ''), // 移除前缀以保持兼容性
reference: reference
})
}
// 规范化所有资源
return resources.map(resource => this.normalizeResource(resource))
} catch (error) {
logger.warn(`[ProjectDiscovery] Discovery failed: ${error.message}`)
return []
}
}
/**
* 从资源列表构建注册表
* @param {Array} resources - 资源列表
* @returns {Map} 资源注册表 Map<resourceId, reference>
*/
_buildRegistryFromResources(resources) {
const registry = new Map()
for (const resource of resources) {
if (resource.id && resource.reference) {
registry.set(resource.id, resource.reference)
}
}
return registry
}
/**
* 查找项目根目录
* @deprecated 使用 DirectoryService.getProjectRoot() 替代
* @returns {Promise<string>} 项目根目录路径
*/
async _findProjectRoot() {
// 使用新的统一目录服务
const { getDirectoryService } = require('../../../utils/DirectoryService')
const directoryService = getDirectoryService()
return await directoryService.getProjectRoot()
}
/**
* 检查.promptx目录是否存在
* @param {string} projectRoot - 项目根目录
* @returns {Promise<boolean>} 是否存在.promptx/resource目录
*/
async _checkPrompxDirectory(projectRoot) {
const promptxResourcePath = path.join(projectRoot, '.promptx', 'resource')
return await this._fsExists(promptxResourcePath)
}
/**
* 扫描项目资源
* @param {string} projectRoot - 项目根目录
* @returns {Promise<Array>} 扫描发现的资源列表
*/
async _scanProjectResources(projectRoot) {
try {
const resourcesDir = path.join(projectRoot, '.promptx', 'resource')
const resources = []
// 定义要扫描的资源类型
const resourceTypes = ['role', 'execution', 'thought', 'knowledge', 'tool']
// 并行扫描所有资源类型
for (const resourceType of resourceTypes) {
try {
const files = await this.fileScanner.scanResourceFiles(resourcesDir, resourceType)
for (const filePath of files) {
// 验证文件内容
const isValid = await this._validateResourceFile(filePath, resourceType)
if (!isValid) {
continue
}
const suffix = `.${resourceType}.md`
const id = this._extractResourceId(filePath, resourceType, suffix)
const reference = this._generateProjectReference(filePath, projectRoot)
resources.push({
id: id,
reference: reference
})
}
} catch (error) {
logger.warn(`[ProjectDiscovery] Failed to scan ${resourceType} resources: ${error.message}`)
}
}
return resources
} catch (error) {
logger.warn(`[ProjectDiscovery] Failed to scan project resources: ${error.message}`)
return []
}
}
/**
* 文件扫描可以被测试mock
* @param {string} baseDir - 基础目录
* @param {string} resourceType - 资源类型
* @returns {Promise<Array>} 匹配的文件路径列表
*/
async _scanFiles(baseDir, resourceType) {
return await this.fileScanner.scanResourceFiles(baseDir, resourceType)
}
/**
* 文件系统存在性检查可以被测试mock
* @param {string} filePath - 文件路径
* @returns {Promise<boolean>} 文件是否存在
*/
async _fsExists(filePath) {
return await fs.pathExists(filePath)
}
/**
* 读取文件内容可以被测试mock
* @param {string} filePath - 文件路径
* @returns {Promise<string>} 文件内容
*/
async _readFile(filePath) {
return await fs.readFile(filePath, 'utf8')
}
/**
* 验证资源文件格式
* @param {string} filePath - 文件路径
* @param {string} protocol - 协议类型
* @returns {Promise<boolean>} 是否是有效的资源文件
*/
async _validateResourceFile(filePath, protocol) {
try {
const content = await this._readFile(filePath)
if (!content || typeof content !== 'string') {
return false
}
const trimmedContent = content.trim()
if (trimmedContent.length === 0) {
return false
}
// 根据协议类型验证DPML标签
switch (protocol) {
case 'role':
return trimmedContent.includes('<role>') && trimmedContent.includes('</role>')
case 'execution':
return trimmedContent.includes('<execution>') && trimmedContent.includes('</execution>')
case 'thought':
return trimmedContent.includes('<thought>') && trimmedContent.includes('</thought>')
case 'knowledge':
// knowledge类型比较灵活只要文件有内容就认为是有效的
// 可以是纯文本、链接、图片等任何形式的知识内容
return true
case 'tool':
// tool类型必须是有效的JavaScript代码
return this._validateToolFile(content)
default:
return false
}
} catch (error) {
logger.warn(`[ProjectDiscovery] Failed to validate ${filePath}: ${error.message}`)
return false
}
}
/**
* 验证Tool文件是否为有效的JavaScript代码
* @param {string} content - 文件内容
* @returns {boolean} 是否为有效的Tool文件
*/
_validateToolFile(content) {
try {
// 1. 基本的JavaScript语法检查
new Function(content);
// 2. 检查是否包含module.exportsCommonJS格式
if (!content.includes('module.exports')) {
return false;
}
// 3. 检查是否包含工具必需的方法getMetadata, execute等
const requiredMethods = ['getMetadata', 'execute'];
const hasRequiredMethods = requiredMethods.some(method =>
content.includes(method)
);
return hasRequiredMethods;
} catch (syntaxError) {
// JavaScript语法错误
return false;
}
}
/**
* 生成项目引用路径
* @param {string} filePath - 文件绝对路径
* @param {string} projectRoot - 项目根目录
* @returns {string} @project://相对路径
*/
_generateProjectReference(filePath, projectRoot) {
const relativePath = this.fileScanner.getRelativePath(projectRoot, filePath)
return `@project://${relativePath}`
}
/**
* 提取资源ID
* @param {string} filePath - 文件路径
* @param {string} protocol - 协议类型
* @param {string} suffix - 文件后缀
* @returns {string} 资源ID (对于role类型返回resourceName对于其他类型返回protocol:resourceName)
*/
_extractResourceId(filePath, protocol, suffix) {
const fileName = path.basename(filePath, suffix)
// role类型不需要前缀其他类型需要前缀
if (protocol === 'role') {
return fileName
} else {
return `${protocol}:${fileName}`
}
}
/**
* 生成项目级注册表文件
* @param {string} projectRoot - 项目根目录
* @returns {Promise<RegistryData>} 生成的注册表数据
*/
async generateRegistry(projectRoot) {
const registryPath = path.join(projectRoot, '.promptx', 'resource', 'project.registry.json')
const registryData = RegistryData.createEmpty('project', registryPath)
// 扫描.promptx/resource目录
const resourcesDir = path.join(projectRoot, '.promptx', 'resource')
if (await this._fsExists(resourcesDir)) {
await this._scanDirectory(resourcesDir, registryData)
}
// 保存注册表文件
await registryData.save()
logger.info(`[ProjectDiscovery] ✅ 项目注册表生成完成,发现 ${registryData.size} 个资源`)
return registryData
}
/**
* 扫描目录并添加资源到注册表
* @param {string} resourcesDir - 资源目录
* @param {RegistryData} registryData - 注册表数据
* @private
*/
async _scanDirectory(resourcesDir, registryData) {
// 扫描domain目录
const domainDir = path.join(resourcesDir, 'domain')
if (await this._fsExists(domainDir)) {
await this._scanDomainDirectory(domainDir, registryData)
}
}
/**
* 扫描domain目录项目角色资源
* @param {string} domainDir - domain目录路径
* @param {RegistryData} registryData - 注册表数据
* @private
*/
async _scanDomainDirectory(domainDir, registryData) {
const items = await fs.readdir(domainDir)
for (const item of items) {
const itemPath = path.join(domainDir, item)
const stat = await fs.stat(itemPath)
if (stat.isDirectory()) {
// 查找role文件
const roleFile = path.join(itemPath, `${item}.role.md`)
if (await this._fsExists(roleFile)) {
const reference = `@project://.promptx/resource/domain/${item}/${item}.role.md`
const resourceData = new ResourceData({
id: item,
source: 'project',
protocol: 'role',
name: ResourceData._generateDefaultName(item, 'role'),
description: ResourceData._generateDefaultDescription(item, 'role'),
reference: reference,
metadata: {
scannedAt: new Date().toISOString()
}
})
registryData.addResource(resourceData)
}
// 查找thought文件
const thoughtDir = path.join(itemPath, 'thought')
if (await this._fsExists(thoughtDir)) {
const thoughtFiles = await fs.readdir(thoughtDir)
for (const thoughtFile of thoughtFiles) {
if (thoughtFile.endsWith('.thought.md')) {
const thoughtId = path.basename(thoughtFile, '.thought.md')
const reference = `@project://.promptx/resource/domain/${item}/thought/${thoughtFile}`
const resourceData = new ResourceData({
id: thoughtId,
source: 'project',
protocol: 'thought',
name: ResourceData._generateDefaultName(thoughtId, 'thought'),
description: ResourceData._generateDefaultDescription(thoughtId, 'thought'),
reference: reference,
metadata: {
scannedAt: new Date().toISOString()
}
})
registryData.addResource(resourceData)
}
}
}
// 查找execution文件
const executionDir = path.join(itemPath, 'execution')
if (await this._fsExists(executionDir)) {
const executionFiles = await fs.readdir(executionDir)
for (const execFile of executionFiles) {
if (execFile.endsWith('.execution.md')) {
const execId = path.basename(execFile, '.execution.md')
const reference = `@project://.promptx/resource/domain/${item}/execution/${execFile}`
const resourceData = new ResourceData({
id: execId,
source: 'project',
protocol: 'execution',
name: ResourceData._generateDefaultName(execId, 'execution'),
description: ResourceData._generateDefaultDescription(execId, 'execution'),
reference: reference,
metadata: {
scannedAt: new Date().toISOString()
}
})
registryData.addResource(resourceData)
}
}
}
// 查找knowledge文件
const knowledgeDir = path.join(itemPath, 'knowledge')
if (await this._fsExists(knowledgeDir)) {
const knowledgeFiles = await fs.readdir(knowledgeDir)
for (const knowledgeFile of knowledgeFiles) {
if (knowledgeFile.endsWith('.knowledge.md')) {
const knowledgeId = path.basename(knowledgeFile, '.knowledge.md')
const reference = `@project://.promptx/resource/domain/${item}/knowledge/${knowledgeFile}`
const resourceData = new ResourceData({
id: knowledgeId,
source: 'project',
protocol: 'knowledge',
name: ResourceData._generateDefaultName(knowledgeId, 'knowledge'),
description: ResourceData._generateDefaultDescription(knowledgeId, 'knowledge'),
reference: reference,
metadata: {
scannedAt: new Date().toISOString()
}
})
registryData.addResource(resourceData)
}
}
}
}
}
}
/**
* 获取RegistryData对象新架构方法
* @returns {Promise<RegistryData>} 项目级RegistryData对象
*/
async getRegistryData() {
try {
const projectRoot = await this._findProjectRoot()
const registryPath = path.join(projectRoot, '.promptx', 'resource', 'project.registry.json')
// 尝试加载现有的注册表文件
if (await this._fsExists(registryPath)) {
const registryData = await RegistryData.fromFile('project', registryPath)
// 检查注册表是否有效(有完整的资源数据)
if (registryData.size > 0 && registryData.resources.length > 0) {
const firstResource = registryData.resources[0]
if (firstResource.id && firstResource.protocol && firstResource.reference) {
logger.info(`[ProjectDiscovery] 📋 从注册表加载 ${registryData.size} 个资源`)
return registryData
}
}
// 如果注册表无效,重新生成
logger.info(`[ProjectDiscovery] 📋 项目注册表无效,重新生成`)
return await this.generateRegistry(projectRoot)
} else {
// 如果没有注册表文件,生成新的
logger.info(`[ProjectDiscovery] 📋 项目注册表不存在,生成新注册表`)
return await this.generateRegistry(projectRoot)
}
} catch (error) {
logger.warn(`[ProjectDiscovery] Failed to load RegistryData: ${error.message}`)
// 返回空的RegistryData
return RegistryData.createEmpty('project', null)
}
}
}
module.exports = ProjectDiscovery