zelin-bot / src /react.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================================
* react.js — ReAct Loop + Tool Registry
* ============================================================
* Patrón Reason → Act → Observe para autonomía real.
*
* Reglas de seguridad duras:
* - NUNCA escribe directamente a /src/
* - NUNCA hace git operations
* - NUNCA ejecuta comandos shell arbitrarios
* - Acciones destructivas SIEMPRE requieren aprobación
*
* Activar con reactLoop: true en config.features
*/
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import * as db from './db.js';
import { callAI } from './ai.js';
import { readConfig } from './utils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BASE_DIR = path.dirname(__dirname);
const config = readConfig();
let _client = null, _guild = null;
export function initReact(client, guild) { _client = client; _guild = guild; }
// ── Tool Registry ─────────────────────────────────────────────────────────────
const TOOLS = {
get_server_stats: {
description: 'Obtiene estadísticas del servidor Discord/Minecraft',
params: {},
async execute() {
const stats = await db.getServerStats();
return JSON.stringify(stats);
},
},
lookup_player: {
description: 'Busca información de un jugador por nombre o ID',
params: { identifier: 'string — nombre, nick o Discord ID' },
async execute({ identifier }) {
const rows = await db.db.execute({
sql : 'SELECT * FROM users WHERE username=? OR nickname=? OR user_id=? LIMIT 1',
args: [identifier, identifier, identifier],
});
if (!rows.rows.length) return 'Jugador no encontrado: ' + identifier;
const u = rows.rows[0];
return JSON.stringify({ username: u.username, nickname: u.nickname, discordId: u.user_id, messages: u.message_count, active: u.is_active });
},
},
send_discord_message: {
description: 'Envía un mensaje a un canal de Discord por nombre o ID',
params: { channel: 'string — nombre o ID del canal', content: 'string — mensaje a enviar' },
async execute({ channel, content }) {
if (!_guild) throw new Error('Sin guild disponible');
const ch = _guild.channels.cache.find(c => c.id === channel || c.name === channel);
if (!ch) throw new Error('Canal no encontrado: ' + channel);
await ch.send(content.slice(0, 2000));
return 'Mensaje enviado a #' + ch.name;
},
},
read_own_code: {
description: 'Lee un archivo de código de Zelin (solo lectura)',
params: { file: 'string — nombre del archivo en src/ (ej: ai.js)' },
readOnly: true,
async execute({ file }) {
const safe = path.resolve(path.join(BASE_DIR, 'src'), path.basename(file));
if (!safe.startsWith(path.join(BASE_DIR, 'src'))) throw new Error('Acceso denegado');
const content = await fs.readFile(safe, 'utf8');
return content.slice(0, 3000) + (content.length > 3000 ? '\n[truncado...]' : '');
},
},
suggest_code_improvement: {
description: 'Guarda una sugerencia de mejora de código para revisión del owner. NUNCA edita código directamente.',
params: { file: 'string', improvement: 'string', reason: 'string' },
async execute({ file, improvement, reason }) {
const sugDir = path.join(BASE_DIR, 'suggestions');
await fs.mkdir(sugDir, { recursive: true });
const fname = Date.now() + '-' + path.basename(file) + '.md';
const fpath = path.join(sugDir, fname);
const content = '# Sugerencia para ' + file + '\n\n**Razón:** ' + reason + '\n\n```js\n' + improvement + '\n```\n\n*Generado por Zelin el ' + new Date().toISOString() + '*';
await fs.writeFile(fpath, content, 'utf8');
await db.memSet('selfimprove.suggestion.' + Date.now(), { file, reason, improvement: improvement.slice(0, 500), createdAt: new Date().toISOString() }, 'selfimprove');
return 'Sugerencia guardada en suggestions/' + fname;
},
},
get_mod_history: {
description: 'Obtiene el historial de moderación de un usuario',
params: { userId: 'string — Discord ID del usuario' },
async execute({ userId }) {
const hist = await db.getUserModHistory(userId, 5).catch(() => []);
if (!hist.length) return 'Sin historial de moderación para ' + userId;
return hist.map(h => '[' + h.action + '] ' + h.reason + ' (' + h.created_at + ')').join('\n');
},
},
ban_player: {
description: 'Banea un jugador del servidor. REQUIERE aprobación humana.',
params: { userId: 'string', reason: 'string', duration: 'string' },
requiresApproval: true,
async execute() { throw new Error('Requiere aprobación humana'); },
},
};
// ── ReAct Loop ────────────────────────────────────────────────────────────────
export async function reactLoop(goal, maxSteps = 5, systemPrompt = '') {
if (!config.features?.reactLoop) return null;
const toolDefs = Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, params: t.params }));
const history = [];
const sys = (systemPrompt || 'Eres Zelin, asistente de TomateSMP.') +
'\n\nHerramientas disponibles:\n' + JSON.stringify(toolDefs, null, 2) +
'\n\nPara usar una herramienta responde SOLO con JSON:\n{"thought":"...","tool":"nombre","params":{...}}' +
'\n\nPara respuesta final:\n{"thought":"...","final_answer":"..."}';
for (let step = 0; step < maxSteps; step++) {
let raw;
try {
raw = await callAI([{ role: 'system', content: sys }, { role: 'user', content: goal }, ...history], 'reasoning', null, goal);
} catch (e) { return 'Error en ReAct loop: ' + e.message; }
const clean = raw.replace(/```json|```/g, '').trim();
let parsed;
try { parsed = JSON.parse(clean); } catch { history.push({ role: 'assistant', content: raw }); continue; }
history.push({ role: 'assistant', content: raw });
if (parsed.final_answer) return parsed.final_answer;
const tool = TOOLS[parsed.tool];
if (!tool) { history.push({ role: 'user', content: 'Observation: Herramienta no existe: ' + parsed.tool }); continue; }
if (tool.requiresApproval) {
return '⚠️ Esta acción requiere aprobación de tomatitoo__: **' + parsed.tool + '** con params: ' + JSON.stringify(parsed.params);
}
let observation;
try {
observation = await tool.execute(parsed.params ?? {});
} catch (e) {
observation = 'Error ejecutando ' + parsed.tool + ': ' + e.message;
}
history.push({ role: 'user', content: 'Observation: ' + String(observation).slice(0, 1000) });
}
return 'Se alcanzó el límite de pasos (' + maxSteps + ') sin respuesta final.';
}
export { TOOLS };