alfred-agent/src/tools.js

543 lines
22 KiB
JavaScript
Raw Normal View History

/**
* 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 = `<?php
$db = new PDO('mysql:host=localhost;dbname=gositeme_whmcs;unix_socket=/run/mysql/mysql.sock','gositeme_whmcs','!q@w#e\$r5t');
$stmt = $db->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(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\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}` };
}
},
});