/** * ============================================ * 🔄 selfimprove.js — Auto-Mejora con Aprobación * ============================================ * Zelin puede proponer mejoras a su propio código. * Siempre requiere aprobación del owner por DM. * * Dos flujos: * 1. Auto: Zelin detecta un problema y propone mejora * 2. Manual: el owner pide explícitamente una modificación */ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { callAI, callAIBackground } from './ai.js'; import { buildSelfImprovementPrompt, CODING_STYLE_PROMPT } from './prompt.js'; import { readConfig } from './utils.js'; import * as db from './db.js'; // Ruta base del proyecto (un nivel arriba de src/) const __filename = fileURLToPath(import.meta.url); const BASE_DIR = path.dirname(path.dirname(__filename)); // /home/container const config = readConfig(); // issueLog en memoria (no necesita persistir — es diagnóstico de la sesión actual) const issueLog = []; // pendingProposals: persistido en Turso para sobrevivir reinicios // Clave: selfimprove.pending.{id} → { proposal, sentAt } export const pendingProposals = { async get(id) { try { const v = await db.memGet('selfimprove.pending.' + id); return v ?? null; } catch { return null; } }, async set(id, data) { try { await db.memSet('selfimprove.pending.' + id, data, 'selfimprove'); } catch {} }, async delete(id) { try { await db.db.execute({ sql: 'DELETE FROM zelin_memory WHERE key = ?', args: ['selfimprove.pending.' + id] }); } catch {} }, async hasPendingFor(description) { // Evitar enviar la misma propuesta dos veces try { const r = await db.db.execute({ sql : "SELECT key, value FROM zelin_memory WHERE category = 'selfimprove' AND key LIKE 'selfimprove.pending.%' LIMIT 20", args: [], }); for (const row of r.rows) { try { const data = typeof row.value === 'string' ? JSON.parse(row.value) : row.value; const issue = data?.proposal?.issue ?? ''; if (issue && description && issue.substring(0,80) === description.substring(0,80)) return true; } catch {} } } catch {} return false; }, }; // ── Log de issues internos ──────────────────────────────────────────────────── export function logIssue(description, sourceFile = null) { // Deduplicar: no añadir si el mismo error ya está pendiente sin reportar const isDupe = issueLog.some(i => !i.reported && i.description === description && i.sourceFile === sourceFile ); if (isDupe) return; issueLog.push({ description, sourceFile, timestamp: new Date().toISOString(), reported : false, }); if (issueLog.length > 50) issueLog.shift(); } // ── Generar propuesta de mejora ─────────────────────────────────────────────── export async function generateImprovement(issue) { if (!issue.sourceFile) return null; // Leer el archivo a mejorar — buscar en raíz primero, luego src/ let currentCode = ''; try { // Orden: raíz → src/ (index.js está en raíz, el resto en src/) let filePath; try { filePath = path.join(BASE_DIR, issue.sourceFile); currentCode = await fs.readFile(filePath, 'utf-8'); } catch { filePath = path.join(BASE_DIR, 'src', issue.sourceFile); currentCode = await fs.readFile(filePath, 'utf-8'); } if (currentCode.length > 4000) { // Extraer solo la sección relevante al issue usando palabras clave const issueWords = issue.description.toLowerCase().split(/\s+/).filter(w => w.length > 4); let bestIdx = 0; let bestScore = 0; const lines = currentCode.split('\n'); for (let i = 0; i < lines.length - 30; i++) { const window = lines.slice(i, i + 60).join('\n').toLowerCase(); const score = issueWords.filter(w => window.includes(w)).length; if (score > bestScore) { bestScore = score; bestIdx = i; } } // Tomar 80 líneas centradas en el fragmento más relevante const start = Math.max(0, bestIdx - 10); const relevant = lines.slice(start, start + 80).join('\n'); const header = lines.slice(0, 5).join('\n'); // imports y declaraciones currentCode = (start > 5 ? header + '\n// ... \n' : '') + relevant; if (start + 80 < lines.length) currentCode += '\n// ... (resto del archivo omitido)'; } } catch (err) { console.error(`[SelfImprove] No se pudo leer ${issue.sourceFile}:`, err.message); return null; } try { const raw = await callAI([ { role: 'system', content: CODING_STYLE_PROMPT }, { role: 'user', content: buildSelfImprovementPrompt(issue.description, currentCode) }, ], 'code', 1000); const clean = raw.replace(/```json[\s\S]*?```/g, m => m.replace(/```json|```/g, '')).replace(/```/g, '').trim(); const proposal = JSON.parse(clean); // Validaciones de seguridad if (!proposal.fileToModify || !proposal.oldCode || !proposal.newCode) { throw new Error('Propuesta incompleta (faltan campos)'); } if (proposal.newCode.includes('@everyone') || proposal.newCode.includes('@here')) { throw new Error('Propuesta rechazada: contiene menciones masivas'); } return proposal; } catch (err) { if (err instanceof SyntaxError) { console.warn('[SelfImprove] AI returned non-JSON response (first 100 chars):', raw?.slice(0, 100)); } else { console.error('[SelfImprove] Error generando propuesta:', err.message); } return null; } } // ── Enviar propuesta al owner por DM ───────────────────────────────────────── export async function sendProposalToOwner(client, proposal, triggeredBy = 'auto') { let owner; try { owner = await client.users.fetch(config.admin.userId); } catch (err) { console.error('[SelfImprove] No se pudo fetch del owner:', err.message); return null; } const proposalId = `prop_${Date.now()}`; // Truncar código para que no supere el límite de Discord (1024 chars por field) const oldCodeShort = (proposal.oldCode ?? '').substring(0, 800); const newCodeShort = (proposal.newCode ?? '').substring(0, 800); const embed = new EmbedBuilder() .setTitle('🔄 Propuesta de mejora de código') .setColor(0x5865f2) .setDescription(`Origen: **${triggeredBy === 'auto' ? 'detección automática' : 'petición manual'}**`) .addFields( { name: '⚠️ Problema', value: (proposal.issue ?? 'No especificado').substring(0, 1020), inline: false }, { name: '💡 Solución', value: (proposal.solution ?? 'No especificado').substring(0, 1020), inline: false }, { name: '📁 Archivo', value: `\`${proposal.fileToModify ?? '?'}\``, inline: true }, { name: '🛡️ Seguridad', value: proposal.safetyImpact ?? 'none', inline: true }, ) .setFooter({ text: `ID: ${proposalId} • Zelin v5.8` }) .setTimestamp(); // Agregar código en fields separados si no está vacío if (oldCodeShort) { embed.addFields({ name : '📝 Código actual', value: `\`\`\`js\n${oldCodeShort}\n\`\`\``.substring(0, 1024), inline: false, }); } if (newCodeShort) { embed.addFields({ name : '✨ Código nuevo', value: `\`\`\`js\n${newCodeShort}\n\`\`\``.substring(0, 1024), inline: false, }); } const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`approve_${proposalId}`) .setLabel('✅ Aprobar y aplicar') .setStyle(ButtonStyle.Success), new ButtonBuilder() .setCustomId(`reject_${proposalId}`) .setLabel('❌ Rechazar') .setStyle(ButtonStyle.Danger), ); // Anti-spam: no enviar si ya hay propuesta pendiente con el mismo problema const alreadyPending = await pendingProposals.hasPendingFor(proposal.issue ?? ''); if (alreadyPending) { console.log('[SelfImprove] Propuesta duplicada ignorada (ya hay una pendiente similar)'); return null; } try { const dm = await owner.createDM(); await dm.send({ embeds: [embed], components: [row] }); await pendingProposals.set(proposalId, { proposal, sentAt: new Date().toISOString() }); console.log(`[SelfImprove] ✅ Propuesta ${proposalId} enviada al owner`); return proposalId; } catch (err) { console.error('[SelfImprove] Error enviando DM al owner:', err.message); return null; } } // ── Aplicar propuesta aprobada ──────────────────────────────────────────────── export async function applyProposal(proposalId) { const data = await pendingProposals.get(proposalId); if (!data) return { success: false, reason: 'Propuesta no encontrada. Si hace más de 24h puede haber expirado.' }; const { proposal } = data; if (!proposal.fileToModify || !proposal.oldCode || !proposal.newCode) { return { success: false, reason: 'Datos de la propuesta incompletos' }; } // Normalizar alias comunes que la IA genera incorrectamente const FILE_ALIASES = { 'main.js': 'index.js', 'app.js': 'index.js', 'bot.js': 'index.js' }; if (FILE_ALIASES[proposal.fileToModify]) { console.log('[SelfImprove] Alias:', proposal.fileToModify, '->', FILE_ALIASES[proposal.fileToModify]); proposal.fileToModify = FILE_ALIASES[proposal.fileToModify]; } // Buscar el archivo — raíz primero, luego src/ let filePath; let current; try { filePath = path.join(BASE_DIR, proposal.fileToModify); // Validate: resolved path must be within BASE_DIR const resolved1 = path.resolve(filePath); if (!resolved1.startsWith(path.resolve(BASE_DIR))) { return { success: false, reason: `Path traversal blocked: ${proposal.fileToModify}` }; } current = await fs.readFile(filePath, 'utf-8'); } catch { try { filePath = path.join(BASE_DIR, 'src', proposal.fileToModify); const resolved2 = path.resolve(filePath); if (!resolved2.startsWith(path.resolve(BASE_DIR))) { return { success: false, reason: `Path traversal blocked: ${proposal.fileToModify}` }; } current = await fs.readFile(filePath, 'utf-8'); } catch (err) { return { success: false, reason: `No se encontró el archivo: ${proposal.fileToModify}` }; } } const trimmedOld = proposal.oldCode.trim(); // Intento 1: match exacto let matched = current.includes(trimmedOld); let searchStr = trimmedOld; // Intento 2: normalizar espacios/tabs (diferencias de formateo) if (!matched) { const normalize = s => s.replace(/[ \t]+/g, ' ').replace(/\r\n/g, '\n').trim(); const normCurrent = normalize(current); const normOld = normalize(trimmedOld); if (normCurrent.includes(normOld)) { // Reconstruir: encontrar el fragmento real en el archivo original const idx = normCurrent.indexOf(normOld); // Mapear posición en el normalizado al original (aproximado por líneas) const linesBeforeInNorm = normCurrent.substring(0, idx).split('\n').length - 1; const origLines = current.split('\n'); // Buscar las primeras líneas del fragmento en el original const firstLineOfOld = normOld.split('\n')[0].trim(); const realIdx = origLines.findIndex(l => l.trim().startsWith(firstLineOfOld.substring(0, 30))); if (realIdx >= 0) { const oldLines = trimmedOld.split('\n').length; searchStr = origLines.slice(realIdx, realIdx + oldLines).join('\n'); matched = current.includes(searchStr); } } } // Intento 3: match ignorando indentación completamente if (!matched) { const stripIndent = s => s.split('\n').map(l => l.trim()).filter(l => l).join('\n'); const strippedCurrent = stripIndent(current); const strippedOld = stripIndent(trimmedOld); if (strippedCurrent.includes(strippedOld) && strippedOld.length > 20) { // Reconstruir buscando la primera línea no vacía en el original const firstSig = trimmedOld.split('\n').find(l => l.trim().length > 10)?.trim(); if (firstSig) { const origLines = current.split('\n'); const firstMatch = origLines.findIndex(l => l.trim() === firstSig || l.includes(firstSig)); if (firstMatch >= 0) { const oldLineCount = trimmedOld.split('\n').length; searchStr = origLines.slice(firstMatch, firstMatch + oldLineCount).join('\n'); matched = current.includes(searchStr); } } } } // Intento 4: match parcial (primeras 3 líneas del fragmento) if (!matched) { const firstThreeLines = trimmedOld.split('\n').slice(0, 3).join('\n').trim(); if (firstThreeLines.length > 20 && current.includes(firstThreeLines)) { // Encontrar el bloque más largo que empiece con esas líneas const startIdx = current.indexOf(firstThreeLines); const endOfOld = trimmedOld.split('\n').length; const fromStart = current.substring(startIdx).split('\n').slice(0, endOfOld).join('\n'); searchStr = fromStart; matched = true; } } if (!matched) { return { success: false, reason : `Fragmento no encontrado en ${proposal.fileToModify}. El archivo puede haber cambiado. Genera una nueva propuesta.`, }; } // Crear backup y aplicar try { await fs.writeFile(filePath + '.bak', current, 'utf-8'); await fs.writeFile(filePath, current.replace(searchStr, proposal.newCode.trim()), 'utf-8'); await pendingProposals.delete(proposalId); console.log(`[SelfImprove] ✅ Propuesta ${proposalId} aplicada en ${proposal.fileToModify}`); return { success: true, file: proposal.fileToModify }; } catch (err) { return { success: false, reason: `Error al escribir el archivo: ${err.message}` }; } } export async function rejectProposal(proposalId) { await pendingProposals.delete(proposalId); console.log(`[SelfImprove] ❌ Propuesta ${proposalId} rechazada`); } // ── Auto-reflexión periódica ────────────────────────────────────────────────── export async function runSelfReflection(client) { const unreported = issueLog.filter(i => !i.reported && i.sourceFile); if (!unreported.length) return; // Priorizar: error > otros. Dentro de cada tipo, el primero (más antiguo) const errors = unreported.filter(i => i.description.toLowerCase().includes('error')); const issue = (errors[0] ?? unreported[0]); issue.reported = true; const proposal = await generateImprovement(issue); if (proposal) await sendProposalToOwner(client, proposal, 'auto'); } // ── Mejora manual: el owner pide un cambio específico ──────────────────────── export async function handleManualImprovement(client, message, request) { const proposal = await generateImprovement({ description: request, sourceFile : 'index.js', // Punto de entrada para análisis reported : false, }); if (!proposal) { await message.reply('no pude generar la mejora, intenta de nuevo con más detalle').catch(() => {}); return; } const proposalId = await sendProposalToOwner(client, proposal, 'manual'); if (proposalId) { await message.reply(`propuesta enviada a tu DM bro, revísala y aprueba si está bien`).catch(() => {}); } else { await message.reply('no pude enviarte el DM, revisa que tengas DMs abiertos conmigo').catch(() => {}); } } export function initSelfImprovement(client) { console.log("[SelfImprove] sistema iniciado"); // Ejecutar reflexión automática cada 30 minutos setInterval(() => { runSelfReflection(client).catch(err => console.error("[SelfImprove] reflection error:", err) ); }, 30 * 60 * 1000); }