/** * ============================================ * 🧠 memory.js β€” Memoria y Contexto Completo * ============================================ * Construye el contexto mΓ‘s rico posible para * que Zelin sepa exactamente dΓ³nde estΓ‘, con * quiΓ©n habla y quΓ© ha pasado. */ import * as db from './db.js'; import { readConfig } from './utils.js'; const config = readConfig(); // ── Contexto del canal (mensajes recientes) ─────────────────────────────────── export async function getChannelContext(channelId, limit = 20) { // Priorizar conversation_context (fuente unificada) sobre messages // porque saveInteraction escribe ahΓ­ y es mΓ‘s reciente/fiable try { const ctxRows = await db.getContext(channelId, limit); if (ctxRows && ctxRows.length >= 3) { return ctxRows .map(r => { const who = r.role === 'assistant' ? 'Zelin' : (r.username ?? r.user_id ?? 'usuario'); return `[${who}]: ${r.content}`; }) .join('\n'); } } catch {} // Fallback a tabla messages si conversation_context tiene poco const messages = await db.getRecentMessages(channelId, limit * 2); if (!messages.length) return ''; const threeHoursAgo = Date.now() - 3 * 60 * 60 * 1000; const recent = messages.filter(m => { const ts = new Date(m.created_at || m.timestamp || 0).getTime(); return ts > threeHoursAgo; }); const final = recent.length >= 5 ? recent : messages.slice(-limit); return final.slice(-limit) .map(m => `[${m.nickname || m.username}]: ${m.content}`) .join('\n'); } // ── Lo que Zelin ha dicho recientemente en este canal ──────────────────────── export async function getZelinRecentMessages(channelId, limit = 5) { try { const r = await db.db.execute({ sql : `SELECT content FROM conversation_context WHERE channel_id = ? AND role = 'assistant' ORDER BY created_at DESC LIMIT ?`, args: [channelId, limit], }); return r.rows.map(row => row.content).reverse(); } catch { return []; } } // ── Historial Zelin ↔ Usuario ───────────────────────────────────────────────── export async function getUserHistory(userId, limit = 15) { const history = await db.getZelinUserHistory(userId, limit); if (!history.length) return null; return history.map(h => { const who = h.role === 'assistant' ? 'Zelin' : 'Usuario'; return `[${who}]: ${h.content}`; }).join('\n'); } // ── Contexto global del servidor ────────────────────────────────────────────── export async function getGlobalContext(mcOnlineOverride = null) { const [stats, summaries, topUsers] = await Promise.all([ db.getServerStats(), db.getRecentSummaries(1), db.getTopUsers(7, 5), ]); const pulse = await getCommunityPulse(3).catch(() => null); const topUsersStr = topUsers .map(u => `${u.nickname || u.username} (${u.count} msgs)`) .join(', '); return { activeUsers : stats.activeUsers, totalMessages: stats.totalMessages, topUsers : topUsersStr, mood : pulse?.mood ?? 'neutral', recentSummary: summaries[0]?.summary ?? null, mcOnline : mcOnlineOverride, }; } // ── Perfil de usuario enriquecido ───────────────────────────────────────────── export async function getEnrichedUserProfile(userId) { const profile = await db.getUserWithRoles(userId); if (!profile) return null; // displayName: nickname > global_name > username profile.displayName = profile.nickname || profile.global_name || profile.username || 'alguien'; // Observaciones de Zelin sobre este usuario const observations = await db.memGet(`user.observations.${userId}`) ?? ''; if (observations) profile.notes = observations; return profile; } // ── Construir contexto completo ─────────────────────────────────────────────── /** * Construye el contexto mΓ‘s completo posible para una llamada a la IA. * Incluye: canal, usuario, historial, global, tiempo, skills disponibles. */ export async function buildContext(channelId, userId, { mcOnline = null, isDM = false, channelObj = null, skillsList = [], guild = null } = {}) { const [channelCtx, userHistory, globalCtx, userProfile, zelinHistory] = await Promise.all([ getChannelContext(channelId, config.behavior.contextWindow), getUserHistory(userId), getGlobalContext(mcOnline), getEnrichedUserProfile(userId), getZelinRecentMessages(channelId), ]); // Info del canal let channelInfo = null; if (!isDM && channelObj) { channelInfo = { name : channelObj.name ?? 'desconocido', topic : channelObj.topic ?? null, category: channelObj.parent?.name ?? null, }; } // Tiempo actual const now = new Date(); const currentTime = now.toLocaleString('es-ES', { timeZone : 'America/Mexico_City', weekday : 'long', hour : '2-digit', minute : '2-digit', day : '2-digit', month : 'long', }); // Contexto unificado: si es DM, tambiΓ©n adjuntar el ΓΊltimo contexto de guild // para que Zelin no pierda el hilo entre DM y el chat del servidor let linkedGuildContext = null; if (isDM) { try { const lastGuildCtx = await db.memGet(`user.last_guild_ctx.${userId}`); if (lastGuildCtx) linkedGuildContext = lastGuildCtx; } catch {} } else { // Guardar el contexto del guild para el usuario, asΓ­ lo tiene en DM db.memSet(`user.last_guild_ctx.${userId}`, channelCtx.substring(0, 800), 'context').catch(() => {}); } // Cargar canales y usuarios del servidor desde DB para que la IA los conozca let guildChannels = [], guildUsers = []; // Cargar canales siempre que haya guild disponible (incluso en DMs del owner) if (guild) { guildChannels = guild.channels.cache .filter(c => c.isTextBased?.() && c.type !== 4) // solo canales de texto, sin categorΓ­as .map(c => ({ channel_id: c.id, name : c.name, type : c.type, topic : c.topic ?? null, category : c.parent?.name ?? null, position : c.position ?? 0, url : `https://discord.com/channels/${guild.id}/${c.id}`, })) .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); guildUsers = await db.getAllActiveUsers().catch(() => []); } else if (!isDM) { // Fallback a DB si no tenemos guild en cache guildChannels = await db.getAllChannels().catch(() => []); guildUsers = await db.getAllActiveUsers().catch(() => []); } return { channelContext : channelCtx, userHistory, globalContext : globalCtx, userProfile, zelinHistory, channelInfo, currentTime, isDM, availableSkills : skillsList, linkedGuildContext, guildChannels, guildUsers, }; } // ── Guardar interacciΓ³n ─────────────────────────────────────────────────────── export async function saveInteraction(channelId, userId, userMessage, zelinResponse, username = null) { if (!channelId || !userId) return; const uMsg = (userMessage ?? '').toString().substring(0, 1500); const zMsg = (zelinResponse ?? '').toString().substring(0, 1500); if (!uMsg.trim() || !zMsg.trim()) return; // no guardar interacciones vacΓ­as await Promise.all([ db.addContext(channelId, 'user', uMsg, userId, username), db.addContext(channelId, 'assistant', zMsg, 'zelin', 'Zelin'), ]); } // ── Community Pulse ──────────────────────────────────────────────────────────── export async function updateCommunityPulse(sentiment) { const key = 'community.pulse'; const today = new Date().toISOString().split('T')[0]; let data; try { data = await db.memGet(key) ?? {}; } catch { data = {}; } if (!data[today]) data[today] = { positive: 0, negative: 0, neutral: 0, conflict: 0 }; data[today][sentiment] = (data[today][sentiment] ?? 0) + 1; // Mantener 30 dΓ­as const days = Object.keys(data).sort().reverse().slice(0, 30); const trimmed = {}; for (const d of days) trimmed[d] = data[d]; await db.memSet(key, trimmed, 'analytics'); } export async function getCommunityPulse(days = 7) { let pulse; try { pulse = await db.memGet('community.pulse') ?? {}; } catch { return { mood: 'neutral', totals: {} }; } const cutoff = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]; const totals = { positive: 0, negative: 0, neutral: 0, conflict: 0 }; for (const [day, counts] of Object.entries(pulse)) { if (day >= cutoff) { for (const [k, v] of Object.entries(counts)) { totals[k] = (totals[k] ?? 0) + v; } } } const total = Object.values(totals).reduce((a, b) => a + b, 0); if (!total) return { mood: 'sin datos', totals }; const score = (totals.positive - totals.negative - totals.conflict * 2) / total; const mood = score > 0.3 ? 'positivo' : score < -0.2 ? 'tenso' : 'neutral'; return { mood, totals, score }; } // ── Actualizar observaciones sobre un usuario ───────────────────────────────── export async function updateUserObservations(userId, observation) { const key = `user.observations.${userId}`; const existing = await db.memGet(key) ?? ''; const updated = (existing ? existing + '\n' : '') + `[${new Date().toLocaleDateString('es-ES')}] ${observation}`; const trimmed = updated.length > 600 ? updated.slice(-600) : updated; await db.memSet(key, trimmed, 'user_profiles'); } export async function evolvePersonality(feedback) { const key = 'personality.evolution_log'; let log; try { log = await db.memGet(key) ?? []; } catch { log = []; } if (!Array.isArray(log)) log = []; log.push({ timestamp: new Date().toISOString(), ...feedback }); if (log.length > 100) log.splice(0, log.length - 100); await db.memSet(key, log, 'personality'); } // ── Limpieza automΓ‘tica ──────────────────────────────────────────────────────── export async function runCleanup(aiCallFn) { const oldGroups = await db.getOldMessages(config.cleanup.messagesDays); if (!oldGroups.length) return; for (const group of oldGroups) { if (group.count < 50) continue; try { const msgs = await db.getRecentMessages(group.channel_id, 200); const sample = msgs.map(m => `[${m.username}]: ${m.content}`).join('\n').substring(0, 3000); const summary = await aiCallFn([ { role: 'system', content: 'Resume esta conversaciΓ³n de Discord en espaΓ±ol. MΓ‘ximo 150 palabras. Solo el resumen, sin intro.' }, { role: 'user', content: sample }, ], 'fast', 300); await db.saveSummary({ channelId : group.channel_id, periodStart : group.start, periodEnd : group.end, messageCount: group.count, summary, topUsers : [], topTopics : [], }); const cutoff = new Date(Date.now() - config.cleanup.messagesDays * 86400000).toISOString(); await db.deleteOldMessages(group.channel_id, cutoff); console.log(`[Cleanup] Canal ${group.channel_id}: ${group.count} msgs resumidos y borrados`); } catch (err) { console.error('[Cleanup] Error:', err.message); } } } // Alias para commands.js export async function getUserObservations(userId) { return await db.memGet(`user.observations.${userId}`) ?? null; } // ═══════════════════════════════════════════════════════════════════════════════ // CHANNEL REGISTRY β€” se actualiza automΓ‘ticamente con eventos de Discord // ═══════════════════════════════════════════════════════════════════════════════ let _channelRegistry = new Map(); // id β†’ { id, name, category, topic, position } let _guildRef = null; export function initChannelRegistry(guild) { _guildRef = guild; _rebuildRegistry(guild); console.log(`[Channels] Registry iniciado: ${_channelRegistry.size} canales`); } function _rebuildRegistry(guild) { if (!guild) return; _channelRegistry.clear(); for (const [id, ch] of guild.channels.cache) { if (!ch.isTextBased?.() || ch.type === 4) continue; _channelRegistry.set(id, { id, name : ch.name, category: ch.parent?.name ?? null, topic : ch.topic ?? null, position: ch.position ?? 0, }); } } // Llamar desde index.js en channelCreate/Delete/Update export function onChannelCreate(channel) { if (!channel.isTextBased?.() || channel.type === 4) return; _channelRegistry.set(channel.id, { id : channel.id, name : channel.name, category: channel.parent?.name ?? null, topic : channel.topic ?? null, position: channel.position ?? 0, }); console.log(`[Channels] Canal aΓ±adido: #${channel.name} (${channel.id})`); } export function onChannelDelete(channel) { if (_channelRegistry.delete(channel.id)) { console.log(`[Channels] Canal eliminado: #${channel.name} (${channel.id})`); } } export function onChannelUpdate(oldCh, newCh) { if (!newCh.isTextBased?.() || newCh.type === 4) return; _channelRegistry.set(newCh.id, { id : newCh.id, name : newCh.name, category: newCh.parent?.name ?? null, topic : newCh.topic ?? null, position: newCh.position ?? 0, }); } // Obtener todos los canales en formato para el prompt export function getChannelRegistryForPrompt() { if (_channelRegistry.size === 0) return ''; // IMPORTANTE: NO listar todos los canales β€” son demasiados tokens y saturan el prompt // Solo listar los canales MÁS IMPORTANTES para que Zelin pueda referirse a ellos // El resto los puede buscar con findChannelByNameOrId cuando los necesite // Canales prioritarios: chat principal, anuncios, comandos, staff const PRIORITY_KEYWORDS = ['chat', 'anunci', 'general', 'comand', 'staff', 'ayuda', 'support', 'off', 'media']; const byCategory = {}; for (const ch of _channelRegistry.values()) { const name = ch.name.toLowerCase(); const isPriority = PRIORITY_KEYWORDS.some(kw => name.includes(kw)); if (!isPriority) continue; // solo canales prioritarios const cat = ch.category ?? 'Sin categorΓ­a'; if (!byCategory[cat]) byCategory[cat] = []; byCategory[cat].push(ch); } let text = '## CANALES PRINCIPALES (usa <#ID> para mencionar)\n'; for (const [cat, channels] of Object.entries(byCategory)) { for (const ch of channels.sort((a,b) => a.position - b.position)) { text += `<#${ch.id}> #${ch.name}\n`; } } text += `Total canales: ${_channelRegistry.size}. Para buscar otro canal usa su nombre exacto.`; return text; } // Buscar canal por nombre parcial o ID β€” null-safe export function findChannelByNameOrId(query) { if (!query) return null; const q = query.toLowerCase().replace(/[#<>]/g, '').trim(); // 1. Por ID exacto if (_channelRegistry.has(q)) return _channelRegistry.get(q); // 2. Por nombre exacto (tal cual) for (const ch of _channelRegistry.values()) { if (ch.name === q) return ch; } // 3. Por nombre normalizado exacto (ignora emojis y separadores) const normalize = s => s.replace(/[^a-z0-9]/g, ''); const qn = normalize(q); for (const ch of _channelRegistry.values()) { if (normalize(ch.name) === qn) return ch; } // 4. Por nombre parcial β€” excluir canales de staff/admin/log para evitar falsos positivos const EXCLUDE_PARTIAL = ['staff', 'admin', 'mod', 'log', 'ticket', 'bot']; for (const ch of _channelRegistry.values()) { const normalized = normalize(ch.name); const isExcluded = EXCLUDE_PARTIAL.some(ex => ch.name.toLowerCase().includes(ex)); if (!isExcluded && normalized.includes(qn) && qn.length > 2) return ch; } // 5. Último recurso: parcial sin exclusiones (por si la bΓΊsqueda es explΓ­citamente un canal staff) for (const ch of _channelRegistry.values()) { if (normalize(ch.name).includes(qn) && qn.length > 2) return ch; } return null; } export function getChannelRegistry() { return _channelRegistry; }