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