File size: 16,260 Bytes
ee826ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
/**
 * ============================================
 * 🔄 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);

}