From d1b2c16bd0e443f2af3f4e5699c7f494b40042f0 Mon Sep 17 00:00:00 2001 From: ARCHITECT Date: Thu, 1 Jan 2026 22:41:20 +0000 Subject: [PATCH] Add Context Manager client and log schema - context_manager.py: Full stateless client for Anthropic API - Builds context from cto.blocks + cto.memory + log.messages - Logs all messages to immutable log.messages table - Interactive chat mode with CLI commands - context_bridge.py: PostgreSQL bridge for context queries - Peer auth support for local connections - get_all_relevant_blocks() single query optimization - build_system_prompt() from blocks - 04_log.sql: Immutable log schema - log.messages with hash chain integrity - Triggers preventing UPDATE/DELETE - 07_initial_blocks.sql: Initial context blocks - tzzr_base, rules_base, r2_storage - server_architect, server_deck, server_corp - cm: Launcher script with venv support --- cm | 14 ++ schemas/04_log.sql | 155 +++++---------- schemas/07_initial_blocks.sql | 79 ++++++++ src/context_bridge.py | 236 +++++++++++++++++++++++ src/context_manager.py | 351 ++++++++++++++++++++++++++++++++++ 5 files changed, 728 insertions(+), 107 deletions(-) create mode 100755 cm create mode 100644 schemas/07_initial_blocks.sql create mode 100644 src/context_bridge.py create mode 100755 src/context_manager.py diff --git a/cm b/cm new file mode 100755 index 0000000..14cad4f --- /dev/null +++ b/cm @@ -0,0 +1,14 @@ +#!/bin/bash +# Context Manager CLI +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "CONTEXT MANAGER - TZZR" +echo "======================" + +# Activar venv si existe +if [ -d "$SCRIPT_DIR/venv" ]; then + source "$SCRIPT_DIR/venv/bin/activate" +fi + +# Ejecutar +exec python3 "$SCRIPT_DIR/context_manager.py" "$@" diff --git a/schemas/04_log.sql b/schemas/04_log.sql index 79f818b..34bceaa 100644 --- a/schemas/04_log.sql +++ b/schemas/04_log.sql @@ -1,119 +1,60 @@ --- ============================================ --- SCHEMA LOG - Sistema TZZR --- Log inmutable de mensajes --- Versión: 2.0 --- Fecha: 2026-01-01 --- ============================================ +-- Schema LOG para Context Manager +CREATE SCHEMA IF NOT EXISTS log; -DROP SCHEMA IF EXISTS log CASCADE; -CREATE SCHEMA log; - --- Extensiones -CREATE EXTENSION IF NOT EXISTS pgcrypto; - --- Tipos -CREATE TYPE log.ref_type AS ENUM ('context', 'accountant', 'secretary'); - --- ============================================ --- Tabla principal: messages --- ============================================ -CREATE TABLE log.messages ( - id BIGSERIAL PRIMARY KEY, - hash CHAR(64) UNIQUE NOT NULL, - session_hash CHAR(64) NOT NULL, - thread_hash CHAR(64), - owner_id CHAR(64) NOT NULL, - players_id CHAR(64)[] NOT NULL DEFAULT '{}', - master_player CHAR(64), - role TEXT, - content TEXT NOT NULL, - attachments JSONB DEFAULT '{}', - prev_hash CHAR(64), - hashtags CHAR(64)[] DEFAULT '{}', - flag_id CHAR(64), - master_item_id CHAR(64), - item_id CHAR(64)[] DEFAULT '{}', - loc_id CHAR(64), - ambient JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() +CREATE TABLE IF NOT EXISTS log.messages ( + id BIGSERIAL PRIMARY KEY, + hash CHAR(64) UNIQUE NOT NULL, + session_hash CHAR(64) NOT NULL, + thread_hash CHAR(64), + owner_id CHAR(64), + players_id CHAR(64)[], + master_player CHAR(64), + role TEXT NOT NULL, + content TEXT NOT NULL, + attachments JSONB DEFAULT '{}', + prev_hash CHAR(64), + hashtags CHAR(64)[], + flag_id CHAR(64), + master_item_id CHAR(64), + item_id CHAR(64)[], + loc_id CHAR(64), + ambient JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() ); --- Índices messages -CREATE INDEX idx_messages_session ON log.messages(session_hash); -CREATE INDEX idx_messages_thread ON log.messages(thread_hash); -CREATE INDEX idx_messages_owner ON log.messages(owner_id); -CREATE INDEX idx_messages_players ON log.messages USING gin(players_id); -CREATE INDEX idx_messages_master ON log.messages(master_player); -CREATE INDEX idx_messages_prev ON log.messages(prev_hash); -CREATE INDEX idx_messages_hashtags ON log.messages USING gin(hashtags); -CREATE INDEX idx_messages_flag ON log.messages(flag_id); -CREATE INDEX idx_messages_master_item ON log.messages(master_item_id); -CREATE INDEX idx_messages_items ON log.messages USING gin(item_id); -CREATE INDEX idx_messages_loc ON log.messages(loc_id); -CREATE INDEX idx_messages_created ON log.messages(created_at); - --- ============================================ --- Tabla relacional: message_refs --- Referencias a contexto y conocimiento --- ============================================ -CREATE TABLE log.message_refs ( - id BIGSERIAL PRIMARY KEY, - message_hash CHAR(64) NOT NULL, - ref_hash CHAR(64) NOT NULL, - ref_type log.ref_type NOT NULL, - position INT NOT NULL, - thread_hash CHAR(64), - UNIQUE(message_hash, ref_hash, ref_type) +CREATE TABLE IF NOT EXISTS log.message_refs ( + id BIGSERIAL PRIMARY KEY, + message_hash CHAR(64) NOT NULL, + ref_hash CHAR(64) NOT NULL, + ref_type VARCHAR(20) NOT NULL, + position INT, + thread_hash CHAR(64) ); --- Índices message_refs -CREATE INDEX idx_refs_message ON log.message_refs(message_hash); -CREATE INDEX idx_refs_ref ON log.message_refs(ref_hash); -CREATE INDEX idx_refs_type ON log.message_refs(ref_type); -CREATE INDEX idx_refs_thread ON log.message_refs(thread_hash); +CREATE INDEX IF NOT EXISTS idx_log_session ON log.messages(session_hash); +CREATE INDEX IF NOT EXISTS idx_log_thread ON log.messages(thread_hash); +CREATE INDEX IF NOT EXISTS idx_log_owner ON log.messages(owner_id); +CREATE INDEX IF NOT EXISTS idx_log_prev ON log.messages(prev_hash); +CREATE INDEX IF NOT EXISTS idx_log_created ON log.messages(created_at); +CREATE INDEX IF NOT EXISTS idx_refs_message ON log.message_refs(message_hash); +CREATE INDEX IF NOT EXISTS idx_refs_ref ON log.message_refs(ref_hash); --- ============================================ --- Funciones --- ============================================ -CREATE OR REPLACE FUNCTION log.sha256(data TEXT) RETURNS CHAR(64) AS $$ +CREATE OR REPLACE FUNCTION log.prevent_modification() RETURNS TRIGGER AS $$ BEGIN - RETURN encode(digest(data, 'sha256'), 'hex'); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -CREATE OR REPLACE FUNCTION log.prevent_update() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION 'UPDATE no permitido en %', TG_TABLE_NAME; + RAISE EXCEPTION 'Log is immutable'; END; $$ LANGUAGE plpgsql; -CREATE OR REPLACE FUNCTION log.prevent_delete() RETURNS TRIGGER AS $$ -BEGIN - RAISE EXCEPTION 'DELETE no permitido en %', TG_TABLE_NAME; -END; -$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS no_update_messages ON log.messages; +DROP TRIGGER IF EXISTS no_delete_messages ON log.messages; +CREATE TRIGGER no_update_messages BEFORE UPDATE ON log.messages FOR EACH ROW EXECUTE FUNCTION log.prevent_modification(); +CREATE TRIGGER no_delete_messages BEFORE DELETE ON log.messages FOR EACH ROW EXECUTE FUNCTION log.prevent_modification(); --- ============================================ --- Triggers de protección (inmutabilidad) --- ============================================ -CREATE TRIGGER protect_messages_update BEFORE UPDATE ON log.messages - FOR EACH ROW EXECUTE FUNCTION log.prevent_update(); -CREATE TRIGGER protect_messages_delete BEFORE DELETE ON log.messages - FOR EACH ROW EXECUTE FUNCTION log.prevent_delete(); +DROP TRIGGER IF EXISTS no_update_refs ON log.message_refs; +DROP TRIGGER IF EXISTS no_delete_refs ON log.message_refs; +CREATE TRIGGER no_update_refs BEFORE UPDATE ON log.message_refs FOR EACH ROW EXECUTE FUNCTION log.prevent_modification(); +CREATE TRIGGER no_delete_refs BEFORE DELETE ON log.message_refs FOR EACH ROW EXECUTE FUNCTION log.prevent_modification(); -CREATE TRIGGER protect_refs_update BEFORE UPDATE ON log.message_refs - FOR EACH ROW EXECUTE FUNCTION log.prevent_update(); -CREATE TRIGGER protect_refs_delete BEFORE DELETE ON log.message_refs - FOR EACH ROW EXECUTE FUNCTION log.prevent_delete(); - --- ============================================ --- Permisos --- ============================================ -GRANT USAGE ON SCHEMA log TO tzzr; -GRANT SELECT, INSERT ON log.messages TO tzzr; -GRANT SELECT, INSERT ON log.message_refs TO tzzr; -GRANT USAGE ON SEQUENCE log.messages_id_seq TO tzzr; -GRANT USAGE ON SEQUENCE log.message_refs_id_seq TO tzzr; - --- Verificación -SELECT 'Schema log v2.0: 2 tablas creadas' as status; +GRANT USAGE ON SCHEMA log TO architect; +GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA log TO architect; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA log TO architect; diff --git a/schemas/07_initial_blocks.sql b/schemas/07_initial_blocks.sql new file mode 100644 index 0000000..c3216d5 --- /dev/null +++ b/schemas/07_initial_blocks.sql @@ -0,0 +1,79 @@ +-- Bloques iniciales para Context Manager + +-- Permitir NULL en session_hash y owner_id si no está ya +ALTER TABLE cto.blocks ALTER COLUMN session_hash DROP NOT NULL; +ALTER TABLE cto.blocks ALTER COLUMN owner_id DROP NOT NULL; + +-- BLOQUE GLOBAL: Info base TZZR +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, status) +VALUES (encode(sha256('tzzr_base'::bytea), 'hex'), 'tzzr_base', 'TZZR Base', 'system', +'# Sistema TZZR + +## Servidores + +| Servidor | IP | Propósito | +|----------|-----|-----------| +| ARCHITECT | 69.62.126.110 | Desarrollo | +| DECK | 72.62.1.113 | Usuario | +| CORP | 92.112.181.188 | Corporativo | + +## Acceso SSH +Usar clave ~/.ssh/tzzr para conexiones entre servidores.', 100, 'global', 'active') +ON CONFLICT (code) DO NOTHING; + +-- BLOQUE GLOBAL: Reglas de operación +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, status) +VALUES (encode(sha256('rules_base'::bytea), 'hex'), 'rules_base', 'Reglas Base', 'rules', +'## Reglas de Operación + +- Ejecutar comandos sin pedir confirmación +- No guardar documentos en servidor local, usar R2 +- Limpieza automática: subir a R2 y eliminar archivos temporales', 90, 'global', 'active') +ON CONFLICT (code) DO NOTHING; + +-- BLOQUE GLOBAL: R2 Storage +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, status) +VALUES (encode(sha256('r2_storage'::bytea), 'hex'), 'r2_storage', 'R2 Storage', 'knowledge', +'## Almacenamiento R2 + +Endpoint: https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com + +### Bucket: architect +- documentos adjuntos/ - Documentos para usuario +- system/ - Configs y backups +- auditorias/ - Logs', 80, 'global', 'active') +ON CONFLICT (code) DO NOTHING; + +-- BLOQUE SERVER: ARCHITECT +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, server_code, status) +VALUES (encode(sha256('server_architect'::bytea), 'hex'), 'server_architect', 'Servidor ARCHITECT', 'config', +'## ARCHITECT (69.62.126.110) + +Servidor de desarrollo. + +Servicios locales: +- Gitea: http://localhost:3000 +- PostgreSQL: localhost:5432 + +SSH a otros servidores: +- ssh -i ~/.ssh/tzzr root@72.62.1.113 (DECK) +- ssh -i ~/.ssh/tzzr root@92.112.181.188 (CORP)', 70, 'server', 'ARCHITECT', 'active') +ON CONFLICT (code) DO NOTHING; + +-- BLOQUE SERVER: DECK +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, server_code, status) +VALUES (encode(sha256('server_deck'::bytea), 'hex'), 'server_deck', 'Servidor DECK', 'config', +'## DECK (72.62.1.113) + +Servidor de usuario.', 70, 'server', 'DECK', 'active') +ON CONFLICT (code) DO NOTHING; + +-- BLOQUE SERVER: CORP +INSERT INTO cto.blocks (hash, code, name, category, content, priority, scope, server_code, status) +VALUES (encode(sha256('server_corp'::bytea), 'hex'), 'server_corp', 'Servidor CORP', 'config', +'## CORP (92.112.181.188) + +Servidor corporativo.', 70, 'server', 'CORP', 'active') +ON CONFLICT (code) DO NOTHING; + +SELECT code, category, scope, server_code, priority FROM cto.blocks ORDER BY priority DESC; diff --git a/src/context_bridge.py b/src/context_bridge.py new file mode 100644 index 0000000..60479cb --- /dev/null +++ b/src/context_bridge.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Context Bridge - Puente entre Captain Claude y Context Manager +Consulta cto.blocks y cto.memory para construir contexto dinámico. +""" + +import os +import psycopg2 +from psycopg2.extras import RealDictCursor +from typing import Optional, List, Dict +from datetime import datetime + + +class ContextBridge: + """Puente para consultar el Context Manager desde PostgreSQL.""" + + def __init__(self, + host: str = None, + port: int = 5432, + database: str = "postgres", + user: str = None, + password: str = None): + # Usar variables de entorno o valores por defecto + self.conn_params = { + "database": os.environ.get("PGDATABASE", database), + } + + # Solo añadir host si no es local (para usar Unix socket con peer auth) + pg_host = os.environ.get("PGHOST", host) + if pg_host and pg_host not in ("localhost", "127.0.0.1", "::1", "", None): + self.conn_params["host"] = pg_host + self.conn_params["port"] = int(os.environ.get("PGPORT", port)) + + # Solo añadir user/password si están definidos + pg_user = os.environ.get("PGUSER", user) + pg_pass = os.environ.get("PGPASSWORD", password) + if pg_user: + self.conn_params["user"] = pg_user + if pg_pass: + self.conn_params["password"] = pg_pass + + self._conn = None + + @property + def conn(self): + """Conexión lazy a PostgreSQL.""" + if self._conn is None or self._conn.closed: + self._conn = psycopg2.connect(**self.conn_params) + return self._conn + + def close(self): + """Cerrar conexión.""" + if self._conn and not self._conn.closed: + self._conn.close() + + def get_active_blocks(self, + scope: str = "global", + server_code: Optional[str] = None, + agent_code: Optional[str] = None) -> List[Dict]: + """ + Obtener bloques activos del Context Manager. + + Args: + scope: global, server, agent, session + server_code: ARCHITECT, DECK, CORP + agent_code: código del agente específico + + Returns: + Lista de bloques con code, name, content, category, priority + """ + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT code, name, content, category, priority, token_count + FROM cto.get_active_blocks(%s, %s, %s) + ORDER BY priority DESC NULLS LAST + """, (scope, server_code, agent_code)) + return cur.fetchall() + + def get_all_relevant_blocks(self, + server_code: Optional[str] = None, + agent_code: Optional[str] = None) -> List[Dict]: + """ + Obtener todos los bloques relevantes (global + server + agent). + La función SQL ya maneja la lógica de combinar scopes. + """ + # Una sola llamada - la función SQL devuelve global + server + agent + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT code, name, content, category, priority, token_count + FROM cto.get_active_blocks(%s, %s, %s) + ORDER BY priority DESC NULLS LAST + """, ('global', server_code, agent_code)) + return cur.fetchall() + + def build_system_prompt(self, + server_code: Optional[str] = None, + agent_code: Optional[str] = None, + include_metadata: bool = False) -> str: + """ + Construir system prompt desde bloques de contexto. + + Args: + server_code: ARCHITECT, DECK, CORP + agent_code: código del agente + include_metadata: incluir comentarios con metadata + + Returns: + String con el system prompt concatenado + """ + blocks = self.get_all_relevant_blocks(server_code, agent_code) + + if not blocks: + return "" + + parts = [] + for block in blocks: + if include_metadata: + header = f"" + parts.append(f"{header}\n{block['content']}") + else: + parts.append(block['content']) + + return "\n\n---\n\n".join(parts) + + def generate_claude_md(self, + output_path: str, + server_code: Optional[str] = None, + agent_code: Optional[str] = None) -> str: + """ + Generar archivo CLAUDE.md desde bloques de contexto. + + Args: + output_path: ruta donde guardar el archivo + server_code: ARCHITECT, DECK, CORP + agent_code: código del agente + + Returns: + Contenido generado + """ + content = self.build_system_prompt(server_code, agent_code, include_metadata=True) + + header = f"""# Context Manager Generated +# Server: {server_code or 'ALL'} +# Agent: {agent_code or 'ALL'} +# Generated: {datetime.now().isoformat()} + +""" + full_content = header + content + + with open(output_path, 'w') as f: + f.write(full_content) + + return full_content + + def get_algorithm_config(self, code: str = "default_v2") -> Optional[Dict]: + """Obtener configuración del algoritmo activo.""" + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT code, name, version, config, status + FROM cto.algorithms + WHERE code = %s OR (status = 'active' AND %s IS NULL) + LIMIT 1 + """, (code, code)) + return cur.fetchone() + + def log_message(self, + session_hash: str, + owner_id: str, + role: str, + content: str, + prev_hash: Optional[str] = None) -> str: + """ + Registrar mensaje en log inmutable. + + Returns: + Hash del mensaje registrado + """ + import hashlib + + # Calcular hash + hash_input = f"{prev_hash or ''}{session_hash}{owner_id}{role}{content}" + msg_hash = hashlib.sha256(hash_input.encode()).hexdigest() + + with self.conn.cursor() as cur: + cur.execute(""" + INSERT INTO log.messages (hash, session_hash, owner_id, role, content, prev_hash, created_at) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (hash) DO NOTHING + RETURNING hash + """, (msg_hash, session_hash, owner_id, role, content, prev_hash)) + self.conn.commit() + + return msg_hash + + +# Singleton para uso fácil +_bridge: Optional[ContextBridge] = None + +def get_bridge() -> ContextBridge: + """Obtener instancia singleton del bridge.""" + global _bridge + if _bridge is None: + _bridge = ContextBridge() + return _bridge + + +def get_context(server_code: Optional[str] = None, + agent_code: Optional[str] = None) -> str: + """Función helper para obtener contexto rápidamente.""" + return get_bridge().build_system_prompt(server_code, agent_code) + + +if __name__ == "__main__": + # Test básico + bridge = ContextBridge() + + print("=== Context Bridge Test ===\n") + + # Probar obtener bloques + blocks = bridge.get_active_blocks('global') + print(f"Bloques globales: {len(blocks)}") + + for block in blocks: + print(f" - {block.get('code')}: {block.get('category')} (priority: {block.get('priority')})") + + # Probar generar system prompt + prompt = bridge.build_system_prompt(server_code='ARCHITECT') + print(f"\nSystem prompt length: {len(prompt)} chars") + + # Probar algoritmo + algo = bridge.get_algorithm_config() + if algo: + print(f"\nAlgoritmo activo: {algo.get('code')} v{algo.get('version')}") + + bridge.close() + print("\n=== Test completado ===") diff --git a/src/context_manager.py b/src/context_manager.py new file mode 100755 index 0000000..0226c63 --- /dev/null +++ b/src/context_manager.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +Context Manager - Cliente completo para gestión de contexto TZZR. +Maneja todo el contexto: cto.blocks, cto.memory, log.messages. +Llama a API Anthropic directamente (stateless). +""" + +import os +import hashlib +import asyncio +from datetime import datetime +from typing import Optional, List, Dict, Any +from dataclasses import dataclass + +import psycopg2 +from psycopg2.extras import RealDictCursor +import anthropic + + +@dataclass +class Message: + role: str + content: str + hash: Optional[str] = None + + +@dataclass +class Session: + hash: str + owner_id: str + server_code: str + created_at: datetime + + +class ContextManager: + """ + Context Manager completo. + - Lee cto.blocks para contexto estático + - Busca cto.memory para memorias relevantes + - Lee/escribe log.messages para historial + - Llama a Anthropic API directamente (stateless) + """ + + def __init__(self, + server_code: str = "ARCHITECT", + model: str = "claude-sonnet-4-20250514", + max_history: int = 20, + max_memories: int = 10, + token_budget: int = 8000): + + self.server_code = server_code + self.model = model + self.max_history = max_history + self.max_memories = max_memories + self.token_budget = token_budget + + # Conexión BD + self.conn_params = {"database": os.environ.get("PGDATABASE", "postgres")} + self._conn = None + + # Cliente Anthropic + self.anthropic = anthropic.Anthropic() + + # Sesión actual + self.session: Optional[Session] = None + self.prev_hash: Optional[str] = None + + @property + def conn(self): + if self._conn is None or self._conn.closed: + self._conn = psycopg2.connect(**self.conn_params) + return self._conn + + def close(self): + if self._conn and not self._conn.closed: + self._conn.close() + + # ========================================================================= + # SESIONES + # ========================================================================= + + def start_session(self, owner_id: str = "user") -> Session: + """Iniciar nueva sesión.""" + session_hash = hashlib.sha256( + f"{owner_id}{self.server_code}{datetime.now().isoformat()}".encode() + ).hexdigest() + + self.session = Session( + hash=session_hash, + owner_id=owner_id, + server_code=self.server_code, + created_at=datetime.now() + ) + self.prev_hash = None + return self.session + + # ========================================================================= + # CONTEXTO: cto.blocks + # ========================================================================= + + def get_blocks(self, agent_code: Optional[str] = None) -> List[Dict]: + """Obtener bloques activos desde cto.blocks.""" + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT code, name, content, category, priority, token_count + FROM cto.get_active_blocks(%s, %s, %s) + ORDER BY priority DESC NULLS LAST + """, ('global', self.server_code, agent_code)) + return cur.fetchall() + + # ========================================================================= + # CONTEXTO: cto.memory + # ========================================================================= + + def search_memories(self, query: str, limit: int = None) -> List[Dict]: + """Buscar memorias relevantes (por ahora sin embeddings, placeholder).""" + limit = limit or self.max_memories + + if not self.session: + return [] + + # TODO: Implementar búsqueda con embeddings + # Por ahora, obtener últimas memorias del owner + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT id, content, type, importance, summary + FROM cto.memory + WHERE owner_id = %s AND status = 'active' + ORDER BY last_access DESC NULLS LAST, importance DESC + LIMIT %s + """, (self.session.owner_id, limit)) + return cur.fetchall() + + # ========================================================================= + # CONTEXTO: log.messages (historial) + # ========================================================================= + + def get_history(self, limit: int = None) -> List[Message]: + """Obtener últimos mensajes de la sesión desde log.messages.""" + limit = limit or self.max_history + + if not self.session: + return [] + + with self.conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT hash, role, content + FROM log.messages + WHERE session_hash = %s + ORDER BY created_at DESC + LIMIT %s + """, (self.session.hash, limit)) + rows = cur.fetchall() + + # Invertir para orden cronológico + messages = [ + Message(role=r['role'], content=r['content'], hash=r['hash']) + for r in reversed(rows) + ] + return messages + + def log_message(self, role: str, content: str) -> str: + """Guardar mensaje en log.messages (inmutable).""" + if not self.session: + raise ValueError("No hay sesión activa") + + # Calcular hash + hash_input = f"{self.prev_hash or ''}{self.session.hash}{role}{content}" + msg_hash = hashlib.sha256(hash_input.encode()).hexdigest() + + with self.conn.cursor() as cur: + cur.execute(""" + INSERT INTO log.messages (hash, session_hash, owner_id, role, content, prev_hash, created_at) + VALUES (%s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (hash) DO NOTHING + """, (msg_hash, self.session.hash, self.session.owner_id, role, content, self.prev_hash)) + self.conn.commit() + + self.prev_hash = msg_hash + return msg_hash + + # ========================================================================= + # CONSTRUIR CONTEXTO COMPLETO + # ========================================================================= + + def build_system_prompt(self, agent_code: Optional[str] = None) -> str: + """Construir system prompt desde blocks + memories.""" + parts = [] + + # 1. Bloques de contexto + blocks = self.get_blocks(agent_code) + for block in blocks: + parts.append(f"\n{block['content']}") + + # 2. Memorias relevantes (si hay) + # memories = self.search_memories("") # TODO: usar query real + # if memories: + # parts.append("## Memorias Relevantes") + # for mem in memories: + # parts.append(f"- {mem.get('summary') or mem.get('content', '')[:100]}") + + return "\n\n---\n\n".join(parts) + + def build_messages(self, user_message: str) -> List[Dict]: + """Construir lista de mensajes para API.""" + messages = [] + + # Historial de la sesión + history = self.get_history() + for msg in history: + messages.append({"role": msg.role, "content": msg.content}) + + # Mensaje actual + messages.append({"role": "user", "content": user_message}) + + return messages + + # ========================================================================= + # ENVIAR MENSAJE + # ========================================================================= + + def send_message(self, user_message: str, agent_code: Optional[str] = None) -> str: + """ + Enviar mensaje usando Context Manager completo. + + 1. Construye contexto desde BD + 2. Llama a Anthropic API (stateless) + 3. Guarda en log + 4. Retorna respuesta + """ + if not self.session: + self.start_session() + + # 1. Construir contexto + system_prompt = self.build_system_prompt(agent_code) + messages = self.build_messages(user_message) + + # 2. Guardar mensaje usuario en log + self.log_message("user", user_message) + + # 3. Llamar a Anthropic API + response = self.anthropic.messages.create( + model=self.model, + max_tokens=4096, + system=system_prompt, + messages=messages + ) + + assistant_message = response.content[0].text + + # 4. Guardar respuesta en log + self.log_message("assistant", assistant_message) + + # 5. TODO: Extraer memorias async + # self.extract_memories_async(user_message, assistant_message) + + return assistant_message + + # ========================================================================= + # EXTRACCIÓN DE MEMORIAS (TODO) + # ========================================================================= + + def extract_memories(self, user_message: str, assistant_message: str): + """Extraer memorias del intercambio y guardar en cto.memory.""" + # TODO: Implementar extracción con LLM + pass + + # ========================================================================= + # CLI INTERACTIVO + # ========================================================================= + + def chat(self): + """Modo chat interactivo.""" + print(f"\n{'='*60}") + print("CONTEXT MANAGER - TZZR") + print(f"Server: {self.server_code} | Model: {self.model}") + print(f"{'='*60}") + print("Comandos: /quit, /session, /blocks, /history") + print(f"{'='*60}\n") + + self.start_session() + print(f"[Sesión: {self.session.hash[:16]}...]\n") + + while True: + try: + user_input = input("Tú: ").strip() + + if not user_input: + continue + + if user_input == "/quit": + print("Adiós.") + break + + if user_input == "/session": + print(f"Sesión: {self.session.hash}") + print(f"Owner: {self.session.owner_id}") + print(f"Server: {self.server_code}") + continue + + if user_input == "/blocks": + blocks = self.get_blocks() + print(f"Bloques ({len(blocks)}):") + for b in blocks: + print(f" - {b['code']} ({b['category']}, p:{b['priority']})") + continue + + if user_input == "/history": + history = self.get_history() + print(f"Historial ({len(history)} mensajes):") + for h in history: + print(f" [{h.role}]: {h.content[:50]}...") + continue + + # Enviar mensaje + response = self.send_message(user_input) + print(f"\nAsistente: {response}\n") + + except KeyboardInterrupt: + print("\nAdiós.") + break + except Exception as e: + print(f"Error: {e}") + + self.close() + + +def main(): + """Punto de entrada.""" + import argparse + + parser = argparse.ArgumentParser(description="Context Manager TZZR") + parser.add_argument("--server", default="ARCHITECT", help="Código de servidor") + parser.add_argument("--model", default="claude-sonnet-4-20250514", help="Modelo Anthropic") + parser.add_argument("--message", "-m", help="Enviar mensaje único") + + args = parser.parse_args() + + cm = ContextManager(server_code=args.server, model=args.model) + + if args.message: + cm.start_session() + response = cm.send_message(args.message) + print(response) + cm.close() + else: + cm.chat() + + +if __name__ == "__main__": + main()