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