/** * Alfred Agent Harness — Tool Registry * * Inspired by Claude Code's tool architecture: * - Each tool has name, description, inputSchema, execute() * - Tools are registered in a central registry * - Execution is sandboxed and results streamed back */ import { execSync, spawn } from 'child_process'; import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; import { resolve, dirname, join, relative } from 'path'; import { homedir } from 'os'; const HOME = homedir(); const WORKSPACE = process.env.ALFRED_WORKSPACE || HOME; /** All registered tools */ const registry = new Map(); /** Register a tool */ export function registerTool(tool) { registry.set(tool.name, tool); } /** Get all tools */ export function getTools() { return Array.from(registry.values()); } /** Get tool by name */ export function getTool(name) { return registry.get(name); } /** Execute a tool by name */ export async function executeTool(name, input) { const tool = registry.get(name); if (!tool) return { error: `Unknown tool: ${name}` }; try { return await tool.execute(input); } catch (err) { return { error: `Tool ${name} failed: ${err.message}` }; } } // ═══════════════════════════════════════════════════════════════════════ // CORE TOOLS — File Operations // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'read_file', description: 'Read the contents of a file. Specify startLine/endLine for partial reads (1-based). Returns the file content as text.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute or workspace-relative path' }, startLine: { type: 'number', description: 'Start line (1-based, optional)' }, endLine: { type: 'number', description: 'End line (1-based, optional)' }, }, required: ['path'], }, async execute({ path, startLine, endLine }) { const fullPath = resolve(WORKSPACE, path); if (!existsSync(fullPath)) return { error: `File not found: ${fullPath}` }; const content = readFileSync(fullPath, 'utf8'); if (startLine || endLine) { const lines = content.split('\n'); const start = (startLine || 1) - 1; const end = endLine || lines.length; return { content: lines.slice(start, end).join('\n'), totalLines: lines.length }; } return { content, totalLines: content.split('\n').length }; }, }); registerTool({ name: 'write_file', description: 'Create or overwrite a file with the given content. Creates directories as needed.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute or workspace-relative path' }, content: { type: 'string', description: 'File content to write' }, }, required: ['path', 'content'], }, async execute({ path, content }) { const fullPath = resolve(WORKSPACE, path); mkdirSync(dirname(fullPath), { recursive: true }); writeFileSync(fullPath, content, 'utf8'); return { success: true, path: fullPath, bytes: Buffer.byteLength(content) }; }, }); registerTool({ name: 'edit_file', description: 'Replace an exact string in a file with a new string. The oldString must match exactly (including whitespace).', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Absolute or workspace-relative path' }, oldString: { type: 'string', description: 'Exact text to find and replace' }, newString: { type: 'string', description: 'Replacement text' }, }, required: ['path', 'oldString', 'newString'], }, async execute({ path, oldString, newString }) { const fullPath = resolve(WORKSPACE, path); if (!existsSync(fullPath)) return { error: `File not found: ${fullPath}` }; const content = readFileSync(fullPath, 'utf8'); const count = content.split(oldString).length - 1; if (count === 0) return { error: 'oldString not found in file' }; if (count > 1) return { error: `oldString found ${count} times — must be unique` }; writeFileSync(fullPath, content.replace(oldString, newString), 'utf8'); return { success: true, path: fullPath }; }, }); // ═══════════════════════════════════════════════════════════════════════ // CORE TOOLS — Shell / Bash // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'bash', description: 'Execute a shell command and return stdout/stderr. Use for system operations, git, package managers, etc. Commands run as the gositeme user.', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'Shell command to execute' }, cwd: { type: 'string', description: 'Working directory (optional, defaults to workspace)' }, timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' }, }, required: ['command'], }, async execute({ command, cwd, timeout = 30000 }) { // Security: block obviously dangerous commands const blocked = [/rm\s+-rf\s+\/[^\/]*/i, /mkfs/i, /dd\s+if.*of=\/dev/i, /:(){ :\|:& };:/]; for (const pattern of blocked) { if (pattern.test(command)) return { error: 'Command blocked for safety. Ask the Commander to approve.' }; } try { const stdout = execSync(command, { cwd: cwd || WORKSPACE, timeout, encoding: 'utf8', maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], }); return { stdout: stdout.slice(0, 50000), exitCode: 0 }; } catch (err) { return { stdout: (err.stdout || '').slice(0, 50000), stderr: (err.stderr || '').slice(0, 10000), exitCode: err.status || 1, }; } }, }); // ═══════════════════════════════════════════════════════════════════════ // CORE TOOLS — Search // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'glob', description: 'Search for files matching a glob pattern in the workspace.', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Glob pattern (e.g. **/*.js, src/**/*.php)' }, cwd: { type: 'string', description: 'Base directory (optional)' }, }, required: ['pattern'], }, async execute({ pattern, cwd }) { const base = cwd || WORKSPACE; try { const result = execSync(`find ${base} -path "${base}/${pattern}" -type f 2>/dev/null | head -100`, { encoding: 'utf8', timeout: 10000, }); const files = result.trim().split('\n').filter(Boolean); return { files, count: files.length }; } catch { // Fallback: use shell glob try { const result = execSync(`ls -1 ${base}/${pattern} 2>/dev/null | head -100`, { encoding: 'utf8', timeout: 10000 }); const files = result.trim().split('\n').filter(Boolean); return { files, count: files.length }; } catch { return { files: [], count: 0 }; } } }, }); registerTool({ name: 'grep', description: 'Search for text patterns in files. Returns matching lines with file paths and line numbers.', inputSchema: { type: 'object', properties: { pattern: { type: 'string', description: 'Search pattern (regex supported)' }, path: { type: 'string', description: 'Directory or file to search in' }, include: { type: 'string', description: 'File pattern to include (e.g. *.js)' }, maxResults: { type: 'number', description: 'Maximum results (default 50)' }, }, required: ['pattern'], }, async execute({ pattern, path, include, maxResults = 50 }) { const searchPath = path || WORKSPACE; let cmd = `grep -rn --color=never`; if (include) cmd += ` --include="${include}"`; cmd += ` "${pattern.replace(/"/g, '\\"')}" "${searchPath}" 2>/dev/null | head -${maxResults}`; try { const result = execSync(cmd, { encoding: 'utf8', timeout: 15000 }); const matches = result.trim().split('\n').filter(Boolean); return { matches, count: matches.length }; } catch { return { matches: [], count: 0 }; } }, }); // ═══════════════════════════════════════════════════════════════════════ // CORE TOOLS — Directory Listing // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'list_dir', description: 'List the contents of a directory with file sizes and types.', inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, }, required: ['path'], }, async execute({ path }) { const fullPath = resolve(WORKSPACE, path); if (!existsSync(fullPath)) return { error: `Directory not found: ${fullPath}` }; const entries = readdirSync(fullPath).map(name => { try { const stat = statSync(join(fullPath, name)); return { name: stat.isDirectory() ? name + '/' : name, size: stat.size, isDir: stat.isDirectory() }; } catch { return { name, size: 0, isDir: false }; } }); return { entries, count: entries.length, path: fullPath }; }, }); // ═══════════════════════════════════════════════════════════════════════ // ALFRED-SPECIFIC TOOLS — Vault, PM2, Database // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'vault_get_credential', description: 'Retrieve a decrypted credential from the Alfred vault. Returns username and password for the matching service.', inputSchema: { type: 'object', properties: { service: { type: 'string', description: 'Service name pattern to search (e.g. "SSH", "OVH", "email")' }, }, required: ['service'], }, async execute({ service }) { try { const result = execSync(`php /home/gositeme/alfred-services/get-credential.php "${service.replace(/"/g, '')}"`, { encoding: 'utf8', timeout: 5000, }); const cred = JSON.parse(result); if (cred.error) return { error: cred.error }; return { service: cred.service_name, username: cred.username, note: 'Password retrieved (not shown in output)' }; } catch (err) { return { error: `Vault lookup failed: ${err.message}` }; } }, }); registerTool({ name: 'pm2_status', description: 'Get the status of PM2 services. Can list all or check a specific service.', inputSchema: { type: 'object', properties: { service: { type: 'string', description: 'Service name (optional — omit for full list)' }, }, }, async execute({ service }) { const cmd = service ? `pm2 show ${service} 2>&1 | head -30` : `pm2 jlist 2>/dev/null`; try { const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 }); if (!service) { const list = JSON.parse(result); const summary = list.map(p => ({ name: p.name, status: p.pm2_env?.status, uptime: p.pm2_env?.pm_uptime, restarts: p.pm2_env?.restart_time, cpu: p.monit?.cpu, memory: Math.round((p.monit?.memory || 0) / 1024 / 1024) + 'MB', })); return { services: summary, total: summary.length }; } return { details: result }; } catch (err) { return { error: err.message }; } }, }); registerTool({ name: 'db_query', description: 'Execute a read-only SQL query against the gositeme_whmcs database. Only SELECT queries are allowed.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'SQL SELECT query' }, }, required: ['query'], }, async execute({ query }) { // Security: only allow SELECT queries const trimmed = query.trim().toUpperCase(); if (!trimmed.startsWith('SELECT') && !trimmed.startsWith('SHOW') && !trimmed.startsWith('DESCRIBE')) { return { error: 'Only SELECT, SHOW, and DESCRIBE queries are allowed. Mutations require Commander approval.' }; } try { const phpCode = `query(base64_decode('${Buffer.from(query).toString('base64')}')); echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC));`; const result = execSync(`php -r '${phpCode.replace(/'/g, "'\\''")}'`, { encoding: 'utf8', timeout: 10000 }); const rows = JSON.parse(result); return { rows, count: rows.length }; } catch (err) { return { error: `Query failed: ${err.message}` }; } }, }); registerTool({ name: 'memory_store', description: 'Store a persistent memory note for Alfred. Survives across sessions. Used to remember important facts, decisions, and context.', inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Memory key/topic (e.g. "server-ports", "eden-birthday")' }, content: { type: 'string', description: 'Content to remember' }, }, required: ['key', 'content'], }, async execute({ key, content }) { const memDir = join(HOME, 'alfred-agent', 'data', 'memories'); mkdirSync(memDir, { recursive: true }); const file = join(memDir, `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.md`); const entry = `\n## ${new Date().toISOString()}\n${content}\n`; const existing = existsSync(file) ? readFileSync(file, 'utf8') : `# Memory: ${key}\n`; writeFileSync(file, existing + entry, 'utf8'); return { success: true, file, key }; }, }); registerTool({ name: 'memory_recall', description: 'Recall a stored memory by key, or list all memory keys if no key given.', inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Memory key to recall (omit to list all)' }, }, }, async execute({ key }) { const memDir = join(HOME, 'alfred-agent', 'data', 'memories'); if (!existsSync(memDir)) return { memories: [], note: 'No memories stored yet' }; if (!key) { const files = readdirSync(memDir).filter(f => f.endsWith('.md')); return { keys: files.map(f => f.replace('.md', '')), count: files.length }; } const file = join(memDir, `${key.replace(/[^a-zA-Z0-9_-]/g, '_')}.md`); if (!existsSync(file)) return { error: `No memory found for key: ${key}` }; return { content: readFileSync(file, 'utf8'), key }; }, }); registerTool({ name: 'web_fetch', description: 'Fetch the content of a web page. Returns text content (HTML stripped).', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to fetch' }, }, required: ['url'], }, async execute({ url }) { // Validate the URL to prevent SSRF const parsed = new URL(url); if (['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname) || parsed.hostname.startsWith('192.168.') || parsed.hostname.startsWith('10.')) { return { error: 'Cannot fetch internal/private URLs for security reasons' }; } try { const res = await fetch(url, { headers: { 'User-Agent': 'Alfred-Agent/1.0' }, signal: AbortSignal.timeout(15000), }); const text = await res.text(); // Strip HTML tags for readability const clean = text.replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 50000); return { content: clean, status: res.status, url }; } catch (err) { return { error: `Fetch failed: ${err.message}` }; } }, }); registerTool({ name: 'session_journal', description: 'Save a session journal entry using the Alfred session save system. Call this at the end of each session to record what was accomplished.', inputSchema: { type: 'object', properties: { summary: { type: 'string', description: 'Summary of what was accomplished this session' }, }, required: ['summary'], }, async execute({ summary }) { try { const result = execSync( `php /home/gositeme/.vault/session-save.php "${summary.replace(/"/g, '\\"').slice(0, 500)}"`, { encoding: 'utf8', timeout: 5000 } ); return { success: true, result: result.trim() }; } catch (err) { return { error: `Session save failed: ${err.message}` }; } }, }); // ═══════════════════════════════════════════════════════════════════════ // MCP BRIDGE — Access all 856+ GoCodeMe MCP tools // ═══════════════════════════════════════════════════════════════════════ registerTool({ name: 'mcp_call', description: 'Call any tool from the GoCodeMe MCP server (856+ tools across 32 categories). Use mcp_list first to discover available tools, then call them by name with their required arguments.', inputSchema: { type: 'object', properties: { tool: { type: 'string', description: 'The MCP tool name to call (e.g. "read_file", "billing_get_invoices", "code_interpreter")' }, args: { type: 'object', description: 'Arguments to pass to the MCP tool (varies per tool)' }, }, required: ['tool'], }, async execute({ tool, args }) { try { const payload = JSON.stringify({ jsonrpc: '2.0', method: 'tools/call', id: Date.now(), params: { name: tool, arguments: args || {} }, }); const result = execSync( `curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${payload.replace(/'/g, "'\\''")}'`, { encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 } ); const parsed = JSON.parse(result); if (parsed.error) return { error: `MCP error: ${parsed.error.message || JSON.stringify(parsed.error)}` }; // Extract content from MCP result format const content = parsed.result?.content; if (Array.isArray(content) && content.length > 0) { const textParts = content.filter(c => c.type === 'text').map(c => c.text); return { result: textParts.join('\n') || JSON.stringify(content) }; } return { result: JSON.stringify(parsed.result || parsed) }; } catch (err) { return { error: `MCP call failed: ${err.message}` }; } }, }); registerTool({ name: 'mcp_list', description: 'List available MCP tools from the GoCodeMe server. Use category filter to narrow results, or search by keyword. Returns tool names and descriptions.', inputSchema: { type: 'object', properties: { category: { type: 'string', description: 'Filter by category (e.g. "billing", "files", "sentinel", "cortex", "empathy"). Leave empty for all.' }, search: { type: 'string', description: 'Search keyword to filter tools by name or description' }, }, }, async execute({ category, search }) { try { const result = execSync( `curl -s http://127.0.0.1:3006/mcp/docs/summary`, { encoding: 'utf8', timeout: 10000 } ); const data = JSON.parse(result); let summary = `Total: ${data.totalTools} tools in ${data.totalCategories} categories\n\n`; if (category || search) { // Get full tool list for filtering const listResult = execSync( `curl -s -X POST http://127.0.0.1:3006/mcp -H 'Content-Type: application/json' -d '${JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 })}'`, { encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024 } ); const listData = JSON.parse(listResult); let tools = listData.result?.tools || []; if (category) { tools = tools.filter(t => (t.category || '').toLowerCase().includes(category.toLowerCase())); } if (search) { const q = search.toLowerCase(); tools = tools.filter(t => (t.name || '').toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q) ); } summary += `Filtered: ${tools.length} tools\n\n`; for (const t of tools.slice(0, 50)) { summary += `• ${t.name}: ${(t.description || '').slice(0, 120)}\n`; } if (tools.length > 50) summary += `\n... and ${tools.length - 50} more`; } else { for (const cat of (data.categories || [])) { summary += `${cat.icon} ${cat.label}: ${cat.toolCount} tools\n`; } } return { result: summary }; } catch (err) { return { error: `MCP list failed: ${err.message}` }; } }, });