zelin-bot / src /context-engine.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* context-engine.js — Context Engineering para Zelin
* ====================================================
* Basado en investigación real:
* - Google ADK (Dic 2025): "Context as a compiled view, not a string buffer"
* - Liu et al. "Lost in the Middle" (2023): info en el medio se ignora 30%+ más
* - Manus AI architecture: event stream tipado + aggressive pruning
* - Context Rot research (2025): degradación empieza a 50% del context window
*
* TRES MEJORAS CONCRETAS:
* 1. Event Stream — historial tipado en vez de texto crudo
* 2. Strategic Positioning — info crítica al INICIO y al FINAL (anti-lost-in-middle)
* 3. Auto-compaction jerárquica — trigger a 50%, no cuando se llena
*/
import { callAIBackground } from './ai.js';
import { isLocalAIReady, ollamaChatDirect } from './local-ai.js';
// ── 1. EVENT STREAM tipado ────────────────────────────────────────────────────
// En vez de guardar raw text, cada evento tiene tipo y metadatos
// Así el compilador sabe qué es importante y qué puede comprimir
export function createEvent(type, content, metadata = {}) {
return { type, content, ts: Date.now(), ...metadata };
}
export const EventTypes = {
USER_MESSAGE : 'user_message',
BOT_RESPONSE : 'bot_response',
TOOL_CALL : 'tool_call',
TOOL_RESULT : 'tool_result', // pruneable después de ser observado
SYSTEM_NOTE : 'system_note',
SUMMARY : 'summary', // resultado de compaction
};
// Convertir events tipados a messages para la API
export function compileEventsToMessages(events, systemPrompt) {
const messages = [{ role: 'system', content: systemPrompt }];
for (const evt of events) {
switch (evt.type) {
case EventTypes.USER_MESSAGE:
messages.push({ role: 'user', content: evt.content });
break;
case EventTypes.BOT_RESPONSE:
messages.push({ role: 'assistant', content: evt.content });
break;
case EventTypes.SUMMARY:
// Resúmenes van como contexto del sistema, no como mensajes
messages[0] = { role: 'system', content: systemPrompt + '\n\n[Resumen de conversación anterior]: ' + evt.content };
break;
case EventTypes.TOOL_RESULT:
// Solo incluir si no fue marcado como observado (pruneable)
if (!evt.pruned) {
messages.push({ role: 'user', content: '[Resultado de herramienta]: ' + evt.content.slice(0, 300) });
}
break;
// tool_call y system_note no se pasan al modelo
}
}
return messages;
}
// ── 2. STRATEGIC POSITIONING (anti-lost-in-middle) ─────────────────────────────
// Investigación: LLMs atienden al INICIO y al FINAL, ignoran el MEDIO
// Solución: poner la info más importante al inicio y fin del system prompt
// NUNCA en el medio
export function buildStrategicPrompt(parts) {
const {
coreIdentity, // quién es Zelin — al INICIO (siempre)
securityRules, // reglas de seguridad — al INICIO (crítico)
ragContext, // contexto RAG — al INICIO si existe
conversationSummary,// resumen previo — en el medio (está bien, es contexto)
userProfile, // perfil del usuario — al FINAL (referencia)
currentTime, // hora actual — al FINAL
instructions, // instrucciones de respuesta — al FINAL (máxima atención)
} = parts;
// INICIO: lo más crítico — identidad + seguridad + contexto RAG
let prompt = coreIdentity + '\n\n';
if (securityRules) prompt += securityRules + '\n\n';
if (ragContext) prompt += '## CONTEXTO RELEVANTE DEL SERVIDOR\n' + ragContext + '\n\n';
// MEDIO: contexto de soporte (comprimir agresivamente si es largo)
if (conversationSummary) prompt += '## RESUMEN PREVIO\n' + conversationSummary + '\n\n';
// FINAL: perfil + instrucciones — alta atención del modelo
if (userProfile) prompt += '## CON QUIÉN HABLAS\n' + userProfile + '\n\n';
if (currentTime) prompt += `Hora actual: ${currentTime}\n\n`;
if (instructions) prompt += '## INSTRUCCIONES DE RESPUESTA\n' + instructions;
return prompt.trim();
}
// ── 3. AUTO-COMPACTION JERÁRQUICA ────────────────────────────────────────────
// Trigger a 50% del context window estimado (no esperar a que se llene)
// Manus: "Compress immediately after acknowledging tool outputs"
// Google ADK: sliding window con overlap para no perder contexto
// Estimación tokens: ~1 token por 3.5 chars en español
export function estimateTokens(messages) {
return Math.ceil(messages.reduce((acc, m) => acc + (m.content?.length ?? 0), 0) / 3.5);
}
const CONTEXT_WINDOW = {
'fast' : 4096,
'chat' : 8192,
'spanish' : 8192,
'reasoning' : 16384,
'code' : 16384,
'default' : 8192,
};
const COMPACTION_THRESHOLD = 0.50; // 50% → compactar (investigación recomienda 50%)
export async function shouldCompact(messages, taskType = 'default') {
const tokens = estimateTokens(messages);
const window = CONTEXT_WINDOW[taskType] ?? CONTEXT_WINDOW.default;
const fillPct = tokens / window;
return fillPct >= COMPACTION_THRESHOLD;
}
// Compaction jerárquica: mantener últimos N verbatim, resumir el resto
export async function hierarchicalCompact(messages, taskType = 'default') {
const KEEP_RECENT = 6; // últimos 6 mensajes siempre verbatim
const sys = messages.filter(m => m.role === 'system');
const convo = messages.filter(m => m.role !== 'system');
if (convo.length <= KEEP_RECENT) return messages; // nada que compactar
const toCompress = convo.slice(0, convo.length - KEEP_RECENT);
const recent = convo.slice(convo.length - KEEP_RECENT);
const text = toCompress.map(m => `${m.role === 'user' ? 'Usuario' : 'Zelin'}: ${m.content?.slice(0, 400) ?? ''}`).join('\n');
let summary;
try {
// Preferir modelo local para no gastar API en compaction
if (isLocalAIReady()) {
summary = await ollamaChatDirect([
{ role: 'system', content: 'Resume en 3-4 frases en español los puntos clave de esta conversación. Solo hechos importantes, sin detalles redundantes.' },
{ role: 'user', content: text.slice(0, 3000) },
], 200, 10000);
} else {
summary = await callAIBackground([
{ role: 'system', content: 'Resume en 3-4 frases los puntos clave. Solo hechos, sin detalles redundantes.' },
{ role: 'user', content: text.slice(0, 3000) },
], 'fast', 200);
}
} catch {
// Si falla la compaction, mantener solo los últimos mensajes
return [...sys, ...recent];
}
const summaryMsg = { role: 'user', content: `[Resumen de conversación anterior]: ${summary}` };
return [...sys, summaryMsg, { role: 'assistant', content: 'Entendido.' }, ...recent];
}
// Wrapper completo: compactar si necesario, con strategic positioning
export async function engineerContext(messages, systemPrompt, taskType, ragContext = '') {
// 1. Separar system del historial
const nonSystem = messages.filter(m => m.role !== 'system');
// 2. Auto-compaction si supera 50%
let optimizedHistory = nonSystem;
if (await shouldCompact([{ role: 'system', content: systemPrompt }, ...nonSystem], taskType)) {
console.log('[ContextEngine] 📦 Auto-compaction activada (>50% window)');
const compacted = await hierarchicalCompact([{ role: 'system', content: systemPrompt }, ...nonSystem], taskType);
optimizedHistory = compacted.filter(m => m.role !== 'system');
}
// 3. Strategic positioning del system prompt
// Info crítica al inicio, instrucciones al final
const optimizedSystem = ragContext
? systemPrompt.replace(
/## INFORMACIÓN RELEVANTE DEL SERVIDOR \(RAG\)\n[\s\S]*?(?=\n\n##|$)/,
'' // quitar RAG del medio si ya estaba
) + `\n\n## CONTEXTO RELEVANTE (posicionado al inicio para máxima atención)\n${ragContext}`
: systemPrompt;
return [{ role: 'system', content: optimizedSystem }, ...optimizedHistory];
}
// ── Pruning de tool results (como hace Manus) ─────────────────────────────────
// Después de que el agente usa un resultado de herramienta, marcarlo como "observado"
// Para que no siga ocupando context en futuras llamadas
export function markToolResultPruned(events, toolCallId) {
for (const e of events) {
if (e.type === EventTypes.TOOL_RESULT && e.toolCallId === toolCallId) {
e.pruned = true;
}
}
}