File size: 8,691 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
/**
 * 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;
    }
  }
}