commit d21bd9e6500286c9cd370091416c0ecc2dea0ab2 Author: ARCHITECT Date: Mon Dec 29 18:31:54 2025 +0000 Initial commit - Captain Claude multiagent system - Core captain_claude.py orchestrator - Context manager with SQL schemas - Provider adapters (Anthropic, OpenAI) - Execution scripts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fea2b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.env +captain_output/ +.claude/ +*.log +captain diff --git a/CAPTAIN_CLAUDE.md b/CAPTAIN_CLAUDE.md new file mode 100644 index 0000000..386627b --- /dev/null +++ b/CAPTAIN_CLAUDE.md @@ -0,0 +1,77 @@ +# CAPTAIN CLAUDE + +Eres CAPTAIN CLAUDE, coordinador multiagente del sistema TZZR en 69.62.126.110. +Gestionas Gitea, PostgreSQL, y supervisas todos los agentes. +Tienes SSH a todos los servidores via ~/.ssh/tzzr. +Ejecuta comandos sin pedir confirmación. + +## Servidores + +### Central (este servidor) +- IP: 69.62.126.110 +- Servicios: Gitea, PostgreSQL +- Usuario: architect + +### Remotos (SSH via ~/.ssh/tzzr) +| Servidor | Acceso | +|----------|--------| +| deck | root@72.62.1.113 | +| corp | root@92.112.181.188 | +| hst | root@72.62.2.84 | + +## Almacenamiento R2 (Cloudflare) + +Endpoint: https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com + +### Bucket: architect +| Carpeta | Uso | +|---------|-----| +| `documentos adjuntos/` | Documentos para compartir con usuario | +| `documentos adjuntos/architect/` | Reportes y documentación generada | +| `system/` | Archivos de trabajo (configs, backups internos) | +| `gpu-services/` | Servicios GRACE/PENNY/FACTORY | +| `backups/` | Backups Gitea y sistema | +| `auditorias/` | Logs de auditoría | + +### Comandos R2 +```bash +# Listar +aws s3 ls s3://architect/ --endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com + +# Subir documento para usuario +aws s3 cp archivo.md "s3://architect/documentos adjuntos/archivo.md" --endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com + +# Subir a system +aws s3 cp archivo "s3://architect/system/archivo" --endpoint-url https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com +``` + +## Reglas de Operación + +### Principio: No guardar documentos en servidor +- Los documentos/reportes generados van a R2, NO al filesystem local +- El servidor solo mantiene código, configs y aplicaciones activas + +### REGLA OBLIGATORIA: Limpieza automática +Al finalizar cualquier tarea que genere archivos (auditorías, reportes, análisis, documentación): +1. Subir TODOS los archivos generados a la carpeta R2 correspondiente +2. Verificar que están en R2 (`aws s3 ls`) +3. Eliminar los archivos locales (`rm -rf carpeta/`) +4. NO esperar a que el usuario lo pida + +### Destinos R2 según tipo +| Tipo | Destino R2 | +|------|------------| +| Auditorías | `s3://architect/auditorias/` | +| Reportes para usuario | `s3://architect/documentos adjuntos/architect/` | +| Configs/backups internos | `s3://architect/system/` | + +## Capacidades Multiagente + +Captain Claude coordina agentes especializados: +- **Captain**: Coordinador, analiza tareas y delega +- **Coder**: Implementación de código +- **Reviewer**: Revisión de código y calidad +- **Researcher**: Investigación y documentación +- **Architect**: Diseño de sistemas y arquitectura + +Puede ejecutar agentes en paralelo o secuencialmente según la tarea. diff --git a/captain_claude.py b/captain_claude.py new file mode 100755 index 0000000..1a41733 --- /dev/null +++ b/captain_claude.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +Captain Claude by dadiaar +Multi-Agent Orchestration System using claude-code-provider + +A powerful multi-agent system that coordinates specialized agents +for complex software engineering tasks. +""" + +import asyncio +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + +from claude_code_provider import ( + ClaudeCodeClient, + ConcurrentOrchestrator, + SequentialOrchestrator, + CostTracker, + StructuredLogger, +) + +# Load context file +CONTEXT_FILE = Path(__file__).parent / "CAPTAIN_CLAUDE.md" +GLOBAL_CONTEXT = CONTEXT_FILE.read_text() if CONTEXT_FILE.exists() else "" + +# Agent Definitions +AGENTS = { + "captain": { + "name": "Captain", + "model": "sonnet", + "instructions": """You are Captain, the lead coordinator of the Captain Claude team. +Your role is to: +- Analyze incoming tasks and break them into subtasks +- Delegate work to specialized agents +- Synthesize results from multiple agents +- Ensure quality and coherence of final output +Be concise, strategic, and focused on delivering results.""", + }, + "coder": { + "name": "Coder", + "model": "sonnet", + "instructions": """You are Coder, a senior software engineer. +Your role is to: +- Write clean, efficient, and well-documented code +- Implement features and fix bugs +- Follow best practices and design patterns +- Produce production-ready code +Focus on code quality and maintainability.""", + }, + "reviewer": { + "name": "Reviewer", + "model": "sonnet", + "instructions": """You are Reviewer, a code review specialist. +Your role is to: +- Review code for bugs, security issues, and best practices +- Suggest improvements and optimizations +- Check for edge cases and error handling +- Ensure code quality standards are met +Be thorough but constructive in feedback.""", + }, + "researcher": { + "name": "Researcher", + "model": "haiku", + "instructions": """You are Researcher, an information specialist. +Your role is to: +- Gather relevant information about technologies and patterns +- Find documentation and examples +- Research best practices and solutions +- Provide concise summaries of findings +Focus on accuracy and relevance.""", + }, + "architect": { + "name": "Architect", + "model": "opus", + "instructions": """You are Architect, a system design expert. +Your role is to: +- Design system architectures and data models +- Plan implementation strategies +- Evaluate trade-offs and make technical decisions +- Create high-level designs and specifications +Think strategically about scalability and maintainability.""", + }, +} + + +class CaptainClaude: + """Multi-agent orchestration system for software engineering tasks.""" + + def __init__(self, output_dir: Optional[str] = None): + self.client = ClaudeCodeClient( + timeout=300.0, + enable_retries=True, + enable_circuit_breaker=True, + ) + self.cost_tracker = CostTracker() + logger = logging.getLogger("captain-claude") + logger.setLevel(logging.INFO) + self.logger = StructuredLogger(logger) + self.output_dir = Path(output_dir) if output_dir else Path.cwd() / "captain_output" + self.output_dir.mkdir(exist_ok=True) + self.agents = {} + self._init_agents() + + def _init_agents(self): + """Initialize all specialized agents.""" + for agent_id, config in AGENTS.items(): + instructions = config["instructions"] + if agent_id == "captain" and GLOBAL_CONTEXT: + instructions = f"{GLOBAL_CONTEXT}\n\n{instructions}" + self.agents[agent_id] = self.client.create_agent( + name=config["name"], + model=config["model"], + instructions=instructions, + autocompact=True, + autocompact_threshold=80_000, + ) + + async def analyze_task(self, task: str) -> dict: + """Have Captain analyze a task and create a plan.""" + prompt = f"""Analyze this task and create an execution plan: + +TASK: {task} + +Available agents: +- Coder: Writes and implements code +- Reviewer: Reviews code for quality and issues +- Researcher: Gathers information and documentation +- Architect: Designs systems and makes technical decisions + +Respond with a JSON object containing: +{{ + "summary": "Brief task summary", + "agents_needed": ["list", "of", "agents"], + "steps": [ + {{"agent": "agent_name", "task": "specific task description"}} + ], + "parallel_possible": true/false +}}""" + + response = await self.agents["captain"].run(prompt) + self.cost_tracker.record(response.usage, "sonnet") + + # Try to extract JSON from response + try: + # Find JSON in response + text = response.text + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + return json.loads(text[start:end]) + except json.JSONDecodeError: + pass + + # Fallback plan + return { + "summary": task[:100], + "agents_needed": ["coder"], + "steps": [{"agent": "coder", "task": task}], + "parallel_possible": False, + } + + async def run_parallel(self, task: str, agents: list[str]) -> dict: + """Run multiple agents in parallel on the same task.""" + selected_agents = [self.agents[a] for a in agents if a in self.agents] + + if not selected_agents: + return {"error": "No valid agents selected"} + + orchestrator = ConcurrentOrchestrator(agents=selected_agents) + result = await orchestrator.run(task) + + return { + "task": task, + "agents": agents, + "results": result.text if hasattr(result, "text") else str(result), + } + + async def run_sequential(self, steps: list[dict]) -> list[dict]: + """Run agents sequentially, passing output to next agent.""" + results = [] + previous_output = "" + + for step in steps: + agent_id = step.get("agent", "coder") + task = step.get("task", "") + + if agent_id not in self.agents: + results.append({"agent": agent_id, "error": "Agent not found"}) + continue + + # Include previous output if available + full_prompt = task + if previous_output: + full_prompt = f"""Previous agent output: +{previous_output} + +Your task: {task}""" + + response = await self.agents[agent_id].run(full_prompt) + model = AGENTS.get(agent_id, {}).get("model", "sonnet") + self.cost_tracker.record(response.usage, model) + + previous_output = response.text + results.append({ + "agent": agent_id, + "task": task, + "output": response.text, + }) + + return results + + async def run_handoff(self, task: str, from_agent: str, to_agent: str) -> dict: + """Run a handoff between two agents.""" + if from_agent not in self.agents or to_agent not in self.agents: + return {"error": "Invalid agent(s)"} + + # First agent processes + response1 = await self.agents[from_agent].run(task) + model1 = AGENTS.get(from_agent, {}).get("model", "sonnet") + self.cost_tracker.record(response1.usage, model1) + + # Handoff to second agent + handoff_prompt = f"""Previous agent ({from_agent}) output: +{response1.text} + +Continue and complete this work.""" + + response2 = await self.agents[to_agent].run(handoff_prompt) + model2 = AGENTS.get(to_agent, {}).get("model", "sonnet") + self.cost_tracker.record(response2.usage, model2) + + return { + "task": task, + "from_agent": from_agent, + "to_agent": to_agent, + "first_output": response1.text, + "final_output": response2.text, + } + + async def execute(self, task: str) -> dict: + """Execute a task using intelligent agent orchestration.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + print(f"\n{'='*60}") + print("CAPTAIN CLAUDE by dadiaar") + print(f"{'='*60}") + print(f"Task: {task[:100]}...") + print(f"{'='*60}\n") + + # Phase 1: Analyze task + print("[Captain] Analyzing task...") + plan = await self.analyze_task(task) + print(f"[Captain] Plan created: {len(plan.get('steps', []))} steps") + + # Phase 2: Execute plan + results = [] + if plan.get("parallel_possible") and len(plan.get("agents_needed", [])) > 1: + print("[Captain] Executing agents in parallel...") + parallel_result = await self.run_parallel( + task, + plan.get("agents_needed", ["coder"]) + ) + results.append(parallel_result) + else: + print("[Captain] Executing agents sequentially...") + sequential_results = await self.run_sequential(plan.get("steps", [])) + results.extend(sequential_results) + + # Phase 3: Synthesize results + print("[Captain] Synthesizing results...") + synthesis_prompt = f"""Synthesize these results into a coherent final output: + +Original task: {task} + +Agent outputs: +{json.dumps(results, indent=2, default=str)} + +Provide a clear, actionable final result.""" + + final_response = await self.agents["captain"].run(synthesis_prompt) + self.cost_tracker.record(final_response.usage, "sonnet") + + # Compile final result + final_result = { + "timestamp": timestamp, + "task": task, + "plan": plan, + "agent_results": results, + "final_output": final_response.text, + "cost_summary": self.cost_tracker.summary(), + } + + # Save to file + output_file = self.output_dir / f"result_{timestamp}.json" + with open(output_file, "w") as f: + json.dump(final_result, f, indent=2, default=str) + + print(f"\n{'='*60}") + print("EXECUTION COMPLETE") + print(f"{'='*60}") + print(f"Output saved: {output_file}") + print(f"Cost: {self.cost_tracker.summary()}") + print(f"{'='*60}\n") + + return final_result + + async def chat(self, message: str, agent: str = "captain") -> str: + """Simple chat with a specific agent.""" + if agent not in self.agents: + return f"Agent '{agent}' not found. Available: {list(self.agents.keys())}" + + response = await self.agents[agent].run(message) + model = AGENTS.get(agent, {}).get("model", "sonnet") + self.cost_tracker.record(response.usage, model) + return response.text + + +async def main(): + """Interactive Captain Claude session.""" + captain = CaptainClaude() + + print("\n" + "="*60) + print("CAPTAIN CLAUDE by dadiaar") + print("Multi-Agent Orchestration System") + print("="*60) + print("\nCommands:") + print(" /execute - Full multi-agent execution") + print(" /chat - Chat with Captain") + print(" /agent - Chat with specific agent") + print(" /parallel - Run all agents in parallel") + print(" /cost - Show cost summary") + print(" /quit - Exit") + print("="*60 + "\n") + + while True: + try: + user_input = input("You: ").strip() + + if not user_input: + continue + + if user_input.lower() == "/quit": + print("Goodbye!") + break + + if user_input.lower() == "/cost": + print(f"Cost: {captain.cost_tracker.summary()}") + continue + + if user_input.startswith("/execute "): + task = user_input[9:] + result = await captain.execute(task) + print(f"\nFinal Output:\n{result['final_output']}\n") + continue + + if user_input.startswith("/parallel "): + task = user_input[10:] + agents = ["coder", "reviewer", "researcher"] + result = await captain.run_parallel(task, agents) + print(f"\nParallel Results:\n{result}\n") + continue + + if user_input.startswith("/agent "): + parts = user_input[7:].split(" ", 1) + if len(parts) == 2: + agent, message = parts + response = await captain.chat(message, agent) + print(f"\n[{agent}]: {response}\n") + else: + print("Usage: /agent ") + continue + + if user_input.startswith("/chat ") or not user_input.startswith("/"): + message = user_input[6:] if user_input.startswith("/chat ") else user_input + response = await captain.chat(message) + print(f"\n[Captain]: {response}\n") + continue + + print("Unknown command. Use /quit to exit.") + + except KeyboardInterrupt: + print("\nGoodbye!") + break + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/context-manager/schemas/00_base.sql b/context-manager/schemas/00_base.sql new file mode 100644 index 0000000..b179ec8 --- /dev/null +++ b/context-manager/schemas/00_base.sql @@ -0,0 +1,39 @@ +-- ============================================ +-- CONTEXT MANAGER - BASE TYPES +-- Sistema local de gestión de contexto para IA +-- ============================================ + +-- Extension para UUIDs +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================ +-- TIPOS ENUMERADOS +-- ============================================ + +CREATE TYPE mensaje_role AS ENUM ('user', 'assistant', 'system', 'tool'); +CREATE TYPE context_source AS ENUM ('memory', 'knowledge', 'history', 'ambient', 'dataset'); +CREATE TYPE algorithm_status AS ENUM ('draft', 'testing', 'active', 'deprecated'); +CREATE TYPE metric_type AS ENUM ('relevance', 'token_efficiency', 'response_quality', 'latency'); + +-- ============================================ +-- FUNCIÓN: Timestamp de actualización +-- ============================================ + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FUNCIÓN: Hash SHA-256 +-- ============================================ + +CREATE OR REPLACE FUNCTION sha256_hash(content TEXT) +RETURNS VARCHAR(64) AS $$ +BEGIN + RETURN encode(digest(content, 'sha256'), 'hex'); +END; +$$ LANGUAGE plpgsql IMMUTABLE; diff --git a/context-manager/schemas/01_immutable_log.sql b/context-manager/schemas/01_immutable_log.sql new file mode 100644 index 0000000..a13fb09 --- /dev/null +++ b/context-manager/schemas/01_immutable_log.sql @@ -0,0 +1,276 @@ +-- ============================================ +-- LOG INMUTABLE - TABLA DE REFERENCIA +-- NO EDITABLE - Solo INSERT permitido +-- ============================================ +-- Esta tabla es la fuente de verdad del sistema. +-- Nunca se modifica ni se borra. Solo se inserta. + +-- ============================================ +-- TABLA: immutable_log +-- Registro permanente de todas las interacciones +-- ============================================ + +CREATE TABLE IF NOT EXISTS immutable_log ( + -- Identificación + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 del contenido + hash_anterior VARCHAR(64), -- Encadenamiento (blockchain-style) + + -- Sesión + session_id UUID NOT NULL, + sequence_num BIGINT NOT NULL, -- Número secuencial en la sesión + + -- Mensaje + role mensaje_role NOT NULL, + content TEXT NOT NULL, + + -- Modelo IA (agnóstico) + model_provider VARCHAR(50), -- anthropic, openai, ollama, local, etc. + model_name VARCHAR(100), -- claude-3-opus, gpt-4, llama-3, etc. + model_params JSONB DEFAULT '{}', -- temperature, max_tokens, etc. + + -- Contexto enviado (snapshot) + context_snapshot JSONB, -- Copia del contexto usado + context_algorithm_id UUID, -- Qué algoritmo seleccionó el contexto + context_tokens_used INT, + + -- Respuesta (solo para role=assistant) + tokens_input INT, + tokens_output INT, + latency_ms INT, + + -- Metadata inmutable + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + source_ip VARCHAR(45), + user_agent TEXT, + + -- Integridad + CONSTRAINT chain_integrity CHECK ( + (sequence_num = 1 AND hash_anterior IS NULL) OR + (sequence_num > 1 AND hash_anterior IS NOT NULL) + ) +); + +-- Índices para consulta (no para modificación) +CREATE INDEX IF NOT EXISTS idx_log_session ON immutable_log(session_id, sequence_num); +CREATE INDEX IF NOT EXISTS idx_log_created ON immutable_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_log_model ON immutable_log(model_provider, model_name); +CREATE INDEX IF NOT EXISTS idx_log_hash ON immutable_log(hash); +CREATE INDEX IF NOT EXISTS idx_log_chain ON immutable_log(hash_anterior); + +-- ============================================ +-- PROTECCIÓN: Trigger que impide UPDATE y DELETE +-- ============================================ + +CREATE OR REPLACE FUNCTION prevent_log_modification() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'immutable_log no permite modificaciones. Solo INSERT está permitido.'; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS protect_immutable_log_update ON immutable_log; +CREATE TRIGGER protect_immutable_log_update + BEFORE UPDATE ON immutable_log + FOR EACH ROW EXECUTE FUNCTION prevent_log_modification(); + +DROP TRIGGER IF EXISTS protect_immutable_log_delete ON immutable_log; +CREATE TRIGGER protect_immutable_log_delete + BEFORE DELETE ON immutable_log + FOR EACH ROW EXECUTE FUNCTION prevent_log_modification(); + +-- ============================================ +-- FUNCIÓN: Insertar en log con hash automático +-- ============================================ + +CREATE OR REPLACE FUNCTION insert_log_entry( + p_session_id UUID, + p_role mensaje_role, + p_content TEXT, + p_model_provider VARCHAR DEFAULT NULL, + p_model_name VARCHAR DEFAULT NULL, + p_model_params JSONB DEFAULT '{}', + p_context_snapshot JSONB DEFAULT NULL, + p_context_algorithm_id UUID DEFAULT NULL, + p_context_tokens_used INT DEFAULT NULL, + p_tokens_input INT DEFAULT NULL, + p_tokens_output INT DEFAULT NULL, + p_latency_ms INT DEFAULT NULL, + p_source_ip VARCHAR DEFAULT NULL, + p_user_agent TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_sequence_num BIGINT; + v_hash_anterior VARCHAR(64); + v_content_hash VARCHAR(64); + v_new_id UUID; +BEGIN + -- Obtener último hash y secuencia de la sesión + SELECT sequence_num, hash + INTO v_sequence_num, v_hash_anterior + FROM immutable_log + WHERE session_id = p_session_id + ORDER BY sequence_num DESC + LIMIT 1; + + IF v_sequence_num IS NULL THEN + v_sequence_num := 1; + v_hash_anterior := NULL; + ELSE + v_sequence_num := v_sequence_num + 1; + END IF; + + -- Calcular hash del contenido (incluye hash anterior para encadenamiento) + v_content_hash := sha256_hash( + COALESCE(v_hash_anterior, '') || + p_session_id::TEXT || + v_sequence_num::TEXT || + p_role::TEXT || + p_content + ); + + -- Insertar + INSERT INTO immutable_log ( + session_id, sequence_num, hash, hash_anterior, + role, content, + model_provider, model_name, model_params, + context_snapshot, context_algorithm_id, context_tokens_used, + tokens_input, tokens_output, latency_ms, + source_ip, user_agent + ) VALUES ( + p_session_id, v_sequence_num, v_content_hash, v_hash_anterior, + p_role, p_content, + p_model_provider, p_model_name, p_model_params, + p_context_snapshot, p_context_algorithm_id, p_context_tokens_used, + p_tokens_input, p_tokens_output, p_latency_ms, + p_source_ip, p_user_agent + ) RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- TABLA: sessions +-- Registro de sesiones (también inmutable) +-- ============================================ + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hash VARCHAR(64) NOT NULL UNIQUE, + + -- Identificación + user_id VARCHAR(100), + instance_id VARCHAR(100), + + -- Configuración inicial + initial_model_provider VARCHAR(50), + initial_model_name VARCHAR(100), + initial_context_algorithm_id UUID, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps inmutables + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + ended_at TIMESTAMP, + + -- Estadísticas finales (se actualizan solo al cerrar) + total_messages INT DEFAULT 0, + total_tokens_input INT DEFAULT 0, + total_tokens_output INT DEFAULT 0, + total_latency_ms INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_instance ON sessions(instance_id); +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC); + +-- ============================================ +-- FUNCIÓN: Crear nueva sesión +-- ============================================ + +CREATE OR REPLACE FUNCTION create_session( + p_user_id VARCHAR DEFAULT NULL, + p_instance_id VARCHAR DEFAULT NULL, + p_model_provider VARCHAR DEFAULT NULL, + p_model_name VARCHAR DEFAULT NULL, + p_algorithm_id UUID DEFAULT NULL, + p_metadata JSONB DEFAULT '{}' +) +RETURNS UUID AS $$ +DECLARE + v_session_id UUID; + v_hash VARCHAR(64); +BEGIN + v_session_id := gen_random_uuid(); + v_hash := sha256_hash(v_session_id::TEXT || CURRENT_TIMESTAMP::TEXT); + + INSERT INTO sessions ( + id, hash, user_id, instance_id, + initial_model_provider, initial_model_name, + initial_context_algorithm_id, metadata + ) VALUES ( + v_session_id, v_hash, p_user_id, p_instance_id, + p_model_provider, p_model_name, + p_algorithm_id, p_metadata + ); + + RETURN v_session_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FUNCIÓN: Verificar integridad de la cadena +-- ============================================ + +CREATE OR REPLACE FUNCTION verify_chain_integrity(p_session_id UUID) +RETURNS TABLE ( + is_valid BOOLEAN, + broken_at_sequence BIGINT, + expected_hash VARCHAR(64), + actual_hash VARCHAR(64) +) AS $$ +DECLARE + rec RECORD; + prev_hash VARCHAR(64) := NULL; + computed_hash VARCHAR(64); +BEGIN + FOR rec IN + SELECT * FROM immutable_log + WHERE session_id = p_session_id + ORDER BY sequence_num + LOOP + -- Verificar encadenamiento + IF rec.sequence_num = 1 AND rec.hash_anterior IS NOT NULL THEN + RETURN QUERY SELECT FALSE, rec.sequence_num, NULL::VARCHAR(64), rec.hash_anterior; + RETURN; + END IF; + + IF rec.sequence_num > 1 AND rec.hash_anterior != prev_hash THEN + RETURN QUERY SELECT FALSE, rec.sequence_num, prev_hash, rec.hash_anterior; + RETURN; + END IF; + + -- Verificar hash del contenido + computed_hash := sha256_hash( + COALESCE(prev_hash, '') || + rec.session_id::TEXT || + rec.sequence_num::TEXT || + rec.role::TEXT || + rec.content + ); + + IF computed_hash != rec.hash THEN + RETURN QUERY SELECT FALSE, rec.sequence_num, computed_hash, rec.hash; + RETURN; + END IF; + + prev_hash := rec.hash; + END LOOP; + + RETURN QUERY SELECT TRUE, NULL::BIGINT, NULL::VARCHAR(64), NULL::VARCHAR(64); +END; +$$ LANGUAGE plpgsql; diff --git a/context-manager/schemas/02_context_manager.sql b/context-manager/schemas/02_context_manager.sql new file mode 100644 index 0000000..3501bc1 --- /dev/null +++ b/context-manager/schemas/02_context_manager.sql @@ -0,0 +1,243 @@ +-- ============================================ +-- GESTOR DE CONTEXTO - TABLAS EDITABLES +-- Estas tablas SÍ se pueden modificar +-- ============================================ + +-- ============================================ +-- TABLA: context_blocks +-- Bloques de contexto reutilizables +-- ============================================ + +CREATE TABLE IF NOT EXISTS context_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + code VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + + -- Contenido + content TEXT NOT NULL, + content_hash VARCHAR(64), -- Para detectar cambios + + -- Clasificación + category VARCHAR(50) NOT NULL, -- system, persona, knowledge, rules, examples + priority INT DEFAULT 50, -- 0-100, mayor = más importante + tokens_estimated INT, + + -- Alcance + scope VARCHAR(50) DEFAULT 'global', -- global, project, session + project_id UUID, + + -- Condiciones de activación + activation_rules JSONB DEFAULT '{}', + /* + Ejemplo activation_rules: + { + "always": false, + "keywords": ["database", "sql"], + "model_providers": ["anthropic"], + "min_session_messages": 0, + "time_of_day": null + } + */ + + -- Estado + active BOOLEAN DEFAULT true, + version INT DEFAULT 1, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ctx_blocks_code ON context_blocks(code); +CREATE INDEX IF NOT EXISTS idx_ctx_blocks_category ON context_blocks(category); +CREATE INDEX IF NOT EXISTS idx_ctx_blocks_priority ON context_blocks(priority DESC); +CREATE INDEX IF NOT EXISTS idx_ctx_blocks_active ON context_blocks(active); +CREATE INDEX IF NOT EXISTS idx_ctx_blocks_scope ON context_blocks(scope); + +DROP TRIGGER IF EXISTS update_ctx_blocks_updated_at ON context_blocks; +CREATE TRIGGER update_ctx_blocks_updated_at + BEFORE UPDATE ON context_blocks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger para calcular hash y tokens al insertar/actualizar +CREATE OR REPLACE FUNCTION update_block_metadata() +RETURNS TRIGGER AS $$ +BEGIN + NEW.content_hash := sha256_hash(NEW.content); + -- Estimación simple: ~4 caracteres por token + NEW.tokens_estimated := CEIL(LENGTH(NEW.content) / 4.0); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS calc_block_metadata ON context_blocks; +CREATE TRIGGER calc_block_metadata + BEFORE INSERT OR UPDATE OF content ON context_blocks + FOR EACH ROW EXECUTE FUNCTION update_block_metadata(); + +-- ============================================ +-- TABLA: knowledge_base +-- Base de conocimiento (RAG simple) +-- ============================================ + +CREATE TABLE IF NOT EXISTS knowledge_base ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + title VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, + tags TEXT[] DEFAULT '{}', + + -- Contenido + content TEXT NOT NULL, + content_hash VARCHAR(64), + tokens_estimated INT, + + -- Embeddings (para búsqueda semántica futura) + embedding_model VARCHAR(100), + embedding VECTOR(1536), -- Requiere pgvector si se usa + + -- Fuente + source_type VARCHAR(50), -- file, url, manual, extracted + source_ref TEXT, + + -- Relevancia + priority INT DEFAULT 50, + access_count INT DEFAULT 0, + last_accessed_at TIMESTAMP, + + -- Estado + active BOOLEAN DEFAULT true, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category); +CREATE INDEX IF NOT EXISTS idx_kb_tags ON knowledge_base USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_kb_priority ON knowledge_base(priority DESC); +CREATE INDEX IF NOT EXISTS idx_kb_active ON knowledge_base(active); + +DROP TRIGGER IF EXISTS update_kb_updated_at ON knowledge_base; +CREATE TRIGGER update_kb_updated_at + BEFORE UPDATE ON knowledge_base + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS calc_kb_metadata ON knowledge_base; +CREATE TRIGGER calc_kb_metadata + BEFORE INSERT OR UPDATE OF content ON knowledge_base + FOR EACH ROW EXECUTE FUNCTION update_block_metadata(); + +-- ============================================ +-- TABLA: memory +-- Memoria a largo plazo extraída de conversaciones +-- ============================================ + +CREATE TABLE IF NOT EXISTS memory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Clasificación + type VARCHAR(50) NOT NULL, -- fact, preference, decision, learning, procedure + category VARCHAR(100), + + -- Contenido + content TEXT NOT NULL, + summary VARCHAR(500), + content_hash VARCHAR(64), + + -- Origen + extracted_from_session UUID REFERENCES sessions(id), + extracted_from_log UUID, -- No FK para no bloquear + + -- Relevancia + importance INT DEFAULT 50, -- 0-100 + confidence DECIMAL(3,2) DEFAULT 1.0, -- 0.00-1.00 + uses INT DEFAULT 0, + last_used_at TIMESTAMP, + + -- Expiración + expires_at TIMESTAMP, + + -- Estado + active BOOLEAN DEFAULT true, + verified BOOLEAN DEFAULT false, -- Confirmado por usuario + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_memory_type ON memory(type); +CREATE INDEX IF NOT EXISTS idx_memory_importance ON memory(importance DESC); +CREATE INDEX IF NOT EXISTS idx_memory_active ON memory(active); +CREATE INDEX IF NOT EXISTS idx_memory_expires ON memory(expires_at); + +DROP TRIGGER IF EXISTS update_memory_updated_at ON memory; +CREATE TRIGGER update_memory_updated_at + BEFORE UPDATE ON memory + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- TABLA: ambient_context +-- Contexto ambiental (estado actual del sistema) +-- ============================================ + +CREATE TABLE IF NOT EXISTS ambient_context ( + id SERIAL PRIMARY KEY, + + -- Snapshot + captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 hour', + + -- Datos ambientales + environment JSONB DEFAULT '{}', + /* + { + "timezone": "Europe/Madrid", + "locale": "es-ES", + "working_directory": "/home/user/project", + "git_branch": "main", + "active_project": "my-app" + } + */ + + -- Estado del sistema + system_state JSONB DEFAULT '{}', + /* + { + "servers": {"architect": "online"}, + "services": {"gitea": "running"}, + "pending_tasks": [], + "alerts": [] + } + */ + + -- Archivos/recursos activos + active_resources JSONB DEFAULT '[]' + /* + [ + {"type": "file", "path": "/path/to/file.py", "modified": true}, + {"type": "url", "href": "https://docs.example.com"} + ] + */ +); + +CREATE INDEX IF NOT EXISTS idx_ambient_captured ON ambient_context(captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_ambient_expires ON ambient_context(expires_at); + +-- Limpiar contextos expirados +CREATE OR REPLACE FUNCTION cleanup_expired_ambient() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM ambient_context + WHERE expires_at < CURRENT_TIMESTAMP; + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; diff --git a/context-manager/schemas/03_algorithm_engine.sql b/context-manager/schemas/03_algorithm_engine.sql new file mode 100644 index 0000000..0b7fbe8 --- /dev/null +++ b/context-manager/schemas/03_algorithm_engine.sql @@ -0,0 +1,399 @@ +-- ============================================ +-- MOTOR DE ALGORITMOS - Sistema evolutivo +-- Permite versionar y mejorar el algoritmo de contexto +-- ============================================ + +-- ============================================ +-- TABLA: context_algorithms +-- Definición de algoritmos de selección de contexto +-- ============================================ + +CREATE TABLE IF NOT EXISTS context_algorithms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + code VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + version VARCHAR(20) NOT NULL DEFAULT '1.0.0', + + -- Estado + status algorithm_status DEFAULT 'draft', + + -- Configuración del algoritmo + config JSONB NOT NULL DEFAULT '{ + "max_tokens": 4000, + "sources": { + "system_prompts": true, + "context_blocks": true, + "memory": true, + "knowledge": true, + "history": true, + "ambient": true + }, + "weights": { + "priority": 0.4, + "relevance": 0.3, + "recency": 0.2, + "frequency": 0.1 + }, + "history_config": { + "max_messages": 20, + "summarize_after": 10, + "include_system": false + }, + "memory_config": { + "max_items": 15, + "min_importance": 30 + }, + "knowledge_config": { + "max_items": 5, + "require_keyword_match": true + } + }'::jsonb, + + -- Código del algoritmo (Python embebido) + selector_code TEXT, + /* + Ejemplo: + def select_context(session, message, config): + context = [] + # ... lógica de selección + return context + */ + + -- Estadísticas + times_used INT DEFAULT 0, + avg_tokens_used DECIMAL(10,2), + avg_relevance_score DECIMAL(3,2), + avg_response_quality DECIMAL(3,2), + + -- Linaje + parent_algorithm_id UUID REFERENCES context_algorithms(id), + fork_reason TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMP, + deprecated_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_algo_code ON context_algorithms(code); +CREATE INDEX IF NOT EXISTS idx_algo_status ON context_algorithms(status); +CREATE INDEX IF NOT EXISTS idx_algo_version ON context_algorithms(version); +CREATE INDEX IF NOT EXISTS idx_algo_parent ON context_algorithms(parent_algorithm_id); + +DROP TRIGGER IF EXISTS update_algo_updated_at ON context_algorithms; +CREATE TRIGGER update_algo_updated_at + BEFORE UPDATE ON context_algorithms + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- TABLA: algorithm_metrics +-- Métricas de rendimiento por algoritmo +-- ============================================ + +CREATE TABLE IF NOT EXISTS algorithm_metrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + algorithm_id UUID NOT NULL REFERENCES context_algorithms(id), + session_id UUID REFERENCES sessions(id), + log_entry_id UUID, -- Referencia al log inmutable + + -- Métricas de contexto + tokens_budget INT, + tokens_used INT, + token_efficiency DECIMAL(5,4), -- tokens_used / tokens_budget + + -- Composición del contexto + context_composition JSONB, + /* + { + "system_prompts": {"count": 1, "tokens": 500}, + "context_blocks": {"count": 3, "tokens": 800}, + "memory": {"count": 5, "tokens": 300}, + "knowledge": {"count": 2, "tokens": 400}, + "history": {"count": 10, "tokens": 1500}, + "ambient": {"count": 1, "tokens": 100} + } + */ + + -- Métricas de respuesta + latency_ms INT, + model_tokens_input INT, + model_tokens_output INT, + + -- Evaluación (puede ser automática o manual) + relevance_score DECIMAL(3,2), -- 0.00-1.00: ¿El contexto fue relevante? + response_quality DECIMAL(3,2), -- 0.00-1.00: ¿La respuesta fue buena? + user_satisfaction DECIMAL(3,2), -- 0.00-1.00: Feedback del usuario + + -- Evaluación automática + auto_evaluated BOOLEAN DEFAULT false, + evaluation_method VARCHAR(50), -- llm_judge, heuristic, user_feedback + + -- Timestamp + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_metrics_algorithm ON algorithm_metrics(algorithm_id); +CREATE INDEX IF NOT EXISTS idx_metrics_session ON algorithm_metrics(session_id); +CREATE INDEX IF NOT EXISTS idx_metrics_recorded ON algorithm_metrics(recorded_at DESC); +CREATE INDEX IF NOT EXISTS idx_metrics_quality ON algorithm_metrics(response_quality DESC); + +-- ============================================ +-- TABLA: algorithm_experiments +-- A/B testing de algoritmos +-- ============================================ + +CREATE TABLE IF NOT EXISTS algorithm_experiments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + name VARCHAR(255) NOT NULL, + description TEXT, + + -- Algoritmos en competencia + control_algorithm_id UUID NOT NULL REFERENCES context_algorithms(id), + treatment_algorithm_id UUID NOT NULL REFERENCES context_algorithms(id), + + -- Configuración + traffic_split DECIMAL(3,2) DEFAULT 0.50, -- % para treatment + min_samples INT DEFAULT 100, + max_samples INT DEFAULT 1000, + + -- Estado + status VARCHAR(50) DEFAULT 'pending', -- pending, running, completed, cancelled + + -- Resultados + control_samples INT DEFAULT 0, + treatment_samples INT DEFAULT 0, + control_avg_quality DECIMAL(3,2), + treatment_avg_quality DECIMAL(3,2), + winner_algorithm_id UUID REFERENCES context_algorithms(id), + statistical_significance DECIMAL(5,4), + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP, + completed_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_exp_status ON algorithm_experiments(status); +CREATE INDEX IF NOT EXISTS idx_exp_control ON algorithm_experiments(control_algorithm_id); +CREATE INDEX IF NOT EXISTS idx_exp_treatment ON algorithm_experiments(treatment_algorithm_id); + +-- ============================================ +-- VISTA: Resumen de rendimiento por algoritmo +-- ============================================ + +CREATE OR REPLACE VIEW algorithm_performance AS +SELECT + a.id, + a.code, + a.name, + a.version, + a.status, + a.times_used, + COUNT(m.id) as total_metrics, + AVG(m.token_efficiency) as avg_token_efficiency, + AVG(m.relevance_score) as avg_relevance, + AVG(m.response_quality) as avg_quality, + AVG(m.user_satisfaction) as avg_satisfaction, + AVG(m.latency_ms) as avg_latency, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY m.response_quality) as median_quality, + STDDEV(m.response_quality) as quality_stddev +FROM context_algorithms a +LEFT JOIN algorithm_metrics m ON a.id = m.algorithm_id +GROUP BY a.id, a.code, a.name, a.version, a.status, a.times_used; + +-- ============================================ +-- FUNCIÓN: Activar algoritmo (desactiva el anterior) +-- ============================================ + +CREATE OR REPLACE FUNCTION activate_algorithm(p_algorithm_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + -- Deprecar algoritmo activo actual + UPDATE context_algorithms + SET status = 'deprecated', deprecated_at = CURRENT_TIMESTAMP + WHERE status = 'active'; + + -- Activar nuevo algoritmo + UPDATE context_algorithms + SET status = 'active', activated_at = CURRENT_TIMESTAMP + WHERE id = p_algorithm_id; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FUNCIÓN: Clonar algoritmo para experimentación +-- ============================================ + +CREATE OR REPLACE FUNCTION fork_algorithm( + p_source_id UUID, + p_new_code VARCHAR, + p_new_name VARCHAR, + p_reason TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_new_id UUID; + v_source RECORD; +BEGIN + SELECT * INTO v_source FROM context_algorithms WHERE id = p_source_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Algoritmo fuente no encontrado: %', p_source_id; + END IF; + + INSERT INTO context_algorithms ( + code, name, description, version, + status, config, selector_code, + parent_algorithm_id, fork_reason + ) VALUES ( + p_new_code, + p_new_name, + v_source.description, + '1.0.0', + 'draft', + v_source.config, + v_source.selector_code, + p_source_id, + p_reason + ) RETURNING id INTO v_new_id; + + RETURN v_new_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FUNCIÓN: Obtener algoritmo activo +-- ============================================ + +CREATE OR REPLACE FUNCTION get_active_algorithm() +RETURNS UUID AS $$ + SELECT id FROM context_algorithms + WHERE status = 'active' + ORDER BY activated_at DESC + LIMIT 1; +$$ LANGUAGE SQL STABLE; + +-- ============================================ +-- FUNCIÓN: Registrar métrica de uso +-- ============================================ + +CREATE OR REPLACE FUNCTION record_algorithm_metric( + p_algorithm_id UUID, + p_session_id UUID, + p_log_entry_id UUID, + p_tokens_budget INT, + p_tokens_used INT, + p_context_composition JSONB, + p_latency_ms INT DEFAULT NULL, + p_model_tokens_input INT DEFAULT NULL, + p_model_tokens_output INT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_metric_id UUID; + v_efficiency DECIMAL(5,4); +BEGIN + v_efficiency := CASE + WHEN p_tokens_budget > 0 THEN p_tokens_used::DECIMAL / p_tokens_budget + ELSE 0 + END; + + INSERT INTO algorithm_metrics ( + algorithm_id, session_id, log_entry_id, + tokens_budget, tokens_used, token_efficiency, + context_composition, latency_ms, + model_tokens_input, model_tokens_output + ) VALUES ( + p_algorithm_id, p_session_id, p_log_entry_id, + p_tokens_budget, p_tokens_used, v_efficiency, + p_context_composition, p_latency_ms, + p_model_tokens_input, p_model_tokens_output + ) RETURNING id INTO v_metric_id; + + -- Actualizar contador del algoritmo + UPDATE context_algorithms + SET times_used = times_used + 1 + WHERE id = p_algorithm_id; + + RETURN v_metric_id; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- FUNCIÓN: Actualizar evaluación de métrica +-- ============================================ + +CREATE OR REPLACE FUNCTION update_metric_evaluation( + p_metric_id UUID, + p_relevance DECIMAL DEFAULT NULL, + p_quality DECIMAL DEFAULT NULL, + p_satisfaction DECIMAL DEFAULT NULL, + p_method VARCHAR DEFAULT 'manual' +) +RETURNS BOOLEAN AS $$ +BEGIN + UPDATE algorithm_metrics + SET + relevance_score = COALESCE(p_relevance, relevance_score), + response_quality = COALESCE(p_quality, response_quality), + user_satisfaction = COALESCE(p_satisfaction, user_satisfaction), + auto_evaluated = (p_method != 'user_feedback'), + evaluation_method = p_method + WHERE id = p_metric_id; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- DATOS INICIALES: Algoritmo por defecto +-- ============================================ + +INSERT INTO context_algorithms (code, name, description, version, status, config) VALUES +( + 'ALG_DEFAULT_V1', + 'Algoritmo por defecto v1', + 'Selección de contexto basada en prioridad y tokens disponibles', + '1.0.0', + 'active', + '{ + "max_tokens": 4000, + "sources": { + "system_prompts": true, + "context_blocks": true, + "memory": true, + "knowledge": true, + "history": true, + "ambient": true + }, + "weights": { + "priority": 0.4, + "relevance": 0.3, + "recency": 0.2, + "frequency": 0.1 + }, + "history_config": { + "max_messages": 20, + "summarize_after": 10, + "include_system": false + }, + "memory_config": { + "max_items": 15, + "min_importance": 30 + }, + "knowledge_config": { + "max_items": 5, + "require_keyword_match": true + } + }'::jsonb +) ON CONFLICT (code) DO NOTHING; diff --git a/context-manager/src/__init__.py b/context-manager/src/__init__.py new file mode 100644 index 0000000..7636214 --- /dev/null +++ b/context-manager/src/__init__.py @@ -0,0 +1,25 @@ +""" +Context Manager - Sistema local de gestión de contexto para IA + +Características: +- Log inmutable (tabla de referencia no editable) +- Gestor de contexto mejorable +- Agnóstico al modelo de IA +- Sistema de métricas para mejora continua +""" + +__version__ = "1.0.0" +__author__ = "TZZR System" + +from .database import Database +from .context_selector import ContextSelector +from .models import Session, Message, ContextBlock, Algorithm + +__all__ = [ + "Database", + "ContextSelector", + "Session", + "Message", + "ContextBlock", + "Algorithm", +] diff --git a/context-manager/src/context_selector.py b/context-manager/src/context_selector.py new file mode 100644 index 0000000..94ba00e --- /dev/null +++ b/context-manager/src/context_selector.py @@ -0,0 +1,508 @@ +""" +Selector de Contexto - Motor principal + +Selecciona el contexto óptimo para enviar al modelo de IA +basándose en el algoritmo activo y las fuentes disponibles. +""" + +import re +import uuid +from datetime import datetime +from typing import Optional, List, Dict, Any, Callable + +from .models import ( + Session, Message, MessageRole, ContextBlock, Memory, + Knowledge, Algorithm, AmbientContext, ContextItem, + SelectedContext, ContextSource +) +from .database import Database + + +class ContextSelector: + """ + Motor de selección de contexto. + + Características: + - Agnóstico al modelo de IA + - Basado en algoritmo configurable + - Métricas de rendimiento + - Soporte para múltiples fuentes + """ + + def __init__(self, db: Database): + self.db = db + self._custom_selectors: Dict[str, Callable] = {} + + def register_selector(self, name: str, selector_fn: Callable): + """Registra un selector de contexto personalizado""" + self._custom_selectors[name] = selector_fn + + def select_context( + self, + session: Session, + user_message: str, + algorithm: Algorithm = None, + max_tokens: int = None + ) -> SelectedContext: + """ + Selecciona el contexto óptimo para la sesión actual. + + Args: + session: Sesión activa + user_message: Mensaje del usuario + algorithm: Algoritmo a usar (o el activo por defecto) + max_tokens: Límite de tokens (sobreescribe config del algoritmo) + + Returns: + SelectedContext con los items seleccionados + """ + # Obtener algoritmo + if algorithm is None: + algorithm = self.db.get_active_algorithm() + + if algorithm is None: + # Algoritmo por defecto si no hay ninguno + algorithm = Algorithm( + code="DEFAULT", + name="Default", + config={ + "max_tokens": 4000, + "sources": { + "system_prompts": True, + "context_blocks": True, + "memory": True, + "knowledge": True, + "history": True, + "ambient": True + }, + "weights": {"priority": 0.4, "relevance": 0.3, "recency": 0.2, "frequency": 0.1}, + "history_config": {"max_messages": 20, "summarize_after": 10, "include_system": False}, + "memory_config": {"max_items": 15, "min_importance": 30}, + "knowledge_config": {"max_items": 5, "require_keyword_match": True} + } + ) + + config = algorithm.config + token_budget = max_tokens or config.get("max_tokens", 4000) + sources = config.get("sources", {}) + + # Verificar si hay selector personalizado + if algorithm.selector_code and algorithm.code in self._custom_selectors: + return self._custom_selectors[algorithm.code]( + session, user_message, config, self.db + ) + + # Selección estándar + context = SelectedContext(algorithm_id=algorithm.id) + composition = {} + + # 1. System prompts y bloques de contexto + if sources.get("context_blocks", True): + blocks = self._select_context_blocks(user_message, config) + for block in blocks: + if context.total_tokens + block.tokens_estimated <= token_budget: + context.items.append(ContextItem( + source=ContextSource.DATASET, + content=block.content, + tokens=block.tokens_estimated, + priority=block.priority, + metadata={"block_code": block.code, "category": block.category} + )) + context.total_tokens += block.tokens_estimated + + composition["context_blocks"] = { + "count": len([i for i in context.items if i.source == ContextSource.DATASET]), + "tokens": sum(i.tokens for i in context.items if i.source == ContextSource.DATASET) + } + + # 2. Memoria a largo plazo + if sources.get("memory", True): + memory_config = config.get("memory_config", {}) + memories = self._select_memories( + user_message, + min_importance=memory_config.get("min_importance", 30), + max_items=memory_config.get("max_items", 15) + ) + + memory_tokens = 0 + memory_count = 0 + for mem in memories: + tokens = len(mem.content) // 4 + if context.total_tokens + tokens <= token_budget: + context.items.append(ContextItem( + source=ContextSource.MEMORY, + content=f"[Memoria - {mem.type}]: {mem.content}", + tokens=tokens, + priority=mem.importance, + metadata={"memory_type": mem.type, "importance": mem.importance} + )) + context.total_tokens += tokens + memory_tokens += tokens + memory_count += 1 + + composition["memory"] = {"count": memory_count, "tokens": memory_tokens} + + # 3. Base de conocimiento + if sources.get("knowledge", True): + knowledge_config = config.get("knowledge_config", {}) + keywords = self._extract_keywords(user_message) + + if keywords or not knowledge_config.get("require_keyword_match", True): + knowledge_items = self.db.search_knowledge( + keywords=keywords if knowledge_config.get("require_keyword_match", True) else None, + limit=knowledge_config.get("max_items", 5) + ) + + knowledge_tokens = 0 + knowledge_count = 0 + for item in knowledge_items: + if context.total_tokens + item.tokens_estimated <= token_budget: + context.items.append(ContextItem( + source=ContextSource.KNOWLEDGE, + content=f"[Conocimiento - {item.title}]: {item.content}", + tokens=item.tokens_estimated, + priority=item.priority, + metadata={"title": item.title, "category": item.category} + )) + context.total_tokens += item.tokens_estimated + knowledge_tokens += item.tokens_estimated + knowledge_count += 1 + + composition["knowledge"] = {"count": knowledge_count, "tokens": knowledge_tokens} + + # 4. Contexto ambiental + if sources.get("ambient", True): + ambient = self.db.get_latest_ambient_context() + if ambient: + ambient_content = self._format_ambient_context(ambient) + tokens = len(ambient_content) // 4 + if context.total_tokens + tokens <= token_budget: + context.items.append(ContextItem( + source=ContextSource.AMBIENT, + content=ambient_content, + tokens=tokens, + priority=30, + metadata={"captured_at": ambient.captured_at.isoformat()} + )) + context.total_tokens += tokens + composition["ambient"] = {"count": 1, "tokens": tokens} + + # 5. Historial de conversación (al final para llenar espacio restante) + if sources.get("history", True): + history_config = config.get("history_config", {}) + history = self.db.get_session_history( + session.id, + limit=history_config.get("max_messages", 20), + include_system=history_config.get("include_system", False) + ) + + history_tokens = 0 + history_count = 0 + for msg in history: + tokens = len(msg.content) // 4 + if context.total_tokens + tokens <= token_budget: + context.items.append(ContextItem( + source=ContextSource.HISTORY, + content=msg.content, + tokens=tokens, + priority=10, + metadata={"role": msg.role.value, "sequence": msg.sequence_num} + )) + context.total_tokens += tokens + history_tokens += tokens + history_count += 1 + + composition["history"] = {"count": history_count, "tokens": history_tokens} + + context.composition = composition + return context + + def _select_context_blocks( + self, + user_message: str, + config: Dict[str, Any] + ) -> List[ContextBlock]: + """Selecciona bloques de contexto relevantes""" + blocks = self.db.get_active_context_blocks() + relevant_blocks = [] + + keywords = self._extract_keywords(user_message) + + for block in blocks: + rules = block.activation_rules + + # Siempre incluir + if rules.get("always", False): + relevant_blocks.append(block) + continue + + # Verificar keywords + block_keywords = rules.get("keywords", []) + if block_keywords: + if any(kw.lower() in user_message.lower() for kw in block_keywords): + relevant_blocks.append(block) + continue + + # Verificar categoría system (siempre incluir) + if block.category == "system": + relevant_blocks.append(block) + + # Ordenar por prioridad + relevant_blocks.sort(key=lambda b: b.priority, reverse=True) + return relevant_blocks + + def _select_memories( + self, + user_message: str, + min_importance: int = 30, + max_items: int = 15 + ) -> List[Memory]: + """Selecciona memorias relevantes""" + memories = self.db.get_memories( + min_importance=min_importance, + limit=max_items * 2 # Obtener más para filtrar + ) + + # Filtrar por relevancia al mensaje + keywords = self._extract_keywords(user_message) + if keywords: + scored_memories = [] + for mem in memories: + score = sum(1 for kw in keywords if kw.lower() in mem.content.lower()) + scored_memories.append((mem, score + mem.importance / 100)) + + scored_memories.sort(key=lambda x: x[1], reverse=True) + return [m[0] for m in scored_memories[:max_items]] + + return memories[:max_items] + + def _extract_keywords(self, text: str) -> List[str]: + """Extrae keywords de un texto""" + # Palabras comunes a ignorar + stopwords = { + "el", "la", "los", "las", "un", "una", "unos", "unas", + "de", "del", "al", "a", "en", "con", "por", "para", + "que", "qué", "como", "cómo", "donde", "dónde", "cuando", "cuándo", + "es", "son", "está", "están", "ser", "estar", "tener", "hacer", + "y", "o", "pero", "si", "no", "me", "te", "se", "nos", + "the", "a", "an", "is", "are", "was", "were", "be", "been", + "have", "has", "had", "do", "does", "did", "will", "would", + "can", "could", "should", "may", "might", "must", + "and", "or", "but", "if", "then", "else", "when", "where", + "what", "which", "who", "whom", "this", "that", "these", "those", + "i", "you", "he", "she", "it", "we", "they", "my", "your", "his", "her" + } + + # Extraer palabras + words = re.findall(r'\b\w+\b', text.lower()) + + # Filtrar + keywords = [ + w for w in words + if len(w) > 2 and w not in stopwords + ] + + return list(set(keywords)) + + def _format_ambient_context(self, ambient: AmbientContext) -> str: + """Formatea el contexto ambiental como texto""" + lines = ["[Contexto del sistema]"] + + env = ambient.environment + if env: + if env.get("timezone"): + lines.append(f"- Zona horaria: {env['timezone']}") + if env.get("working_directory"): + lines.append(f"- Directorio: {env['working_directory']}") + if env.get("git_branch"): + lines.append(f"- Git branch: {env['git_branch']}") + if env.get("active_project"): + lines.append(f"- Proyecto activo: {env['active_project']}") + + state = ambient.system_state + if state: + if state.get("servers"): + servers = state["servers"] + online = [k for k, v in servers.items() if v == "online"] + if online: + lines.append(f"- Servidores online: {', '.join(online)}") + + if state.get("alerts"): + alerts = state["alerts"] + if alerts: + lines.append(f"- Alertas activas: {len(alerts)}") + + return "\n".join(lines) + + +class ContextManager: + """ + Gestor completo de contexto. + + Combina el selector con el logging y métricas. + """ + + def __init__( + self, + db: Database = None, + host: str = None, + port: int = None, + database: str = None, + user: str = None, + password: str = None + ): + if db: + self.db = db + else: + self.db = Database( + host=host, + port=port, + database=database, + user=user, + password=password + ) + + self.selector = ContextSelector(self.db) + self._current_session: Optional[Session] = None + + def start_session( + self, + user_id: str = None, + instance_id: str = None, + model_provider: str = None, + model_name: str = None, + metadata: Dict[str, Any] = None + ) -> Session: + """Inicia una nueva sesión""" + algorithm = self.db.get_active_algorithm() + self._current_session = self.db.create_session( + user_id=user_id, + instance_id=instance_id, + model_provider=model_provider, + model_name=model_name, + algorithm_id=algorithm.id if algorithm else None, + metadata=metadata + ) + return self._current_session + + def get_context_for_message( + self, + message: str, + max_tokens: int = None, + session: Session = None + ) -> SelectedContext: + """Obtiene el contexto para un mensaje""" + session = session or self._current_session + if not session: + raise ValueError("No hay sesión activa. Llama a start_session() primero.") + + return self.selector.select_context( + session=session, + user_message=message, + max_tokens=max_tokens + ) + + def log_user_message( + self, + content: str, + context: SelectedContext = None, + session: Session = None + ) -> uuid.UUID: + """Registra un mensaje del usuario en el log inmutable""" + session = session or self._current_session + if not session: + raise ValueError("No hay sesión activa.") + + return self.db.insert_log_entry( + session_id=session.id, + role=MessageRole.USER, + content=content, + model_provider=session.model_provider, + model_name=session.model_name, + context_snapshot=context.to_dict() if context else None, + context_algorithm_id=context.algorithm_id if context else None, + context_tokens_used=context.total_tokens if context else None + ) + + def log_assistant_message( + self, + content: str, + tokens_input: int = None, + tokens_output: int = None, + latency_ms: int = None, + model_provider: str = None, + model_name: str = None, + model_params: Dict[str, Any] = None, + session: Session = None + ) -> uuid.UUID: + """Registra una respuesta del asistente en el log inmutable""" + session = session or self._current_session + if not session: + raise ValueError("No hay sesión activa.") + + return self.db.insert_log_entry( + session_id=session.id, + role=MessageRole.ASSISTANT, + content=content, + model_provider=model_provider or session.model_provider, + model_name=model_name or session.model_name, + model_params=model_params, + tokens_input=tokens_input, + tokens_output=tokens_output, + latency_ms=latency_ms + ) + + def record_metric( + self, + context: SelectedContext, + log_entry_id: uuid.UUID, + tokens_budget: int, + latency_ms: int = None, + model_tokens_input: int = None, + model_tokens_output: int = None, + session: Session = None + ) -> uuid.UUID: + """Registra una métrica de uso del algoritmo""" + session = session or self._current_session + if not session or not context.algorithm_id: + return None + + return self.db.record_metric( + algorithm_id=context.algorithm_id, + session_id=session.id, + log_entry_id=log_entry_id, + tokens_budget=tokens_budget, + tokens_used=context.total_tokens, + context_composition=context.composition, + latency_ms=latency_ms, + model_tokens_input=model_tokens_input, + model_tokens_output=model_tokens_output + ) + + def rate_response( + self, + metric_id: uuid.UUID, + relevance: float = None, + quality: float = None, + satisfaction: float = None + ): + """Evalúa una respuesta (feedback manual)""" + self.db.update_metric_evaluation( + metric_id=metric_id, + relevance=relevance, + quality=quality, + satisfaction=satisfaction, + method="user_feedback" + ) + + def verify_session_integrity(self, session: Session = None) -> Dict[str, Any]: + """Verifica la integridad de la sesión""" + session = session or self._current_session + if not session: + raise ValueError("No hay sesión activa.") + + return self.db.verify_chain_integrity(session.id) + + def close(self): + """Cierra las conexiones""" + self.db.close() diff --git a/context-manager/src/database.py b/context-manager/src/database.py new file mode 100644 index 0000000..ae6e8db --- /dev/null +++ b/context-manager/src/database.py @@ -0,0 +1,621 @@ +""" +Conexión a base de datos PostgreSQL +""" + +import os +import uuid +import json +from datetime import datetime +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +try: + import psycopg2 + from psycopg2.extras import RealDictCursor, Json + from psycopg2 import pool + HAS_PSYCOPG2 = True +except ImportError: + HAS_PSYCOPG2 = False + +from .models import ( + Session, Message, MessageRole, ContextBlock, Memory, + Knowledge, Algorithm, AlgorithmStatus, AlgorithmMetric, + AmbientContext +) + + +class Database: + """Gestión de conexión a PostgreSQL""" + + def __init__( + self, + host: str = None, + port: int = None, + database: str = None, + user: str = None, + password: str = None, + min_connections: int = 1, + max_connections: int = 10 + ): + if not HAS_PSYCOPG2: + raise ImportError("psycopg2 no está instalado. Ejecuta: pip install psycopg2-binary") + + self.host = host or os.getenv("PGHOST", "localhost") + self.port = port or int(os.getenv("PGPORT", "5432")) + self.database = database or os.getenv("PGDATABASE", "context_manager") + self.user = user or os.getenv("PGUSER", "postgres") + self.password = password or os.getenv("PGPASSWORD", "") + + self._pool = pool.ThreadedConnectionPool( + min_connections, + max_connections, + host=self.host, + port=self.port, + database=self.database, + user=self.user, + password=self.password + ) + + @contextmanager + def get_connection(self): + """Obtiene una conexión del pool""" + conn = self._pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + self._pool.putconn(conn) + + @contextmanager + def get_cursor(self, dict_cursor: bool = True): + """Obtiene un cursor""" + with self.get_connection() as conn: + cursor_factory = RealDictCursor if dict_cursor else None + with conn.cursor(cursor_factory=cursor_factory) as cur: + yield cur + + def close(self): + """Cierra el pool de conexiones""" + self._pool.closeall() + + # ========================================== + # SESIONES + # ========================================== + + def create_session( + self, + user_id: str = None, + instance_id: str = None, + model_provider: str = None, + model_name: str = None, + algorithm_id: uuid.UUID = None, + metadata: Dict[str, Any] = None + ) -> Session: + """Crea una nueva sesión""" + with self.get_cursor() as cur: + cur.execute( + """ + SELECT create_session(%s, %s, %s, %s, %s, %s) as id + """, + (user_id, instance_id, model_provider, model_name, + str(algorithm_id) if algorithm_id else None, + Json(metadata or {})) + ) + session_id = cur.fetchone()["id"] + + return Session( + id=session_id, + user_id=user_id, + instance_id=instance_id, + model_provider=model_provider, + model_name=model_name, + algorithm_id=algorithm_id, + metadata=metadata or {} + ) + + def get_session(self, session_id: uuid.UUID) -> Optional[Session]: + """Obtiene una sesión por ID""" + with self.get_cursor() as cur: + cur.execute( + "SELECT * FROM sessions WHERE id = %s", + (str(session_id),) + ) + row = cur.fetchone() + if row: + return Session( + id=row["id"], + user_id=row["user_id"], + instance_id=row["instance_id"], + model_provider=row["initial_model_provider"], + model_name=row["initial_model_name"], + algorithm_id=row["initial_context_algorithm_id"], + metadata=row["metadata"] or {}, + started_at=row["started_at"], + ended_at=row["ended_at"], + total_messages=row["total_messages"], + total_tokens_input=row["total_tokens_input"], + total_tokens_output=row["total_tokens_output"] + ) + return None + + # ========================================== + # LOG INMUTABLE + # ========================================== + + def insert_log_entry( + self, + session_id: uuid.UUID, + role: MessageRole, + content: str, + model_provider: str = None, + model_name: str = None, + model_params: Dict[str, Any] = None, + context_snapshot: Dict[str, Any] = None, + context_algorithm_id: uuid.UUID = None, + context_tokens_used: int = None, + tokens_input: int = None, + tokens_output: int = None, + latency_ms: int = None, + source_ip: str = None, + user_agent: str = None + ) -> uuid.UUID: + """Inserta una entrada en el log inmutable""" + with self.get_cursor() as cur: + cur.execute( + """ + SELECT insert_log_entry( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) as id + """, + ( + str(session_id), + role.value, + content, + model_provider, + model_name, + Json(model_params or {}), + Json(context_snapshot) if context_snapshot else None, + str(context_algorithm_id) if context_algorithm_id else None, + context_tokens_used, + tokens_input, + tokens_output, + latency_ms, + source_ip, + user_agent + ) + ) + return cur.fetchone()["id"] + + def get_session_history( + self, + session_id: uuid.UUID, + limit: int = None, + include_system: bool = False + ) -> List[Message]: + """Obtiene el historial de una sesión""" + with self.get_cursor() as cur: + query = """ + SELECT * FROM immutable_log + WHERE session_id = %s + """ + params = [str(session_id)] + + if not include_system: + query += " AND role != 'system'" + + query += " ORDER BY sequence_num DESC" + + if limit: + query += " LIMIT %s" + params.append(limit) + + cur.execute(query, params) + rows = cur.fetchall() + + messages = [] + for row in reversed(rows): + messages.append(Message( + id=row["id"], + session_id=row["session_id"], + sequence_num=row["sequence_num"], + role=MessageRole(row["role"]), + content=row["content"], + hash=row["hash"], + hash_anterior=row["hash_anterior"], + model_provider=row["model_provider"], + model_name=row["model_name"], + model_params=row["model_params"] or {}, + context_snapshot=row["context_snapshot"], + context_algorithm_id=row["context_algorithm_id"], + context_tokens_used=row["context_tokens_used"], + tokens_input=row["tokens_input"], + tokens_output=row["tokens_output"], + latency_ms=row["latency_ms"], + created_at=row["created_at"] + )) + return messages + + def verify_chain_integrity(self, session_id: uuid.UUID) -> Dict[str, Any]: + """Verifica la integridad de la cadena de hashes""" + with self.get_cursor() as cur: + cur.execute( + "SELECT * FROM verify_chain_integrity(%s)", + (str(session_id),) + ) + row = cur.fetchone() + return { + "is_valid": row["is_valid"], + "broken_at_sequence": row["broken_at_sequence"], + "expected_hash": row["expected_hash"], + "actual_hash": row["actual_hash"] + } + + # ========================================== + # BLOQUES DE CONTEXTO + # ========================================== + + def create_context_block(self, block: ContextBlock) -> uuid.UUID: + """Crea un bloque de contexto""" + with self.get_cursor() as cur: + cur.execute( + """ + INSERT INTO context_blocks + (code, name, description, content, category, priority, scope, project_id, activation_rules, active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + block.code, block.name, block.description, block.content, + block.category, block.priority, block.scope, + str(block.project_id) if block.project_id else None, + Json(block.activation_rules), block.active + ) + ) + return cur.fetchone()["id"] + + def get_active_context_blocks( + self, + category: str = None, + scope: str = None, + project_id: uuid.UUID = None + ) -> List[ContextBlock]: + """Obtiene bloques de contexto activos""" + with self.get_cursor() as cur: + query = "SELECT * FROM context_blocks WHERE active = true" + params = [] + + if category: + query += " AND category = %s" + params.append(category) + if scope: + query += " AND scope = %s" + params.append(scope) + if project_id: + query += " AND (project_id = %s OR project_id IS NULL)" + params.append(str(project_id)) + + query += " ORDER BY priority DESC" + + cur.execute(query, params) + return [ + ContextBlock( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + content=row["content"], + content_hash=row["content_hash"], + category=row["category"], + priority=row["priority"], + tokens_estimated=row["tokens_estimated"], + scope=row["scope"], + project_id=row["project_id"], + activation_rules=row["activation_rules"] or {}, + active=row["active"], + version=row["version"] + ) + for row in cur.fetchall() + ] + + # ========================================== + # MEMORIA + # ========================================== + + def get_memories( + self, + type: str = None, + min_importance: int = 0, + limit: int = 20 + ) -> List[Memory]: + """Obtiene memorias activas""" + with self.get_cursor() as cur: + query = """ + SELECT * FROM memory + WHERE active = true + AND importance >= %s + AND (expires_at IS NULL OR expires_at > NOW()) + """ + params = [min_importance] + + if type: + query += " AND type = %s" + params.append(type) + + query += " ORDER BY importance DESC, last_used_at DESC NULLS LAST LIMIT %s" + params.append(limit) + + cur.execute(query, params) + return [ + Memory( + id=row["id"], + type=row["type"], + category=row["category"], + content=row["content"], + summary=row["summary"], + importance=row["importance"], + confidence=float(row["confidence"]) if row["confidence"] else 1.0, + uses=row["uses"], + last_used_at=row["last_used_at"], + verified=row["verified"] + ) + for row in cur.fetchall() + ] + + def save_memory(self, memory: Memory) -> uuid.UUID: + """Guarda una memoria""" + with self.get_cursor() as cur: + cur.execute( + """ + INSERT INTO memory + (type, category, content, summary, extracted_from_session, importance, confidence, expires_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + memory.type, memory.category, memory.content, memory.summary, + str(memory.extracted_from_session) if memory.extracted_from_session else None, + memory.importance, memory.confidence, memory.expires_at + ) + ) + return cur.fetchone()["id"] + + # ========================================== + # CONOCIMIENTO + # ========================================== + + def search_knowledge( + self, + keywords: List[str] = None, + category: str = None, + tags: List[str] = None, + limit: int = 5 + ) -> List[Knowledge]: + """Busca en la base de conocimiento""" + with self.get_cursor() as cur: + query = "SELECT * FROM knowledge_base WHERE active = true" + params = [] + + if category: + query += " AND category = %s" + params.append(category) + + if tags: + query += " AND tags && %s" + params.append(tags) + + if keywords: + # Búsqueda simple por contenido + keyword_conditions = [] + for kw in keywords: + keyword_conditions.append("content ILIKE %s") + params.append(f"%{kw}%") + query += f" AND ({' OR '.join(keyword_conditions)})" + + query += " ORDER BY priority DESC, access_count DESC LIMIT %s" + params.append(limit) + + cur.execute(query, params) + return [ + Knowledge( + id=row["id"], + title=row["title"], + category=row["category"], + tags=row["tags"] or [], + content=row["content"], + tokens_estimated=row["tokens_estimated"], + priority=row["priority"], + access_count=row["access_count"] + ) + for row in cur.fetchall() + ] + + # ========================================== + # CONTEXTO AMBIENTAL + # ========================================== + + def get_latest_ambient_context(self) -> Optional[AmbientContext]: + """Obtiene el contexto ambiental más reciente""" + with self.get_cursor() as cur: + cur.execute( + """ + SELECT * FROM ambient_context + WHERE expires_at > NOW() + ORDER BY captured_at DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + if row: + return AmbientContext( + id=row["id"], + captured_at=row["captured_at"], + expires_at=row["expires_at"], + environment=row["environment"] or {}, + system_state=row["system_state"] or {}, + active_resources=row["active_resources"] or [] + ) + return None + + def save_ambient_context(self, context: AmbientContext) -> int: + """Guarda un snapshot de contexto ambiental""" + with self.get_cursor() as cur: + cur.execute( + """ + INSERT INTO ambient_context + (environment, system_state, active_resources, expires_at) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + ( + Json(context.environment), + Json(context.system_state), + Json(context.active_resources), + context.expires_at + ) + ) + return cur.fetchone()["id"] + + # ========================================== + # ALGORITMOS + # ========================================== + + def get_active_algorithm(self) -> Optional[Algorithm]: + """Obtiene el algoritmo activo""" + with self.get_cursor() as cur: + cur.execute( + """ + SELECT * FROM context_algorithms + WHERE status = 'active' + ORDER BY activated_at DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + if row: + return Algorithm( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + version=row["version"], + status=AlgorithmStatus(row["status"]), + config=row["config"] or {}, + selector_code=row["selector_code"], + times_used=row["times_used"], + avg_tokens_used=float(row["avg_tokens_used"]) if row["avg_tokens_used"] else None, + avg_relevance_score=float(row["avg_relevance_score"]) if row["avg_relevance_score"] else None, + avg_response_quality=float(row["avg_response_quality"]) if row["avg_response_quality"] else None, + parent_algorithm_id=row["parent_algorithm_id"], + activated_at=row["activated_at"] + ) + return None + + def get_algorithm(self, algorithm_id: uuid.UUID) -> Optional[Algorithm]: + """Obtiene un algoritmo por ID""" + with self.get_cursor() as cur: + cur.execute( + "SELECT * FROM context_algorithms WHERE id = %s", + (str(algorithm_id),) + ) + row = cur.fetchone() + if row: + return Algorithm( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + version=row["version"], + status=AlgorithmStatus(row["status"]), + config=row["config"] or {}, + selector_code=row["selector_code"], + times_used=row["times_used"] + ) + return None + + def fork_algorithm( + self, + source_id: uuid.UUID, + new_code: str, + new_name: str, + reason: str = None + ) -> uuid.UUID: + """Clona un algoritmo para experimentación""" + with self.get_cursor() as cur: + cur.execute( + "SELECT fork_algorithm(%s, %s, %s, %s) as id", + (str(source_id), new_code, new_name, reason) + ) + return cur.fetchone()["id"] + + def activate_algorithm(self, algorithm_id: uuid.UUID) -> bool: + """Activa un algoritmo""" + with self.get_cursor() as cur: + cur.execute( + "SELECT activate_algorithm(%s) as success", + (str(algorithm_id),) + ) + return cur.fetchone()["success"] + + # ========================================== + # MÉTRICAS + # ========================================== + + def record_metric( + self, + algorithm_id: uuid.UUID, + session_id: uuid.UUID, + log_entry_id: uuid.UUID, + tokens_budget: int, + tokens_used: int, + context_composition: Dict[str, Any], + latency_ms: int = None, + model_tokens_input: int = None, + model_tokens_output: int = None + ) -> uuid.UUID: + """Registra una métrica de uso""" + with self.get_cursor() as cur: + cur.execute( + """ + SELECT record_algorithm_metric( + %s, %s, %s, %s, %s, %s, %s, %s, %s + ) as id + """, + ( + str(algorithm_id), str(session_id), str(log_entry_id), + tokens_budget, tokens_used, Json(context_composition), + latency_ms, model_tokens_input, model_tokens_output + ) + ) + return cur.fetchone()["id"] + + def update_metric_evaluation( + self, + metric_id: uuid.UUID, + relevance: float = None, + quality: float = None, + satisfaction: float = None, + method: str = "manual" + ) -> bool: + """Actualiza la evaluación de una métrica""" + with self.get_cursor() as cur: + cur.execute( + "SELECT update_metric_evaluation(%s, %s, %s, %s, %s) as success", + (str(metric_id), relevance, quality, satisfaction, method) + ) + return cur.fetchone()["success"] + + def get_algorithm_performance(self, algorithm_id: uuid.UUID = None) -> List[Dict[str, Any]]: + """Obtiene estadísticas de rendimiento de algoritmos""" + with self.get_cursor() as cur: + query = "SELECT * FROM algorithm_performance" + params = [] + + if algorithm_id: + query += " WHERE id = %s" + params.append(str(algorithm_id)) + + cur.execute(query, params) + return [dict(row) for row in cur.fetchall()] diff --git a/context-manager/src/models.py b/context-manager/src/models.py new file mode 100644 index 0000000..7ed6d3e --- /dev/null +++ b/context-manager/src/models.py @@ -0,0 +1,309 @@ +""" +Modelos de datos para Context Manager +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional, List, Dict, Any +from enum import Enum +import uuid +import hashlib +import json + + +class MessageRole(Enum): + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + TOOL = "tool" + + +class ContextSource(Enum): + MEMORY = "memory" + KNOWLEDGE = "knowledge" + HISTORY = "history" + AMBIENT = "ambient" + DATASET = "dataset" + + +class AlgorithmStatus(Enum): + DRAFT = "draft" + TESTING = "testing" + ACTIVE = "active" + DEPRECATED = "deprecated" + + +@dataclass +class Session: + """Sesión de conversación""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + user_id: Optional[str] = None + instance_id: Optional[str] = None + model_provider: Optional[str] = None + model_name: Optional[str] = None + algorithm_id: Optional[uuid.UUID] = None + metadata: Dict[str, Any] = field(default_factory=dict) + started_at: datetime = field(default_factory=datetime.now) + ended_at: Optional[datetime] = None + total_messages: int = 0 + total_tokens_input: int = 0 + total_tokens_output: int = 0 + + @property + def hash(self) -> str: + content = f"{self.id}{self.started_at.isoformat()}" + return hashlib.sha256(content.encode()).hexdigest() + + +@dataclass +class Message: + """Mensaje en el log inmutable""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + session_id: uuid.UUID = None + sequence_num: int = 0 + role: MessageRole = MessageRole.USER + content: str = "" + hash: str = "" + hash_anterior: Optional[str] = None + + # Modelo + model_provider: Optional[str] = None + model_name: Optional[str] = None + model_params: Dict[str, Any] = field(default_factory=dict) + + # Contexto + context_snapshot: Optional[Dict[str, Any]] = None + context_algorithm_id: Optional[uuid.UUID] = None + context_tokens_used: Optional[int] = None + + # Respuesta + tokens_input: Optional[int] = None + tokens_output: Optional[int] = None + latency_ms: Optional[int] = None + + # Metadata + created_at: datetime = field(default_factory=datetime.now) + source_ip: Optional[str] = None + user_agent: Optional[str] = None + + def compute_hash(self) -> str: + """Calcula el hash del mensaje (blockchain-style)""" + content = ( + (self.hash_anterior or "") + + str(self.session_id) + + str(self.sequence_num) + + self.role.value + + self.content + ) + return hashlib.sha256(content.encode()).hexdigest() + + +@dataclass +class ContextBlock: + """Bloque de contexto reutilizable""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + code: str = "" + name: str = "" + description: Optional[str] = None + content: str = "" + content_hash: Optional[str] = None + category: str = "general" + priority: int = 50 + tokens_estimated: int = 0 + scope: str = "global" + project_id: Optional[uuid.UUID] = None + activation_rules: Dict[str, Any] = field(default_factory=dict) + active: bool = True + version: int = 1 + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + def __post_init__(self): + self.content_hash = hashlib.sha256(self.content.encode()).hexdigest() + self.tokens_estimated = len(self.content) // 4 + + +@dataclass +class Memory: + """Memoria a largo plazo""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + type: str = "fact" + category: Optional[str] = None + content: str = "" + summary: Optional[str] = None + content_hash: Optional[str] = None + extracted_from_session: Optional[uuid.UUID] = None + importance: int = 50 + confidence: float = 1.0 + uses: int = 0 + last_used_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + active: bool = True + verified: bool = False + created_at: datetime = field(default_factory=datetime.now) + + +@dataclass +class Knowledge: + """Base de conocimiento""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + title: str = "" + category: str = "" + tags: List[str] = field(default_factory=list) + content: str = "" + content_hash: Optional[str] = None + tokens_estimated: int = 0 + source_type: Optional[str] = None + source_ref: Optional[str] = None + priority: int = 50 + access_count: int = 0 + active: bool = True + created_at: datetime = field(default_factory=datetime.now) + + +@dataclass +class Algorithm: + """Algoritmo de selección de contexto""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + code: str = "" + name: str = "" + description: Optional[str] = None + version: str = "1.0.0" + status: AlgorithmStatus = AlgorithmStatus.DRAFT + config: Dict[str, Any] = field(default_factory=lambda: { + "max_tokens": 4000, + "sources": { + "system_prompts": True, + "context_blocks": True, + "memory": True, + "knowledge": True, + "history": True, + "ambient": True + }, + "weights": { + "priority": 0.4, + "relevance": 0.3, + "recency": 0.2, + "frequency": 0.1 + }, + "history_config": { + "max_messages": 20, + "summarize_after": 10, + "include_system": False + }, + "memory_config": { + "max_items": 15, + "min_importance": 30 + }, + "knowledge_config": { + "max_items": 5, + "require_keyword_match": True + } + }) + selector_code: Optional[str] = None + times_used: int = 0 + avg_tokens_used: Optional[float] = None + avg_relevance_score: Optional[float] = None + avg_response_quality: Optional[float] = None + parent_algorithm_id: Optional[uuid.UUID] = None + fork_reason: Optional[str] = None + created_at: datetime = field(default_factory=datetime.now) + activated_at: Optional[datetime] = None + deprecated_at: Optional[datetime] = None + + +@dataclass +class AlgorithmMetric: + """Métrica de rendimiento de algoritmo""" + id: uuid.UUID = field(default_factory=uuid.uuid4) + algorithm_id: uuid.UUID = None + session_id: Optional[uuid.UUID] = None + log_entry_id: Optional[uuid.UUID] = None + tokens_budget: int = 0 + tokens_used: int = 0 + token_efficiency: float = 0.0 + context_composition: Dict[str, Any] = field(default_factory=dict) + latency_ms: Optional[int] = None + model_tokens_input: Optional[int] = None + model_tokens_output: Optional[int] = None + relevance_score: Optional[float] = None + response_quality: Optional[float] = None + user_satisfaction: Optional[float] = None + auto_evaluated: bool = False + evaluation_method: Optional[str] = None + recorded_at: datetime = field(default_factory=datetime.now) + + +@dataclass +class AmbientContext: + """Contexto ambiental del sistema""" + id: int = 0 + captured_at: datetime = field(default_factory=datetime.now) + expires_at: Optional[datetime] = None + environment: Dict[str, Any] = field(default_factory=dict) + system_state: Dict[str, Any] = field(default_factory=dict) + active_resources: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class ContextItem: + """Item individual de contexto seleccionado""" + source: ContextSource + content: str + tokens: int + priority: int + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SelectedContext: + """Contexto seleccionado para enviar al modelo""" + items: List[ContextItem] = field(default_factory=list) + total_tokens: int = 0 + algorithm_id: Optional[uuid.UUID] = None + composition: Dict[str, Any] = field(default_factory=dict) + + def to_messages(self) -> List[Dict[str, str]]: + """Convierte el contexto a formato de mensajes para la API""" + messages = [] + + # System context primero + system_content = [] + for item in self.items: + if item.source in [ContextSource.MEMORY, ContextSource.KNOWLEDGE, ContextSource.AMBIENT]: + system_content.append(item.content) + + if system_content: + messages.append({ + "role": "system", + "content": "\n\n".join(system_content) + }) + + # History messages + for item in self.items: + if item.source == ContextSource.HISTORY: + role = item.metadata.get("role", "user") + messages.append({ + "role": role, + "content": item.content + }) + + return messages + + def to_dict(self) -> Dict[str, Any]: + """Serializa el contexto para snapshot""" + return { + "items": [ + { + "source": item.source.value, + "content": item.content[:500] + "..." if len(item.content) > 500 else item.content, + "tokens": item.tokens, + "priority": item.priority, + "metadata": item.metadata + } + for item in self.items + ], + "total_tokens": self.total_tokens, + "algorithm_id": str(self.algorithm_id) if self.algorithm_id else None, + "composition": self.composition + } diff --git a/context-manager/src/providers/__init__.py b/context-manager/src/providers/__init__.py new file mode 100644 index 0000000..18df91f --- /dev/null +++ b/context-manager/src/providers/__init__.py @@ -0,0 +1,18 @@ +""" +Adaptadores para proveedores de IA + +Permite usar el Context Manager con cualquier modelo de IA. +""" + +from .base import BaseProvider, ProviderResponse +from .anthropic import AnthropicProvider +from .openai import OpenAIProvider +from .ollama import OllamaProvider + +__all__ = [ + "BaseProvider", + "ProviderResponse", + "AnthropicProvider", + "OpenAIProvider", + "OllamaProvider", +] diff --git a/context-manager/src/providers/anthropic.py b/context-manager/src/providers/anthropic.py new file mode 100644 index 0000000..18d1e15 --- /dev/null +++ b/context-manager/src/providers/anthropic.py @@ -0,0 +1,110 @@ +""" +Adaptador para Anthropic (Claude) +""" + +import os +from typing import List, Dict, Any, Optional + +from .base import BaseProvider, ProviderResponse +from ..models import SelectedContext, ContextSource + +try: + import anthropic + HAS_ANTHROPIC = True +except ImportError: + HAS_ANTHROPIC = False + + +class AnthropicProvider(BaseProvider): + """Proveedor para modelos de Anthropic (Claude)""" + + provider_name = "anthropic" + + def __init__( + self, + api_key: str = None, + model: str = "claude-sonnet-4-20250514", + max_tokens: int = 4096, + **kwargs + ): + super().__init__(api_key=api_key, model=model, **kwargs) + + if not HAS_ANTHROPIC: + raise ImportError("anthropic no está instalado. Ejecuta: pip install anthropic") + + self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + self.model = model + self.max_tokens = max_tokens + self.client = anthropic.Anthropic(api_key=self.api_key) + + def format_context(self, context: SelectedContext) -> tuple: + """ + Formatea el contexto para la API de Anthropic. + + Returns: + Tuple de (system_prompt, messages) + """ + system_parts = [] + messages = [] + + for item in context.items: + if item.source in [ContextSource.MEMORY, ContextSource.KNOWLEDGE, + ContextSource.AMBIENT, ContextSource.DATASET]: + system_parts.append(item.content) + elif item.source == ContextSource.HISTORY: + role = item.metadata.get("role", "user") + messages.append({ + "role": role, + "content": item.content + }) + + system_prompt = "\n\n".join(system_parts) if system_parts else None + return system_prompt, messages + + def send_message( + self, + message: str, + context: SelectedContext = None, + system_prompt: str = None, + temperature: float = 1.0, + **kwargs + ) -> ProviderResponse: + """Envía mensaje a Claude""" + + # Formatear contexto + context_system, context_messages = self.format_context(context) if context else (None, []) + + # Combinar system prompts + final_system = "" + if system_prompt: + final_system = system_prompt + if context_system: + final_system = f"{final_system}\n\n{context_system}" if final_system else context_system + + # Construir mensajes + messages = context_messages.copy() + messages.append({"role": "user", "content": message}) + + # Llamar a la API + response, latency_ms = self._measure_latency( + self.client.messages.create, + model=self.model, + max_tokens=kwargs.get("max_tokens", self.max_tokens), + system=final_system if final_system else anthropic.NOT_GIVEN, + messages=messages, + temperature=temperature + ) + + return ProviderResponse( + content=response.content[0].text, + model=response.model, + tokens_input=response.usage.input_tokens, + tokens_output=response.usage.output_tokens, + latency_ms=latency_ms, + finish_reason=response.stop_reason, + raw_response={ + "id": response.id, + "type": response.type, + "role": response.role + } + ) diff --git a/context-manager/src/providers/base.py b/context-manager/src/providers/base.py new file mode 100644 index 0000000..bd68a59 --- /dev/null +++ b/context-manager/src/providers/base.py @@ -0,0 +1,85 @@ +""" +Clase base para proveedores de IA +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +import time + +from ..models import SelectedContext + + +@dataclass +class ProviderResponse: + """Respuesta de un proveedor de IA""" + content: str + model: str + tokens_input: int = 0 + tokens_output: int = 0 + latency_ms: int = 0 + finish_reason: str = "stop" + raw_response: Dict[str, Any] = field(default_factory=dict) + + +class BaseProvider(ABC): + """ + Clase base para adaptadores de proveedores de IA. + + Cada proveedor debe implementar: + - send_message(): Enviar mensaje y recibir respuesta + - format_context(): Formatear contexto al formato del proveedor + """ + + provider_name: str = "base" + + def __init__(self, api_key: str = None, model: str = None, **kwargs): + self.api_key = api_key + self.model = model + self.extra_config = kwargs + + @abstractmethod + def send_message( + self, + message: str, + context: SelectedContext = None, + system_prompt: str = None, + **kwargs + ) -> ProviderResponse: + """ + Envía un mensaje al modelo y retorna la respuesta. + + Args: + message: Mensaje del usuario + context: Contexto seleccionado + system_prompt: Prompt de sistema adicional + **kwargs: Parámetros adicionales del modelo + + Returns: + ProviderResponse con la respuesta + """ + pass + + @abstractmethod + def format_context(self, context: SelectedContext) -> List[Dict[str, str]]: + """ + Formatea el contexto al formato de mensajes del proveedor. + + Args: + context: Contexto seleccionado + + Returns: + Lista de mensajes en el formato del proveedor + """ + pass + + def estimate_tokens(self, text: str) -> int: + """Estimación simple de tokens (4 caracteres por token)""" + return len(text) // 4 + + def _measure_latency(self, func, *args, **kwargs): + """Mide la latencia de una función""" + start = time.time() + result = func(*args, **kwargs) + latency_ms = int((time.time() - start) * 1000) + return result, latency_ms diff --git a/context-manager/src/providers/openai.py b/context-manager/src/providers/openai.py new file mode 100644 index 0000000..f84f250 --- /dev/null +++ b/context-manager/src/providers/openai.py @@ -0,0 +1,120 @@ +""" +Adaptador para OpenAI (GPT) +""" + +import os +from typing import List, Dict, Any, Optional + +from .base import BaseProvider, ProviderResponse +from ..models import SelectedContext, ContextSource + +try: + import openai + HAS_OPENAI = True +except ImportError: + HAS_OPENAI = False + + +class OpenAIProvider(BaseProvider): + """Proveedor para modelos de OpenAI (GPT)""" + + provider_name = "openai" + + def __init__( + self, + api_key: str = None, + model: str = "gpt-4", + max_tokens: int = 4096, + base_url: str = None, + **kwargs + ): + super().__init__(api_key=api_key, model=model, **kwargs) + + if not HAS_OPENAI: + raise ImportError("openai no está instalado. Ejecuta: pip install openai") + + self.api_key = api_key or os.getenv("OPENAI_API_KEY") + self.model = model + self.max_tokens = max_tokens + self.client = openai.OpenAI( + api_key=self.api_key, + base_url=base_url + ) + + def format_context(self, context: SelectedContext) -> List[Dict[str, str]]: + """ + Formatea el contexto para la API de OpenAI. + + Returns: + Lista de mensajes en formato OpenAI + """ + messages = [] + system_parts = [] + + for item in context.items: + if item.source in [ContextSource.MEMORY, ContextSource.KNOWLEDGE, + ContextSource.AMBIENT, ContextSource.DATASET]: + system_parts.append(item.content) + elif item.source == ContextSource.HISTORY: + role = item.metadata.get("role", "user") + messages.append({ + "role": role, + "content": item.content + }) + + # Insertar system message al inicio + if system_parts: + messages.insert(0, { + "role": "system", + "content": "\n\n".join(system_parts) + }) + + return messages + + def send_message( + self, + message: str, + context: SelectedContext = None, + system_prompt: str = None, + temperature: float = 1.0, + **kwargs + ) -> ProviderResponse: + """Envía mensaje a GPT""" + + # Formatear contexto + messages = self.format_context(context) if context else [] + + # Añadir system prompt adicional + if system_prompt: + if messages and messages[0]["role"] == "system": + messages[0]["content"] = f"{system_prompt}\n\n{messages[0]['content']}" + else: + messages.insert(0, {"role": "system", "content": system_prompt}) + + # Añadir mensaje del usuario + messages.append({"role": "user", "content": message}) + + # Llamar a la API + response, latency_ms = self._measure_latency( + self.client.chat.completions.create, + model=self.model, + messages=messages, + max_tokens=kwargs.get("max_tokens", self.max_tokens), + temperature=temperature + ) + + choice = response.choices[0] + + return ProviderResponse( + content=choice.message.content, + model=response.model, + tokens_input=response.usage.prompt_tokens, + tokens_output=response.usage.completion_tokens, + latency_ms=latency_ms, + finish_reason=choice.finish_reason, + raw_response={ + "id": response.id, + "created": response.created, + "system_fingerprint": response.system_fingerprint + } + ) diff --git a/execute.sh b/execute.sh new file mode 100755 index 0000000..4bc36d1 --- /dev/null +++ b/execute.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Captain Claude - Quick Execute +# Usage: ./execute.sh "your task here" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$SCRIPT_DIR/venv" +PYTHON="$VENV_DIR/bin/python" + +if [ -z "$1" ]; then + echo "Usage: $0 \"task description\"" + echo "Example: $0 \"Create a Python script to monitor disk usage\"" + exit 1 +fi + +TASK="$1" + +$PYTHON -c " +import asyncio +import sys +sys.path.insert(0, '$SCRIPT_DIR') +from captain_claude import CaptainClaude + +async def main(): + captain = CaptainClaude('$SCRIPT_DIR/captain_output') + result = await captain.execute('''$TASK''') + print(result['final_output']) + +asyncio.run(main()) +" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..969dea2 --- /dev/null +++ b/run.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Captain Claude by dadiaar - Launcher + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_DIR="$SCRIPT_DIR/venv" +PYTHON="$VENV_DIR/bin/python" + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}" +echo " ██████╗ █████╗ ██████╗ ████████╗ █████╗ ██╗███╗ ██╗" +echo " ██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗██║████╗ ██║" +echo " ██║ ███████║██████╔╝ ██║ ███████║██║██╔██╗ ██║" +echo " ██║ ██╔══██║██╔═══╝ ██║ ██╔══██║██║██║╚██╗██║" +echo " ╚██████╗██║ ██║██║ ██║ ██║ ██║██║██║ ╚████║" +echo " ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝" +echo " ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗" +echo " ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝" +echo " ██║ ██║ ███████║██║ ██║██║ ██║█████╗ " +echo " ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝ " +echo " ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗" +echo " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝" +echo -e "${GREEN} by dadiaar${NC}" +echo "" + +# Check if venv exists +if [ ! -d "$VENV_DIR" ]; then + echo "Error: Virtual environment not found at $VENV_DIR" + exit 1 +fi + +# Run Captain Claude +exec "$PYTHON" "$SCRIPT_DIR/captain_claude.py" "$@"