Spaces:
Paused
Paused
| /** | |
| * ============================================ | |
| * 🔄 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); | |
| } | |