zelin-bot / src /selfimprove.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* 🔄 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);
}