diff --git a/LokiLogger.py b/LokiLogger.py new file mode 100644 index 0000000..5d80b98 --- /dev/null +++ b/LokiLogger.py @@ -0,0 +1,178 @@ +import time +import json +import requests +import traceback +from typing import Optional, Dict, Any, Callable +from functools import wraps + + +class LokiLogger: + """ + Logger compatible con Grafana Loki / Alloy. + Envía logs en formato JSON a través del endpoint HTTP de Loki. + """ + + ALLOWED_LEVELS = ( + "TRACE", + "DEBUG", + "INFO", + "WARN", + "ERROR", + "FATAL", + "CRITICAL", + "UNKNOWN", + ) + + def __init__( + self, + endpoint: str = "http://127.0.0.1:3101/loki/api/v1/push", + add_labels: Optional[Dict[str, str]] = None, + timeout: float = 5.0, + min_level: str = "DEBUG", + service_name: Optional[str] = None, + ): + """ + :param endpoint: URL completa del endpoint de Loki / Alloy. + :param add_labels: etiquetas adicionales comunes (ej: {"env": "dev"}) + :param timeout: timeout en segundos para la petición HTTP + :param min_level: nivel mínimo para enviar logs + :param service_name: nombre del servicio (usado como 'service_name' label) + """ + self.endpoint = endpoint + self.timeout = timeout + self.service_name = service_name or "unknown-service" + + min_level = min_level.upper() + if min_level not in self.ALLOWED_LEVELS: + raise ValueError(f"min_level debe estar en {self.ALLOWED_LEVELS}") + self.min_level = min_level + + # 🔹 Labels adicionales del constructor + self.add_labels = dict(add_labels or {}) + + self._level_order = { + "TRACE": 0, + "DEBUG": 1, + "INFO": 2, + "WARN": 3, + "ERROR": 4, + "FATAL": 5, + "CRITICAL": 6, + "UNKNOWN": 7, + } + + def _current_ns(self) -> str: + """Devuelve timestamp actual en nanosegundos como string.""" + return str(int(time.time() * 1e9)) + + def _should_log(self, level: str) -> bool: + """Comprueba si el nivel cumple con el mínimo configurado.""" + return self._level_order[level] >= self._level_order[self.min_level] + + def log( + self, + level: str, + message: str, + add_labels: Optional[Dict[str, str]] = None, + add_fields: Optional[Dict[str, Any]] = None, + ) -> None: + """Envía un log a Loki con los campos mínimos (timestamp + message).""" + level = level.upper() + if level == "WARNING": + level = "WARN" + + if level not in self.ALLOWED_LEVELS: + raise ValueError(f"Nivel no válido: {level}. Debe estar en {self.ALLOWED_LEVELS}") + + if not self._should_log(level): + return + + # 🔸 Construimos labels: solo 2 labels por defecto + final_labels = { + "service_name": self.service_name, + "detected_level": level, + } + + # Agrega labels adicionales del constructor + if self.add_labels: + final_labels.update(self.add_labels) + + # Agrega labels adicionales del método + if add_labels: + final_labels.update(add_labels) + + # 🧾 El log line lleva timestamp + message + campos adicionales + log_line_data = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.localtime()), + "message": message, + } + + # Agrega campos adicionales si se pasan + if add_fields: + log_line_data.update(add_fields) + + log_line = json.dumps(log_line_data, ensure_ascii=False) + + body = { + "streams": [ + {"stream": final_labels, "values": [[self._current_ns(), log_line]]} + ] + } + + try: + resp = requests.post(self.endpoint, json=body, timeout=self.timeout) + resp.raise_for_status() + except Exception as e: + print(f"Failed to send log to Loki: {e}", flush=True) + + # Métodos estándar por nivel + def trace(self, message, add_labels=None, add_fields=None): + self.log("TRACE", message, add_labels, add_fields) + + def debug(self, message, add_labels=None, add_fields=None): + self.log("DEBUG", message, add_labels, add_fields) + + def info(self, message, add_labels=None, add_fields=None): + self.log("INFO", message, add_labels, add_fields) + + def warn(self, message, add_labels=None, add_fields=None): + self.log("WARN", message, add_labels, add_fields) + + def warning(self, message, add_labels=None, add_fields=None): + self.log("WARN", message, add_labels, add_fields) + + def error(self, message, add_labels=None, add_fields=None): + self.log("ERROR", message, add_labels, add_fields) + + def exception(self, exc: Exception, add_labels=None, add_fields=None): + """Registra una excepción con traceback incluido.""" + tb = traceback.format_exc() + message = str(exc) + self.log("ERROR", f"{message}\n{tb}", add_labels=add_labels, add_fields=add_fields) + + def fatal(self, message, add_labels=None, add_fields=None): + self.log("FATAL", message, add_labels, add_fields) + + def critical(self, message, add_labels=None, add_fields=None): + self.log("CRITICAL", message, add_labels, add_fields) + + def unknown(self, message, add_labels=None, add_fields=None): + self.log("UNKNOWN", message, add_labels, add_fields) + + # 🧩 Decorador para capturar excepciones + def catch_exceptions(self, reraise: bool = False): + """Decorador que captura excepciones y las loguea.""" + def decorator(func: Callable): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + tb = traceback.format_exc() + self.error( + f"Exception en función '{func.__name__}': {e}\n{tb}" + ) + if reraise: + raise + return wrapper + return decorator diff --git a/README.md b/README.md index e69de29..1d4c203 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,19 @@ +# Kanboard Agent + +## Telegram Bot + +1. Copia tu token de bot en `.env` usando las siguientes variables: + ``` + TELEGRAM_BOT_TOKEN="" + TELEGRAM_ALLOWED_CHAT_ID="" + TELEGRAM_ALLOWED_USER_ID="" + TELEGRAM_AGENT_KEY="kan" # opcional, usa el valor del registro si es distinto + ``` +2. Asegúrate de tener configurado `OPENAI_API_KEY` y cualquier otra credencial necesaria. +3. Ejecuta el bot con `python ejecucion_telegram.py` desde el entorno virtual del proyecto. +4. Usa el chat de Telegram indicado para conversar con el agente Kan. + +> **Notas** +> - El bot filtra mensajes por `user_id` y `chat_id` para que solo tú puedas usarlo. +> - El endpoint de Loki se reutiliza automáticamente para los logs del bot. +> - Puedes personalizar el agente cambiando `TELEGRAM_AGENT_KEY` si registras más agentes. diff --git a/agentes/__init__.py b/agentes/__init__.py new file mode 100644 index 0000000..2aed311 --- /dev/null +++ b/agentes/__init__.py @@ -0,0 +1,24 @@ +"""Paquete que contiene las definiciones de agentes personalizados.""" + +from .agente_kanboard import AgenteBasico +from .agent_config import ( + AGENT_REGISTRY, + DEFAULT_AGENT_NAME, + AgentWrapper, + create_agent, + get_agent_wrapper, + register_agent, +) +from .base import AgenteBase, AgentDefinition + +__all__ = [ + "AgenteBasico", + "AgentDefinition", + "AgenteBase", + "AgentWrapper", + "AGENT_REGISTRY", + "DEFAULT_AGENT_NAME", + "create_agent", + "get_agent_wrapper", + "register_agent", +] diff --git a/agentes/agent_config.py b/agentes/agent_config.py new file mode 100644 index 0000000..bfe43ef --- /dev/null +++ b/agentes/agent_config.py @@ -0,0 +1,293 @@ +import os +from dataclasses import dataclass, field +from time import perf_counter +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from agno.agent import Agent +from agno.db.base import SessionType +from agno.models.openai import OpenAIChat + +from .agente_kanboard import AgenteBasico +from .base import AgentDefinition +from mcp_wrapper import MCPConfigError, initialize_mcp_tools, load_mcp_tools +from utils.agno_logging import configure_agno_to_use_loki + +ToolFactory = Callable[[Any], Any] + + +@dataclass +class AgentWrapper: + name: str + description: str + system_prompt: str + model_id: str + memory_config: Dict[str, Any] = field(default_factory=dict) + mcp_config: Optional[Dict[str, Any]] = None + tool_factories: List[ToolFactory] = field(default_factory=list) + markdown: bool = True + debug_mode: bool = False + telemetry: bool = False + model_kwargs: Dict[str, Any] = field(default_factory=dict) + db: Optional[Any] = None + user_id: Optional[str] = None + session_id: Optional[str] = None + resume_previous_session: bool = True + + async def create(self, logger) -> Tuple[Agent, Dict[str, Any]]: + configure_agno_to_use_loki(logger) + + local_tools = [factory(logger) for factory in self.tool_factories] + + mcp_tools: List[Any] = [] + active_mcp_servers: List[str] = [] + server_tool_map: Dict[str, List[str]] = {} + + if self.mcp_config: + mcp_config = dict(self.mcp_config) + try: + mcp_tools, active_mcp_servers = load_mcp_tools(mcp_config, logger=logger) + if mcp_tools: + server_tool_map = await initialize_mcp_tools(mcp_tools, logger=logger) + except MCPConfigError as error: + logger.error( + "🚨 Configuración MCP inválida", + add_fields={ + "error": str(error), + "agent_call": { + "action": "load_mcp_config", + "agent_name": self.name, + }, + "agent_response": {"status": "error", "error": str(error)}, + }, + ) + except Exception as error: + logger.error( + "💥 Error inesperado configurando MCP", + add_fields={ + "error": str(error), + "agent_call": { + "action": "load_mcp_config", + "agent_name": self.name, + }, + "agent_response": {"status": "error", "error": str(error)}, + }, + ) + raise + + if self.db: + logger.info( + "🗄️ Base de datos SQLite configurada para el agente", + add_fields={ + "agent_call": { + "action": "configure_memory", + "agent_name": self.name, + "db_file": getattr(self.db, "db_file", None), + }, + "agent_response": { + "status": "db_ready", + "resume_previous_session": self.resume_previous_session, + }, + }, + ) + + if self.resume_previous_session and not self.session_id and self.user_id: + start = perf_counter() + try: + sessions = self.db.get_sessions(user_id=self.user_id, session_type=SessionType.AGENT) + duration_ms = round((perf_counter() - start) * 1000, 3) + if sessions: + self.session_id = getattr(sessions[0], "session_id", None) + logger.info( + "📂 Sesión previa recuperada desde memoria persistente", + add_fields={ + "agent_call": { + "action": "restore_session", + "agent_name": self.name, + "user_id": self.user_id, + }, + "agent_response": { + "status": "session_found", + "session_id": self.session_id, + "sessions_encontradas": len(sessions), + "duration_ms": duration_ms, + }, + }, + ) + else: + logger.info( + "🔎 No se encontró sesión previa para el agente", + add_fields={ + "agent_call": { + "action": "restore_session", + "agent_name": self.name, + "user_id": self.user_id, + }, + "agent_response": { + "status": "session_not_found", + "duration_ms": duration_ms, + }, + }, + ) + except Exception as error: + duration_ms = round((perf_counter() - start) * 1000, 3) + logger.error( + "🚨 Error recuperando la sesión previa desde SQLite", + add_fields={ + "error": str(error), + "agent_call": { + "action": "restore_session", + "agent_name": self.name, + "user_id": self.user_id, + }, + "agent_response": { + "status": "error", + "duration_ms": duration_ms, + }, + }, + ) + else: + logger.warn( + "⚠️ Agente sin base de datos configurada; la memoria no se persistirá", + add_fields={ + "agent_call": { + "action": "configure_memory", + "agent_name": self.name, + }, + "agent_response": { + "status": "db_missing", + "add_history_to_context": self.memory_config.get("add_history_to_context", False), + }, + }, + ) + + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + logger.warn( + "⚠️ OPENAI_API_KEY no encontrado; el agente puede fallar al invocar el modelo", + add_fields={ + "agent_call": { + "action": "load_model_credentials", + "agent_name": self.name, + }, + "agent_response": {"status": "missing_credentials"}, + }, + ) + + agent = Agent( + model=OpenAIChat( + id=self.model_id, + api_key=api_key, + **self.model_kwargs, + ), + db=self.db, + user_id=self.user_id, + session_id=self.session_id, + tools=[*local_tools, *mcp_tools], + markdown=self.markdown, + name=self.name, + description=self.description, + system_message=self.system_prompt, + debug_mode=self.debug_mode, + telemetry=self.telemetry, + **self.memory_config, + ) + + logger.info( + "🤖 Agente configurado", + add_fields={ + "agent_call": { + "action": "create_agent", + "agent_name": self.name, + "local_tools": [tool.__name__ for tool in local_tools], + "mcp_servers": active_mcp_servers, + }, + "agent_response": { + "status": "created", + "tool_count": len(agent.tools) if agent.tools else 0, + }, + }, + ) + + logger.info( + "✅ Sesión del agente lista para usarse", + add_fields={ + "agent_call": { + "action": "session_ready", + "agent_name": self.name, + }, + "agent_response": { + "status": "ready", + "session_id": getattr(agent, "session_id", self.session_id), + "user_id": self.user_id, + "db_file": getattr(self.db, "db_file", None) if self.db else None, + }, + }, + ) + + context = { + "mcp_tools": mcp_tools, + "active_servers": active_mcp_servers, + "server_tool_map": server_tool_map, + "local_tools": [tool.__name__ for tool in local_tools], + "agent_name": self.name, + "db_file": getattr(self.db, "db_file", None) if self.db else None, + "user_id": self.user_id, + "session_id": getattr(agent, "session_id", self.session_id), + "resume_previous_session": self.resume_previous_session, + } + self.session_id = context["session_id"] + + return agent, context + + +def _wrapper_from_definition(definition: AgentDefinition) -> AgentWrapper: + return AgentWrapper( + name=definition.name, + description=definition.description, + system_prompt=definition.system_prompt, + model_id=definition.model_id, + memory_config=definition.memory_config, + mcp_config=definition.mcp_config, + tool_factories=definition.tool_factories, + markdown=definition.markdown, + debug_mode=definition.debug_mode, + telemetry=definition.telemetry, + model_kwargs=definition.model_kwargs, + db=definition.db, + user_id=definition.user_id, + session_id=definition.session_id, + resume_previous_session=definition.resume_previous_session, + ) + + +_AGENT_DEFINITIONS = [ + AgenteBasico(), +] + +DEFAULT_AGENT_NAME = _AGENT_DEFINITIONS[0].get_registry_key() + +AGENT_REGISTRY: Dict[str, AgentWrapper] = { + agent.get_registry_key(): _wrapper_from_definition(agent.build_definition()) + for agent in _AGENT_DEFINITIONS +} + + +def register_agent(key: str, wrapper: AgentWrapper) -> None: + AGENT_REGISTRY[key.lower()] = wrapper + + +def get_agent_wrapper(agent: Union[str, AgentWrapper, None]) -> Tuple[str, AgentWrapper]: + if isinstance(agent, AgentWrapper): + return agent.name.lower(), agent + agent_key = (agent or DEFAULT_AGENT_NAME).lower() + if agent_key not in AGENT_REGISTRY: + raise KeyError(f"No se encontró un agente registrado con la clave '{agent_key}'") + return agent_key, AGENT_REGISTRY[agent_key] + + +async def create_agent(logger, agent: Union[str, AgentWrapper, None] = None) -> Tuple[Agent, Dict[str, Any]]: + agent_key, wrapper = get_agent_wrapper(agent) + agent_instance, context = await wrapper.create(logger) + context.setdefault("agent_name", wrapper.name) + context.setdefault("agent_key", agent_key) + return agent_instance, context diff --git a/agentes/agente_kanboard.py b/agentes/agente_kanboard.py new file mode 100644 index 0000000..cf13588 --- /dev/null +++ b/agentes/agente_kanboard.py @@ -0,0 +1,168 @@ +"""Definición del agente básico de ejemplo.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from .base import AgenteBase, ToolFactory + +SYSTEM_PROMPT = ("""## 🧠 System Prompt — “Agente Kanboard” + +**Nombre del agente:** KAN +**Rol:** Asistente autónomo de productividad y coordinación de tareas en Kanboard. + +--- + +### 🎯 Objetivo principal + +Gestionar, organizar y optimizar las tareas de los proyectos en Kanboard. +Debe actuar como un gestor de proyectos proactivo, ayudando a mantener el flujo de trabajo limpio, priorizado y actualizado. + +--- + +### 💼 Responsabilidades + +1. **Gestión de tareas** + + * Crear, actualizar, mover y cerrar tareas según el estado de avance. + * Asignar responsables, etiquetas y fechas límite con base en contexto o patrones previos. + * Detectar tareas duplicadas o bloqueadas y sugerir acciones correctivas. + +2. **Priorización inteligente** + + * Ordenar tareas usando criterios de impacto, urgencia y dependencias. + * Recalcular prioridades automáticamente si cambian fechas o recursos. + +3. **Contexto y comunicación** + + * Resumir el estado actual de cada proyecto. + * Generar reportes diarios o semanales con progreso, riesgos y tareas pendientes. + * Explicar cambios recientes en el tablero (por ejemplo: “se movieron 3 tareas a *Done*”). + +4. **Automatización de flujo** + + * Mover tareas automáticamente al siguiente estado según condiciones predefinidas (por ejemplo: “si los tests pasan, mover a *Ready for Review*”). + * Crear subtareas recurrentes o tareas de seguimiento. + +--- + +### 🧩 Entradas esperadas + +El agente debe ser capaz de interpretar comandos naturales como: + +* “Crea una tarea para configurar el pipeline de ingestión en Kafka.” +* “Muévela a *Doing* y asígnamela.” +* “Resúmeme el tablero del proyecto *Data Lake*.” +* “Prioriza las tareas de optimización antes que las de documentación.” +* “Muéstrame las tareas bloqueadas desde hace más de 3 días.” + +--- + +### 🔍 Integraciones + +* **Kanboard API:** CRUD de tareas, columnas, etiquetas, usuarios y comentarios. +* **GitHub / GitLab:** Referencias automáticas a commits o PRs relacionados. +* **Slack / Email:** Notificaciones opcionales sobre cambios importantes. +* **Calendario / Notion (opcional):** Sincronización de deadlines y notas de planificación. + +--- + +### 🧠 Personalidad y estilo + +* **Tono:** Profesional, directo, pero con iniciativa. +* **Comunicación:** Clara, contextual, priorizando acciones útiles. +* **Criterio:** Detecta inconsistencias y las menciona sin esperar instrucción. + (Ej: “La tarea ‘Configurar ETL’ lleva 10 días en *Doing*, ¿quieres revisarla?”) + +--- + +### ⚙️ Reglas de operación + +1. No crea ni elimina proyectos sin confirmación explícita. +2. Puede sugerir cambios automáticos, pero siempre explica el motivo. +3. Mantiene trazabilidad de todas las acciones. +4. Respeta el esquema de columnas estándar: + + * **Backlog → Ready → Doing → Review → Done** +5. Si un comando es ambiguo, pregunta antes de actuar. +6. Todas las respuestas deben incluir **acciones sugeridas o contexto útil** (no solo datos). + +--- + +### 📈 Objetivo secundario + +Aprender del comportamiento del usuario (por ejemplo, patrones de asignación o tiempos de ciclo) para **recomendar automatizaciones personalizadas**, como: + +* Recordatorios automáticos. +* Repriorización dinámica. +* Creación de plantillas de tareas repetitivas. + + +""" + +) + +DEFAULT_MEMORY_CONFIG: Dict[str, Any] = { + "add_history_to_context": True, + "num_history_messages": 10, + "store_history_messages": True, +} + +DEFAULT_MCP_CONFIG: Dict[str, Any] = { + "mcpServers": { + "time": { + "command": "/home/lucas/DataProyects/kanboard/.venv/bin/python", + "args": ["/home/lucas/DataProyects/kanboard/kanboard_mcp.py"], + }, + } +} + + +# def _create_multiplicar_tool(logger) -> ToolFactory: +# def multiplicar(a: float, b: float) -> float: +# tool_call_data = { +# "tool_name": "multiplicar", +# "params": {"a": a, "b": b}, +# "action": "multiply_numbers", +# } +# logger.info( +# "🛠️ Tool llamada: multiplicar", +# add_fields={"agent_call": tool_call_data}, +# ) + +# resultado = a * b + +# tool_response_data = { +# "tool_name": "multiplicar", +# "response": resultado, +# "success": True, +# "operation": f"{a} × {b} = {resultado}", +# } +# logger.info( +# "✅ Tool respuesta: multiplicar completada exitosamente", +# add_fields={"agent_response": tool_response_data}, +# ) + +# return resultado + +# multiplicar.__name__ = "multiplicar" +# return multiplicar + + +class AgenteBasico(AgenteBase): + key = "kan" + name = "Kan" + description = "Agente de tareas para gestionar mi tablero kanboard" + system_prompt = SYSTEM_PROMPT + model_id = "gpt-4o" + markdown = True + debug_mode = True + telemetry = False + + def get_memory_config(self) -> Dict[str, Any]: + return dict(DEFAULT_MEMORY_CONFIG) + + def get_mcp_config(self) -> Dict[str, Any]: + return dict(DEFAULT_MCP_CONFIG) + + # def get_tool_factories(self) -> List[ToolFactory]: + # return [_create_multiplicar_tool] diff --git a/agentes/base.py b/agentes/base.py new file mode 100644 index 0000000..379020c --- /dev/null +++ b/agentes/base.py @@ -0,0 +1,118 @@ +"""Clases base para definir agentes del proyecto.""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from agno.db.sqlite import SqliteDb + +logger = logging.getLogger(__name__) + +ToolFactory = Callable[[Any], Any] + + +@dataclass +class AgentDefinition: + """Contenedor ligero con los parámetros para construir un AgentWrapper.""" + + name: str + description: str + system_prompt: str + model_id: str + memory_config: Dict[str, Any] + mcp_config: Optional[Dict[str, Any]] + tool_factories: List[ToolFactory] + markdown: bool + debug_mode: bool + telemetry: bool + model_kwargs: Dict[str, Any] + db: Optional[SqliteDb] + user_id: Optional[str] + session_id: Optional[str] + resume_previous_session: bool + + +class AgenteBase: + """Proporciona valores por defecto y utilidades para los agentes concretos.""" + + key: str = "base" + name: str = "Agente base" + description: str = "Agente genérico." + system_prompt: str = "" + model_id: str = "gpt-4o" + markdown: bool = True + debug_mode: bool = False + telemetry: bool = False + memory_config: Dict[str, Any] = {} + mcp_config: Optional[Dict[str, Any]] = None + model_kwargs: Dict[str, Any] = {} + db_path: Optional[str] = "memoria/conversaciones.db" + user_id: Optional[str] = None + resume_previous_session: bool = True + + def get_registry_key(self) -> str: + """Clave utilizada en el registro global de agentes.""" + return self.key.lower() + + def get_memory_config(self) -> Dict[str, Any]: + return dict(self.memory_config) if self.memory_config else {} + + def get_mcp_config(self) -> Optional[Dict[str, Any]]: + if not self.mcp_config: + return None + return {**self.mcp_config} + + def get_tool_factories(self) -> List[ToolFactory]: + return [] + + def get_model_kwargs(self) -> Dict[str, Any]: + return dict(self.model_kwargs) if self.model_kwargs else {} + + def _resolve_db_path(self) -> Optional[Path]: + if not self.db_path: + return None + db_path = Path(self.db_path) + if not db_path.is_absolute(): + project_root = Path(__file__).resolve().parent.parent + db_path = project_root / db_path + try: + db_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as error: + logger.warning( + "⚠️ No se pudo crear el directorio para la base de datos SQLite", + extra={"db_directory": str(db_path.parent), "error": str(error)}, + ) + return None + return db_path + + def get_database(self) -> Optional[SqliteDb]: + db_path = self._resolve_db_path() + if not db_path: + return None + return SqliteDb(db_file=str(db_path)) + + def get_user_id(self) -> str: + return self.user_id or self.get_registry_key() + + def build_definition(self) -> AgentDefinition: + """Genera los parámetros necesarios para instanciar un AgentWrapper.""" + db = self.get_database() + return AgentDefinition( + name=self.name, + description=self.description, + system_prompt=self.system_prompt, + model_id=self.model_id, + memory_config=self.get_memory_config(), + mcp_config=self.get_mcp_config(), + tool_factories=list(self.get_tool_factories()), + markdown=self.markdown, + debug_mode=self.debug_mode, + telemetry=self.telemetry, + model_kwargs=self.get_model_kwargs(), + db=db, + user_id=self.get_user_id(), + session_id=None, + resume_previous_session=self.resume_previous_session, + ) diff --git a/ejecucion_cli.py b/ejecucion_cli.py new file mode 100644 index 0000000..f4f76e0 --- /dev/null +++ b/ejecucion_cli.py @@ -0,0 +1,249 @@ +"""CLI interactiva para conversar con los agentes registrados.""" +from __future__ import annotations + +import asyncio +from pprint import pformat +from typing import Any, Dict, List + +from dotenv import load_dotenv + +from agentes import AGENT_REGISTRY, create_agent +from LokiLogger import LokiLogger +from mcp_wrapper import close_mcp_tools + +# Logger reutilizado en toda la sesión +logger = LokiLogger( + service_name="agente_kanboard", + add_labels={"env": "local", "agent": "CLI"}, +) + + +def _make_json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {k: _make_json_safe(v) for k, v in value.items()} + if isinstance(value, list): + return [_make_json_safe(v) for v in value] + if hasattr(value, "to_dict"): + return _make_json_safe(value.to_dict()) + if hasattr(value, "model_dump"): + return _make_json_safe(value.model_dump()) + if hasattr(value, "dict"): + return _make_json_safe(value.dict()) + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _pretty_print(title: str, payload: Any) -> None: + safe_payload = _make_json_safe(payload) + print(f"\n{title}") + print("=" * len(title)) + print(pformat(safe_payload, width=100, compact=False, sort_dicts=False)) + + +def _announce_session(context: Dict[str, Any]) -> None: + summary = { + "agente": context.get("agent_name"), + "usuario": context.get("user_id"), + "sesion": context.get("session_id"), + "herramientas_locales": context.get("local_tools", []), + "servidores_mcp_activos": context.get("active_servers", []), + "herramientas_mcp": context.get("server_tool_map", {}), + "base_datos": context.get("db_file"), + "reanudar_memoria": context.get("resume_previous_session"), + } + + print("🚀 Sesión iniciada") + _pretty_print("Contexto inicial", summary) + + if context.get("db_file"): + print(f"\n🗄️ Memoria persistente: {context['db_file']}") + + if context.get("active_servers"): + print("\n🔗 MCP habilitado. Servidores activos detectados.") + else: + print("\n⚠️ MCP no tiene servidores activos configurados.") + + print("\nEscribe 'salir' para terminar la conversación.") + + +def _render_response(response: Any) -> None: + if not response: + print("⚠️ No se obtuvo respuesta del agente.") + return + + agent_response_fields = { + "run_id": response.run_id, + "agent_id": response.agent_id, + "session_id": response.session_id, + "model": response.model, + "model_provider": response.model_provider, + "status": str(response.status), + "metrics": { + "input_tokens": response.metrics.input_tokens if response.metrics else 0, + "output_tokens": response.metrics.output_tokens if response.metrics else 0, + "total_tokens": response.metrics.total_tokens if response.metrics else 0, + "duration": response.metrics.duration if response.metrics else 0, + "time_to_first_token": response.metrics.time_to_first_token if response.metrics else 0, + }, + "tools_invocadas": [tool.tool_name for tool in response.tools or []], + } + + _pretty_print("📊 Métricas de la respuesta", agent_response_fields) + + if response.tools: + herramientas = [] + for tool_call in response.tools: + herramientas.append( + { + "tool_call_id": tool_call.tool_call_id, + "tool_name": tool_call.tool_name, + "tool_args": _make_json_safe(tool_call.tool_args), + "result": _make_json_safe(tool_call.result), + "error": tool_call.tool_call_error, + "metrics": ( + tool_call.metrics.to_dict() if getattr(tool_call, "metrics", None) else None + ), + } + ) + _pretty_print("🧰 Herramientas invocadas", herramientas) + else: + print("\nℹ️ El modelo no invocó herramientas durante la respuesta.") + + if response.content: + print("\n💬 Respuesta del agente") + print("----------------------") + print(response.content) + else: + print("\n💬 Respuesta del agente: (vacía)") + + +async def ejecutar_cli() -> None: + load_dotenv() + logger.info("🚀 Iniciando sesión interactiva del agente") + + agent_keys = list(AGENT_REGISTRY.keys()) + if not agent_keys: + print("\n❌ No hay agentes registrados disponibles.") + logger.error( + "🚫 No hay agentes registrados para iniciar la sesión", + add_fields={"agent_call": {"action": "list_agents"}, "agent_response": {"status": "empty_registry"}}, + ) + return + + print("\n🤖 Agentes disponibles:") + print("=======================") + name_to_key = {} + for idx, key in enumerate(agent_keys, start=1): + wrapper = AGENT_REGISTRY[key] + name_to_key[wrapper.name.lower()] = key + print(f"[{idx}] {wrapper.name} → {wrapper.description}") + + selected_key = None + while not selected_key: + try: + choice = input("\nSelecciona un agente (número o nombre): ").strip().lower() + except KeyboardInterrupt: + print("\n\n⛔ Selección interrumpida por el usuario.") + logger.info( + "⛔ Selección de agente interrumpida por Ctrl+C", + add_fields={ + "agent_call": {"action": "select_agent", "agent_options": agent_keys}, + "agent_response": {"status": "interrupted"}, + }, + ) + return + if not choice: + print("❌ Entrada vacía. Intenta nuevamente.") + continue + + if choice.isdigit(): + index = int(choice) - 1 + if 0 <= index < len(agent_keys): + selected_key = agent_keys[index] + else: + print("❌ Número inválido. Intenta de nuevo.") + continue + elif choice in agent_keys: + selected_key = choice + elif choice in name_to_key: + selected_key = name_to_key[choice] + else: + print("❌ Agente no encontrado. Intenta nuevamente.") + continue + + selected_wrapper = AGENT_REGISTRY[selected_key] + print(f"\n✅ Agente seleccionado: {selected_wrapper.name}\n") + logger.info( + "🤖 Agente seleccionado para la sesión", + add_fields={ + "agent_call": {"action": "select_agent", "agent_options": agent_keys}, + "agent_response": {"status": "agent_selected", "agent_key": selected_key, "agent_name": selected_wrapper.name}, + }, + ) + + try: + agent, context = await create_agent(logger, agent=selected_key) + except Exception as error: # pragma: no cover - salida controlada + logger.exception(error) + print(f"No se pudo crear el agente: {error}") + return + + mcp_tools: List[Any] = context.get("mcp_tools", []) + _announce_session(context) + + try: + while True: + user_input = (await asyncio.to_thread(input, "\nTú → ")).strip() + if not user_input: + continue + + if user_input.lower() in {"salir", "exit", "quit"}: + logger.info( + "👋 Chat finalizado por el usuario", + add_fields={ + "agent_call": {"action": "end_chat"}, + "agent_response": {"status": "ended_by_user"}, + }, + ) + print("\n👋 Sesión finalizada por el usuario.") + break + + logger.info( + "📨 Mensaje recibido del usuario", + add_fields={ + "agent_call": { + "action": "user_message", + "content": user_input, + } + }, + ) + + try: + response = await agent.arun(user_input) + except Exception as error: # pragma: no cover - salida controlada + logger.exception(error) + print(f"Error al procesar la consulta: {error}") + continue + + _render_response(response) + + except KeyboardInterrupt: # pragma: no cover - salida controlada + logger.info( + "⛔ Chat interrumpido por el usuario", + add_fields={ + "agent_response": {"status": "interrupted"}, + }, + ) + print("\n⛔ Sesión interrumpida manualmente.") + finally: + await close_mcp_tools(mcp_tools, logger=logger) + logger.info("✅ Sesión del agente finalizada") + + +def main() -> None: + asyncio.run(ejecutar_cli()) + + +if __name__ == "__main__": # pragma: no cover - ejecución directa + main() diff --git a/ejecucion_telegram.py b/ejecucion_telegram.py new file mode 100644 index 0000000..13a2cd3 --- /dev/null +++ b/ejecucion_telegram.py @@ -0,0 +1,359 @@ +"""Bot de Telegram para conversar con el agente Kanboard.""" +from __future__ import annotations + +import asyncio +import os +import json +from contextlib import suppress +from typing import Any, Dict, List, Optional + +import requests +from dotenv import load_dotenv + +from agentes import DEFAULT_AGENT_NAME, create_agent +from LokiLogger import LokiLogger +from mcp_wrapper import close_mcp_tools + +TELEGRAM_API_BASE = "https://api.telegram.org" +MAX_MESSAGE_LENGTH = 3800 # margen para markdown/formatting + + +def _parse_int(value: Optional[str], *, env_name: str) -> Optional[int]: + if value is None or value.strip() == "": + return None + try: + return int(value) + except ValueError as error: # pragma: no cover - salida controlada + raise ValueError(f"{env_name} debe ser un entero válido, se recibió: {value!r}") from error + + +class TelegramKanBot: + def __init__( + self, + *, + token: str, + allowed_user_id: int, + allowed_chat_id: int, + agent_key: str, + logger: LokiLogger, + ) -> None: + self.token = token + self.allowed_user_id = allowed_user_id + self.allowed_chat_id = allowed_chat_id + self.agent_key = agent_key + self.logger = logger + self.api_timeout = 30 + self.http_timeout = 35 + self.idle_sleep = 1 + self.offset: Optional[int] = None + self.base_url = f"{TELEGRAM_API_BASE}/bot{token}" + self.session = requests.Session() + + self.agent = None + self.context: Dict[str, Any] = {} + + async def setup_agent(self) -> None: + agent, context = await create_agent(self.logger, agent=self.agent_key) + self.agent = agent + self.context = context + self.logger.info( + "🤖 Agente Kan para Telegram listo", + add_fields={ + "agent_call": {"action": "telegram_agent_ready", "agent": self.agent_key}, + "agent_response": {"status": "ready", "allowed_chat": self.allowed_chat_id}, + }, + ) + + async def close(self) -> None: + if self.context.get("mcp_tools"): + await close_mcp_tools(self.context["mcp_tools"], logger=self.logger) + self.session.close() + + async def run(self) -> None: + if self.agent is None: + await self.setup_agent() + + self.logger.info( + "🚀 Escuchando mensajes de Telegram", + add_fields={ + "agent_call": {"action": "telegram_poll"}, + "agent_response": { + "status": "listening", + "chat_id": self.allowed_chat_id, + "user_id": self.allowed_user_id, + }, + }, + ) + + try: + while True: + updates = await self._fetch_updates() + for update in updates: + self.offset = update["update_id"] + 1 + await self._handle_update(update) + await asyncio.sleep(self.idle_sleep) + except asyncio.CancelledError: # pragma: no cover - interrupción controlada + raise + except Exception as error: + self.logger.exception(error) + raise + + async def _fetch_updates(self) -> List[Dict[str, Any]]: + params = { + "timeout": self.api_timeout, + "allowed_updates": json.dumps(["message", "edited_message"]), + } + if self.offset is not None: + params["offset"] = self.offset + try: + response = await asyncio.to_thread( + self.session.get, + f"{self.base_url}/getUpdates", + params=params, + timeout=self.http_timeout, + ) + response.raise_for_status() + data = response.json() + except Exception as error: # pragma: no cover - manejo defensivo + self.logger.exception( + error, + add_fields={ + "agent_call": {"action": "telegram_get_updates"}, + "agent_response": {"status": "error"}, + }, + ) + await asyncio.sleep(5) + return [] + + if not data.get("ok"): + self.logger.error( + "La API de Telegram devolvió un estado no-ok", + add_fields={ + "agent_call": {"action": "telegram_get_updates"}, + "agent_response": {"status": data.get("ok"), "description": data.get("description")}, + }, + ) + return [] + + result = data.get("result", []) + if result: + self.logger.debug( + "📩 Nuevos updates recibidos", + add_fields={ + "agent_call": {"action": "telegram_get_updates"}, + "agent_response": {"status": "updates", "count": len(result)}, + }, + ) + return result + + async def _handle_update(self, update: Dict[str, Any]) -> None: + message = update.get("message") or update.get("edited_message") + if not message: + return + + chat = message.get("chat", {}) + chat_id = chat.get("id") + user = message.get("from", {}) + user_id = user.get("id") + + if chat_id != self.allowed_chat_id or user_id != self.allowed_user_id: + self.logger.warn( + "Mensaje ignorado por no cumplir filtros", + add_fields={ + "agent_call": {"action": "telegram_filter_message"}, + "agent_response": {"status": "ignored", "chat_id": chat_id, "user_id": user_id}, + }, + ) + return + + text = message.get("text") or "" + if not text: + await self._send_message(chat_id, "Solo puedo procesar mensajes de texto por ahora.") + return + + if text.strip().lower() in {"/start", "hola", "hi"}: + await self._send_message(chat_id, "¡Hola! Soy KAN, listo para ayudarte con Kanboard.") + return + + await self._process_message(chat_id, text) + + async def _process_message(self, chat_id: int, text: str) -> None: + self.logger.info( + "📨 Mensaje recibido desde Telegram", + add_fields={ + "agent_call": {"action": "telegram_user_message", "content": text}, + "agent_response": {"status": "processing"}, + }, + ) + typing_task = asyncio.create_task(self._typing_indicator(chat_id)) + try: + response = await self.agent.arun(text) + except Exception as error: # pragma: no cover - manejo defensivo + self.logger.exception( + error, + add_fields={ + "agent_call": {"action": "agent_run", "content": text}, + "agent_response": {"status": "error"}, + }, + ) + await self._send_message(chat_id, "Hubo un error procesando tu mensaje. Revisa los logs para más detalles.") + return + finally: + typing_task.cancel() + with suppress(asyncio.CancelledError): + await typing_task + + content = (response.content or "(respuesta vacía)").strip() + if not content: + content = "(respuesta vacía)" + + await self._send_message(chat_id, content) + + if response.metrics: + self.logger.info( + "📊 Métricas de ejecución", + add_fields={ + "agent_call": {"action": "agent_run_metrics"}, + "agent_response": { + "status": "completed", + "metrics": { + "input_tokens": response.metrics.input_tokens, + "output_tokens": response.metrics.output_tokens, + "total_tokens": response.metrics.total_tokens, + "duration_seconds": response.metrics.duration, + }, + }, + }, + ) + + async def _send_message(self, chat_id: int, text: str) -> None: + for chunk in self._chunk_text(text): + payload = {"chat_id": chat_id, "text": chunk} + try: + response = await asyncio.to_thread( + self.session.post, + f"{self.base_url}/sendMessage", + json=payload, + timeout=self.http_timeout, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + self.logger.error( + "Telegram rechazó el mensaje", + add_fields={ + "agent_call": {"action": "telegram_send_message"}, + "agent_response": {"status": data.get("ok"), "description": data.get("description")}, + }, + ) + except Exception as error: # pragma: no cover - manejo defensivo + self.logger.exception( + error, + add_fields={ + "agent_call": {"action": "telegram_send_message", "payload": payload}, + "agent_response": {"status": "error"}, + }, + ) + + async def _typing_indicator(self, chat_id: int) -> None: + try: + while True: + await self._send_chat_action(chat_id, "typing") + await asyncio.sleep(4) + except asyncio.CancelledError: # pragma: no cover - interrupción esperada + raise + + async def _send_chat_action(self, chat_id: int, action: str) -> None: + payload = {"chat_id": chat_id, "action": action} + try: + response = await asyncio.to_thread( + self.session.post, + f"{self.base_url}/sendChatAction", + json=payload, + timeout=self.http_timeout, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + self.logger.error( + "Telegram rechazó la acción del chat", + add_fields={ + "agent_call": {"action": "telegram_send_chat_action"}, + "agent_response": {"status": data.get("ok"), "description": data.get("description")}, + }, + ) + except Exception as error: # pragma: no cover - manejo defensivo + self.logger.exception( + error, + add_fields={ + "agent_call": {"action": "telegram_send_chat_action", "payload": payload}, + "agent_response": {"status": "error"}, + }, + ) + + def _chunk_text(self, text: str) -> List[str]: + if len(text) <= MAX_MESSAGE_LENGTH: + return [text] + + chunks: List[str] = [] + current = [] + current_len = 0 + for paragraph in text.split("\n\n"): + paragraph = paragraph.strip() + if not paragraph: + continue + paragraph_len = len(paragraph) + 2 # margen para doble salto + if current_len + paragraph_len > MAX_MESSAGE_LENGTH and current: + chunks.append("\n\n".join(current)) + current = [paragraph] + current_len = len(paragraph) + elif paragraph_len > MAX_MESSAGE_LENGTH: + chunks.extend([paragraph[i:i + MAX_MESSAGE_LENGTH] for i in range(0, len(paragraph), MAX_MESSAGE_LENGTH)]) + current = [] + current_len = 0 + else: + current.append(paragraph) + current_len += paragraph_len + if current: + chunks.append("\n\n".join(current)) + return chunks + + +async def run_bot() -> None: + load_dotenv() + + token = os.getenv("TELEGRAM_BOT_TOKEN") + allowed_user_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_USER_ID"), env_name="TELEGRAM_ALLOWED_USER_ID") + allowed_chat_id = _parse_int(os.getenv("TELEGRAM_ALLOWED_CHAT_ID"), env_name="TELEGRAM_ALLOWED_CHAT_ID") + agent_key = (os.getenv("TELEGRAM_AGENT_KEY") or DEFAULT_AGENT_NAME).lower() + + if not token: + raise RuntimeError("TELEGRAM_BOT_TOKEN debe definirse en el entorno") + if allowed_user_id is None or allowed_chat_id is None: + raise RuntimeError("TELEGRAM_ALLOWED_USER_ID y TELEGRAM_ALLOWED_CHAT_ID son obligatorios") + + logger = LokiLogger( + service_name="agente_kanboard", + add_labels={"env": "local", "agent": "telegram"}, + ) + + bot = TelegramKanBot( + token=token, + allowed_user_id=allowed_user_id, + allowed_chat_id=allowed_chat_id, + agent_key=agent_key, + logger=logger, + ) + + try: + await bot.run() + finally: + await bot.close() + + +def main() -> None: + asyncio.run(run_bot()) + + +if __name__ == "__main__": # pragma: no cover - ejecución directa + main() diff --git a/kanboard_mcp.py b/kanboard_mcp.py index c0e3570..827359c 100644 --- a/kanboard_mcp.py +++ b/kanboard_mcp.py @@ -1,6 +1,6 @@ # kanboard_mcp.py """ -Servidor FastMCP para controlar Kanboard desde un LLM. +🚀 Servidor FastMCP para controlar Kanboard desde un LLM. Este módulo expone las funciones del paquete `kanboard_utils` como herramientas del protocolo MCP (Model Context Protocol), @@ -8,9 +8,10 @@ permitiendo a modelos de lenguaje (LLMs) listar, crear, mover y editar tareas en Kanboard de forma segura y programática. Requisitos: - uv add fastmcp requests + uv add fastmcp requests python-dotenv """ +import os from fastmcp import FastMCP from kanboard_utils import ( listar_proyectos, @@ -23,113 +24,107 @@ from kanboard_utils import ( editar_tarea, ) -# Instancia principal del servidor MCP -mcp = FastMCP("Kanboard Controller", instructions=""" -Controlador MCP para Kanboard que permite a un LLM gestionar tareas, -proyectos y usuarios de un tablero Kanboard mediante JSON-RPC. -""") +# ======================== +# ⚙️ Configuración global +# ======================== +# Carga las variables de entorno automáticamente +# (útil si usas un archivo .env con los valores) +from dotenv import load_dotenv +load_dotenv() -# === Herramientas expuestas === +# Lee las credenciales de Kanboard desde variables de entorno +API_URL = os.getenv("KANBOARD_API_URL", "http://localhost:8080/jsonrpc.php") +USER = os.getenv("KANBOARD_USER", "jsonrpc") +TOKEN = os.getenv("KANBOARD_TOKEN", "792d8fdd6cbf69b3a32d800beaf48b958e4490dd9185c72c06c56061a591") + +# ======================== +# 🧠 Inicializa el servidor +# ======================== +mcp = FastMCP( + "Kanboard Controller", + instructions=""" + Controlador MCP para Kanboard que permite a un LLM gestionar tareas, + proyectos y usuarios de un tablero Kanboard mediante JSON-RPC. + """ +) + +# ===================================================== +# 🧩 Herramientas disponibles para el agente LLM +# ===================================================== @mcp.tool -def list_projects(api_url: str, usuario: str, token: str) -> list[dict]: - """ - 📁 Listar todos los proyectos de Kanboard. - - Permite recuperar todos los proyectos accesibles para un usuario. - Ideal para que el modelo entienda qué tableros existen antes de operar. - - Parámetros: - api_url (str): URL del endpoint JSON-RPC, por ejemplo http://localhost:8080/jsonrpc.php - usuario (str): Nombre de usuario con permisos API - token (str): Token de autenticación - - Retorna: - list[dict]: Proyectos con campos como 'id', 'name', 'identifier', etc. - """ - return listar_proyectos(api_url, usuario, token) +def list_projects() -> list[dict]: + """📁 Listar todos los proyectos de Kanboard.""" + return listar_proyectos(API_URL, USER, TOKEN) @mcp.tool -def list_users(api_url: str, usuario: str, token: str) -> list[dict]: - """ - 👥 Listar todos los usuarios disponibles en Kanboard. +def list_users() -> list[dict]: + """👥 Listar todos los usuarios disponibles en Kanboard.""" + return listar_usuarios(API_URL, USER, TOKEN) - Devuelve la lista de usuarios con sus IDs, nombres y nombres de usuario. - """ - return listar_usuarios(api_url, usuario, token) @mcp.tool -def list_tasks(api_url: str, usuario: str, token: str, project_id: int) -> list[dict]: +def list_tasks(project_id: int) -> list[str]: """ - 🗂️ Listar todas las tareas de un proyecto. + 🗂️ Listar todas las tareas de un proyecto (solo nombres). - Permite a un LLM inspeccionar las tareas dentro de un proyecto específico. + Devuelve una lista simple de títulos de tareas, sin metadatos adicionales. + Esto simplifica el contexto para modelos de lenguaje. """ - return listar_tareas(api_url, usuario, token, project_id) + tareas = listar_tareas(API_URL, USER, TOKEN, project_id) + + # 🧹 Extrae solo los títulos (ignorando otros campos) + nombres = [t["title"] for t in tareas if "title" in t] + + # 🔢 Devuelve lista simple de strings + return nombres @mcp.tool -def list_subtasks(api_url: str, usuario: str, token: str, task_id: int) -> list[dict]: - """ - 📋 Listar las subtareas de una tarea específica. - - Útil para analizar el desglose de una tarea compleja. - """ - return listar_subtareas(api_url, usuario, token, task_id) +def list_subtasks(task_id: int) -> list[dict]: + """📋 Listar las subtareas de una tarea específica.""" + return listar_subtareas(API_URL, USER, TOKEN, task_id) @mcp.tool -def list_tasks_by_column(api_url: str, usuario: str, token: str, project_id: int) -> dict: - """ - 🧩 Agrupa las tareas por columna del tablero Kanboard. - - Permite al modelo entender la estructura actual del flujo de trabajo. - """ - return listar_tareas_por_columna(api_url, usuario, token, project_id) +def list_tasks_by_column(project_id: int) -> dict: + """🧩 Agrupar las tareas por columna del tablero Kanboard.""" + return listar_tareas_por_columna(API_URL, USER, TOKEN, project_id) @mcp.tool -def move_task(api_url: str, usuario: str, token: str, project_id: int, - task_id: int, column_id: int, position: int = 1, swimlane_id: int = 0) -> bool: - """ - 🔄 Mover una tarea entre columnas del tablero. - - Permite cambiar el estado de una tarea dentro del flujo de trabajo (por ejemplo, de "To Do" a "En progreso"). - """ - return mover_tarea_columna(api_url, usuario, token, project_id, task_id, column_id, position, swimlane_id) +def move_task(project_id: int, task_id: int, column_id: int, + position: int = 1, swimlane_id: int = 0) -> bool: + """🔄 Mover una tarea entre columnas del tablero.""" + return mover_tarea_columna(API_URL, USER, TOKEN, project_id, task_id, column_id, position, swimlane_id) @mcp.tool -def create_task(api_url: str, usuario: str, token: str, project_id: int, titulo: str, - descripcion: str = "", swimlane_id: int | None = None, assignee_id: int | None = None, +def create_task(project_id: int, titulo: str, descripcion: str = "", + swimlane_id: int | None = None, assignee_id: int | None = None, tags: list[str] | None = None, priority: int | None = None) -> dict: - """ - ➕ Crear una nueva tarea en un proyecto Kanboard. - - Ideal para automatizar la creación de tickets desde prompts naturales. - """ - return crear_tarea(api_url, usuario, token, project_id, titulo, descripcion, + """➕ Crear una nueva tarea en un proyecto Kanboard.""" + return crear_tarea(API_URL, USER, TOKEN, project_id, titulo, descripcion, swimlane_id, assignee_id, tags, priority) @mcp.tool -def edit_task(api_url: str, usuario: str, token: str, task_id: int, - titulo: str | None = None, descripcion: str | None = None, +def edit_task(task_id: int, titulo: str | None = None, descripcion: str | None = None, swimlane_id: int | None = None, assignee_id: int | None = None, tags: list[str] | None = None, priority: int | None = None) -> dict: - """ - ✏️ Editar una tarea existente en Kanboard. - - Actualiza campos específicos sin alterar los demás. - Permite editar título, descripción, prioridad o etiquetas. - """ - return editar_tarea(api_url, usuario, token, task_id, titulo, descripcion, + """✏️ Editar una tarea existente en Kanboard.""" + return editar_tarea(API_URL, USER, TOKEN, task_id, titulo, descripcion, swimlane_id, assignee_id, tags, priority) -# === Punto de entrada del servidor === +# ===================================================== +# 🚀 Punto de entrada principal del servidor FastMCP +# ===================================================== if __name__ == "__main__": + print("✅ Kanboard MCP listo y conectado con:") + print(f" 🌐 API_URL: {API_URL}") + print(f" 👤 Usuario: {USER}") mcp.run() diff --git a/mcp_wrapper.py b/mcp_wrapper.py new file mode 100644 index 0000000..97bd55a --- /dev/null +++ b/mcp_wrapper.py @@ -0,0 +1,358 @@ +"""Helpers to build and manage MCPTools instances from JSON configuration.""" + +from __future__ import annotations + +import json +import shlex +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + +from agno.tools.mcp import MCPTools + + +class MCPConfigError(ValueError): + """Raised when the provided MCP configuration is invalid.""" + + +@dataclass +class MCPServerDefinition: + """Normalized representation of an MCP server entry.""" + + name: str + command: Optional[str] = None + args: List[str] = field(default_factory=list) + transport: str = "stdio" + url: Optional[str] = None + env: Dict[str, str] = field(default_factory=dict) + include_tools: Optional[List[str]] = None + exclude_tools: Optional[List[str]] = None + timeout_seconds: Optional[int] = None + refresh_connection: Optional[bool] = None + enabled: bool = True + + @classmethod + def from_dict(cls, name: str, data: Dict[str, Any]) -> "MCPServerDefinition": + if not isinstance(data, dict): + raise MCPConfigError( + f"Configuration for MCP server '{name}' must be a JSON object." + ) + + command = data.get("command") + args = data.get("args", []) + transport = data.get("transport", "stdio") + + if args is None: + args = [] + if not isinstance(args, list): + raise MCPConfigError( + f"The 'args' field for MCP server '{name}' must be an array of strings." + ) + args = [str(arg) for arg in args] + + env = data.get("env") or {} + if not isinstance(env, dict): + raise MCPConfigError( + f"The 'env' field for MCP server '{name}' must be an object of key/value pairs." + ) + env = {str(key): str(value) for key, value in env.items()} + + include_tools = data.get("include_tools") + if include_tools is not None and not isinstance(include_tools, list): + raise MCPConfigError( + f"The 'include_tools' field for MCP server '{name}' must be an array." + ) + + exclude_tools = data.get("exclude_tools") + if exclude_tools is not None and not isinstance(exclude_tools, list): + raise MCPConfigError( + f"The 'exclude_tools' field for MCP server '{name}' must be an array." + ) + + timeout_seconds = data.get("timeout_seconds") + if timeout_seconds is not None: + timeout_seconds = int(timeout_seconds) + + refresh_connection = data.get("refresh_connection") + if refresh_connection is not None: + refresh_connection = bool(refresh_connection) + + enabled = bool(data.get("enabled", True)) + + return cls( + name=name, + command=command, + args=args, + transport=str(transport or "stdio"), + url=data.get("url"), + env=env, + include_tools=[str(item) for item in include_tools] if include_tools else None, + exclude_tools=[str(item) for item in exclude_tools] if exclude_tools else None, + timeout_seconds=timeout_seconds, + refresh_connection=refresh_connection, + enabled=enabled, + ) + + def to_kwargs(self) -> Dict[str, Any]: + kwargs: Dict[str, Any] = {"transport": self.transport or "stdio"} + + if self.command: + command_parts = [self.command, *self.args] + # MCPTools accepts the entire command as a string; shlex avoids shell injection. + kwargs["command"] = shlex.join([str(part) for part in command_parts if part]) + elif (self.transport or "stdio") == "stdio": + raise MCPConfigError( + f"MCP server '{self.name}' must define a 'command' when using stdio transport." + ) + + if self.url: + kwargs["url"] = self.url + if self.env: + kwargs["env"] = self.env + if self.include_tools is not None: + kwargs["include_tools"] = self.include_tools + if self.exclude_tools is not None: + kwargs["exclude_tools"] = self.exclude_tools + if self.timeout_seconds is not None: + kwargs["timeout_seconds"] = self.timeout_seconds + if self.refresh_connection is not None: + kwargs["refresh_connection"] = self.refresh_connection + + return kwargs + + +class MCPServerManager: + """Utility to load MCP server definitions and instantiate MCPTools.""" + + def __init__( + self, + config_source: Union[str, bytes, Dict[str, Any], Path], + *, + logger=None, + ) -> None: + self.logger = logger + self._raw_config = self._load_config(config_source) + self._servers = self._build_servers(self._raw_config) + + @staticmethod + def _load_config(config_source: Union[str, bytes, Dict[str, Any], Path]) -> Dict[str, Any]: + if isinstance(config_source, Path): + config_source = config_source.read_text(encoding="utf-8") + + if isinstance(config_source, (str, bytes)): + config_text = config_source.decode() if isinstance(config_source, bytes) else config_source + try: + loaded = json.loads(config_text) + except json.JSONDecodeError as exc: + raise MCPConfigError(f"Invalid MCP configuration JSON: {exc}") from exc + return MCPServerManager._validate_root(loaded) + + if isinstance(config_source, dict): + return MCPServerManager._validate_root(config_source) + + raise MCPConfigError("Unsupported MCP configuration source type.") + + @staticmethod + def _validate_root(config: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(config, dict): + raise MCPConfigError("MCP configuration root must be a JSON object.") + if "mcpServers" not in config: + raise MCPConfigError("MCP configuration must include a 'mcpServers' object.") + if not isinstance(config["mcpServers"], dict): + raise MCPConfigError("The 'mcpServers' field must be a JSON object.") + return config + + @staticmethod + def _build_servers(config: Dict[str, Any]) -> List[MCPServerDefinition]: + servers = [] + for name, data in config.get("mcpServers", {}).items(): + definition = MCPServerDefinition.from_dict(str(name), data) + servers.append(definition) + return servers + + def build_tools( + self, + only_servers: Optional[Iterable[str]] = None, + *, + include_disabled: bool = False, + ) -> Tuple[List[MCPTools], List[str]]: + target = {str(name) for name in only_servers} if only_servers else None + tools: List[MCPTools] = [] + active_servers: List[str] = [] + + for definition in self._servers: + if target and definition.name not in target: + continue + if not definition.enabled and not include_disabled: + if self.logger: + self.logger.info( + "⏭️ Skipping MCP server because it is disabled", + add_fields={ + "mcp_server": definition.name, + "agent_call": { + "action": "load_mcp_server", + "mcp_server": definition.name, + }, + "agent_response": { + "status": "skipped", + "reason": "disabled", + }, + }, + ) + continue + try: + tool = MCPTools(**definition.to_kwargs()) + setattr(tool, "mcp_server_id", definition.name) + setattr(tool, "mcp_server_definition", definition) + tools.append(tool) + active_servers.append(definition.name) + if self.logger: + self.logger.info( + "⚙️ Configured MCP server", + add_fields={ + "mcp_server": definition.name, + "transport": definition.transport, + "has_url": bool(definition.url), + "agent_call": { + "action": "configure_mcp_server", + "mcp_server": definition.name, + }, + "agent_response": { + "status": "configured", + "transport": definition.transport, + "has_url": bool(definition.url), + }, + }, + ) + except Exception as exc: + if self.logger: + self.logger.error( + "🚨 Failed to configure MCP server", + add_fields={ + "mcp_server": definition.name, + "error": str(exc), + "agent_call": { + "action": "configure_mcp_server", + "mcp_server": definition.name, + }, + "agent_response": { + "status": "error", + "error": str(exc), + }, + }, + ) + raise + + return tools, active_servers + + @property + def server_names(self) -> List[str]: + return [definition.name for definition in self._servers] + + +def load_mcp_tools( + config_source: Union[str, bytes, Dict[str, Any], Path], + *, + logger=None, + only_servers: Optional[Iterable[str]] = None, + include_disabled: bool = False, +) -> Tuple[List[MCPTools], List[str]]: + """Convenience helper to build MCPTools instances from a configuration source.""" + + manager = MCPServerManager(config_source, logger=logger) + return manager.build_tools(only_servers=only_servers, include_disabled=include_disabled) + + +async def initialize_mcp_tools( + tools: Iterable[MCPTools], + *, + logger=None, +) -> Dict[str, List[str]]: + """Connect to each MCP server and return the available tool names.""" + + server_tool_map: Dict[str, List[str]] = {} + + for toolkit in tools: + server_id = getattr(toolkit, "mcp_server_id", toolkit.name) + try: + await toolkit.connect() + tool_names = sorted(toolkit.functions.keys()) + server_tool_map[server_id] = tool_names + if logger: + logger.info( + "🔌 MCP server conectado", + add_fields={ + "mcp_server": server_id, + "available_tools": tool_names, + "tool_count": len(tool_names), + "agent_call": { + "action": "connect_mcp_server", + "mcp_server": server_id, + }, + "agent_response": { + "status": "connected", + "available_tools": tool_names, + "tool_count": len(tool_names), + }, + }, + ) + except Exception as exc: + if logger: + logger.error( + "🚨 Error conectando con MCP", + add_fields={ + "mcp_server": server_id, + "error": str(exc), + "agent_call": { + "action": "connect_mcp_server", + "mcp_server": server_id, + }, + "agent_response": { + "status": "error", + "error": str(exc), + }, + }, + ) + raise + + return server_tool_map + + +async def close_mcp_tools(tools: Iterable[MCPTools], *, logger=None) -> None: + """Close MCP connections gracefully.""" + + for toolkit in tools: + server_id = getattr(toolkit, "mcp_server_id", toolkit.name) + try: + await toolkit.close() + if logger: + logger.debug( + "🔻 MCP server desconectado", + add_fields={ + "mcp_server": server_id, + "agent_call": { + "action": "close_mcp_server", + "mcp_server": server_id, + }, + "agent_response": { + "status": "disconnected", + }, + }, + ) + except Exception as exc: + if logger: + logger.warning( + "⚠️ Error cerrando conexión MCP", + add_fields={ + "mcp_server": server_id, + "error": str(exc), + "agent_call": { + "action": "close_mcp_server", + "mcp_server": server_id, + }, + "agent_response": { + "status": "error", + "error": str(exc), + }, + }, + ) diff --git a/memoria/conversaciones.db b/memoria/conversaciones.db new file mode 100644 index 0000000..455b21f Binary files /dev/null and b/memoria/conversaciones.db differ diff --git a/pyproject.toml b/pyproject.toml index 27fcb07..880d7d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,11 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ + "agno>=2.2.8", "fastmcp>=2.12.5", "marimo>=0.17.0", + "openai>=2.7.1", + "sqlalchemy>=2.0.44", ] [tool.marimo.runtime] diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/agno_logging.py b/utils/agno_logging.py new file mode 100644 index 0000000..741a440 --- /dev/null +++ b/utils/agno_logging.py @@ -0,0 +1,89 @@ +import logging +from typing import Any, Dict + +from agno.utils.log import configure_agno_logging + +from LokiLogger import LokiLogger + +_AGNO_LOGGING_CONFIGURED = False + + +class LokiLoggingHandler(logging.Handler): + """Bridge Python logging records to LokiLogger entries.""" + + def __init__(self, loki_logger: LokiLogger) -> None: + super().__init__() + self._loki_logger = loki_logger + + def emit(self, record: logging.LogRecord) -> None: + try: + message = self.format(record) + except Exception: + message = record.getMessage() + + add_fields = _extract_extra_fields(record) + if add_fields: + self._loki_logger.log(record.levelname, message, add_fields=add_fields) + else: + self._loki_logger.log(record.levelname, message) + + +def _extract_extra_fields(record: logging.LogRecord) -> Dict[str, Any]: + """Extract custom fields from the log record while skipping logging internals.""" + + skip_keys = { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + } + extra: Dict[str, Any] = {} + for key, value in record.__dict__.items(): + if key in skip_keys: + continue + extra[key] = value + return extra + + +def configure_agno_to_use_loki(loki_logger: LokiLogger) -> None: + """Configure Agno logging to emit through the provided LokiLogger instance.""" + + global _AGNO_LOGGING_CONFIGURED + if _AGNO_LOGGING_CONFIGURED: + return + + bridge_logger = logging.getLogger("agno.loki_bridge") + bridge_logger.setLevel(logging.INFO) + bridge_logger.propagate = False + + handler = LokiLoggingHandler(loki_logger) + handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s")) + + # Avoid duplicate handlers if this is invoked multiple times + if not any(isinstance(existing, LokiLoggingHandler) for existing in bridge_logger.handlers): + bridge_logger.handlers.clear() + bridge_logger.addHandler(handler) + + configure_agno_logging( + custom_default_logger=bridge_logger, + custom_agent_logger=bridge_logger, + custom_team_logger=bridge_logger, + ) + + _AGNO_LOGGING_CONFIGURED = True diff --git a/uv.lock b/uv.lock index 81905c8..e5c0a1a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,30 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "agno" +version = "2.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "h11" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/67/c7f0154602ca3cd7ba4956921cf1ad1b194a4897d2e49c8d9fab25924ca4/agno-2.2.8.tar.gz", hash = "sha256:4df7282d5189c3c43b8196564825b1ef877505caa8b7cee1af8d0e56cea8adca", size = 1072301, upload-time = "2025-11-05T09:43:34.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/34/d450438ff317f9913e2a0b06e715325bc6b5c96442999381cc41bc5bd8f6/agno-2.2.8-py3-none-any.whl", hash = "sha256:17fe5ad416ccb472ae5d5b40c663fd82c0afa7942a453870afcf3ea56eab4b87", size = 1323050, upload-time = "2025-11-05T09:43:31.504Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -232,6 +256,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/0e/0a22e076944600aeb06f40b7e03bbd762a42d56d43a2f5f4ab954aed9005/cyclopts-4.0.0-py3-none-any.whl", hash = "sha256:e64801a2c86b681f08323fd50110444ee961236a0bae402a66d2cc3feda33da7", size = 178837, upload-time = "2025-10-20T18:33:00.191Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -303,6 +336,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/c1/9fb98c9649e15ea8cc691b4b09558b61dafb3dc0345f7322f8c4a8991ade/fastmcp-2.12.5-py3-none-any.whl", hash = "sha256:b1e542f9b83dbae7cecfdc9c73b062f77074785abda9f2306799116121344133", size = 329099, upload-time = "2025-10-17T13:24:57.518Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -388,6 +473,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jiter" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/68/0357982493a7b20925aece061f7fb7a2678e3b232f8d73a6edb7e5304443/jiter-0.11.1.tar.gz", hash = "sha256:849dcfc76481c0ea0099391235b7ca97d7279e0fa4c86005457ac7c88e8b76dc", size = 168385, upload-time = "2025-10-17T11:31:15.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/4b/e4dd3c76424fad02a601d570f4f2a8438daea47ba081201a721a903d3f4c/jiter-0.11.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:71b6a920a5550f057d49d0e8bcc60945a8da998019e83f01adf110e226267663", size = 305272, upload-time = "2025-10-17T11:29:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/83/2cd3ad5364191130f4de80eacc907f693723beaab11a46c7d155b07a092c/jiter-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b3de72e925388453a5171be83379549300db01284f04d2a6f244d1d8de36f94", size = 314038, upload-time = "2025-10-17T11:29:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3c/8e67d9ba524e97d2f04c8f406f8769a23205026b13b0938d16646d6e2d3e/jiter-0.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc19dd65a2bd3d9c044c5b4ebf657ca1e6003a97c0fc10f555aa4f7fb9821c00", size = 345977, upload-time = "2025-10-17T11:29:42.009Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/489ce64d992c29bccbffabb13961bbb0435e890d7f2d266d1f3df5e917d2/jiter-0.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d58faaa936743cd1464540562f60b7ce4fd927e695e8bc31b3da5b914baa9abd", size = 364503, upload-time = "2025-10-17T11:29:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c0/e321dd83ee231d05c8fe4b1a12caf1f0e8c7a949bf4724d58397104f10f2/jiter-0.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:902640c3103625317291cb73773413b4d71847cdf9383ba65528745ff89f1d14", size = 487092, upload-time = "2025-10-17T11:29:44.835Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/8f24ec49c8d37bd37f34ec0112e0b1a3b4b5a7b456c8efff1df5e189ad43/jiter-0.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30405f726e4c2ed487b176c09f8b877a957f535d60c1bf194abb8dadedb5836f", size = 376328, upload-time = "2025-10-17T11:29:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/7f/70/ded107620e809327cf7050727e17ccfa79d6385a771b7fe38fb31318ef00/jiter-0.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3217f61728b0baadd2551844870f65219ac4a1285d5e1a4abddff3d51fdabe96", size = 356632, upload-time = "2025-10-17T11:29:47.454Z" }, + { url = "https://files.pythonhosted.org/packages/19/53/c26f7251613f6a9079275ee43c89b8a973a95ff27532c421abc2a87afb04/jiter-0.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1364cc90c03a8196f35f396f84029f12abe925415049204446db86598c8b72c", size = 384358, upload-time = "2025-10-17T11:29:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/84/16/e0f2cc61e9c4d0b62f6c1bd9b9781d878a427656f88293e2a5335fa8ff07/jiter-0.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:53a54bf8e873820ab186b2dca9f6c3303f00d65ae5e7b7d6bda1b95aa472d646", size = 517279, upload-time = "2025-10-17T11:29:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/4cd095eaee68961bca3081acbe7c89e12ae24a5dae5fd5d2a13e01ed2542/jiter-0.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e29aca023627b0e0c2392d4248f6414d566ff3974fa08ff2ac8dbb96dfee92a", size = 508276, upload-time = "2025-10-17T11:29:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/25/f459240e69b0e09a7706d96ce203ad615ca36b0fe832308d2b7123abf2d0/jiter-0.11.1-cp313-cp313-win32.whl", hash = "sha256:f153e31d8bca11363751e875c0a70b3d25160ecbaee7b51e457f14498fb39d8b", size = 205593, upload-time = "2025-10-17T11:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/7c/16/461bafe22bae79bab74e217a09c907481a46d520c36b7b9fe71ee8c9e983/jiter-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:f773f84080b667c69c4ea0403fc67bb08b07e2b7ce1ef335dea5868451e60fed", size = 203518, upload-time = "2025-10-17T11:29:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/7b/72/c45de6e320edb4fa165b7b1a414193b3cae302dd82da2169d315dcc78b44/jiter-0.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:635ecd45c04e4c340d2187bcb1cea204c7cc9d32c1364d251564bf42e0e39c2d", size = 188062, upload-time = "2025-10-17T11:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/4a57922437ca8753ef823f434c2dec5028b237d84fa320f06a3ba1aec6e8/jiter-0.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d892b184da4d94d94ddb4031296931c74ec8b325513a541ebfd6dfb9ae89904b", size = 313814, upload-time = "2025-10-17T11:29:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/76/50/62a0683dadca25490a4bedc6a88d59de9af2a3406dd5a576009a73a1d392/jiter-0.11.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa22c223a3041dacb2fcd37c70dfd648b44662b4a48e242592f95bda5ab09d58", size = 344987, upload-time = "2025-10-17T11:30:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/da/00/2355dbfcbf6cdeaddfdca18287f0f38ae49446bb6378e4a5971e9356fc8a/jiter-0.11.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330e8e6a11ad4980cd66a0f4a3e0e2e0f646c911ce047014f984841924729789", size = 356399, upload-time = "2025-10-17T11:30:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/c9/07/c2bd748d578fa933d894a55bff33f983bc27f75fc4e491b354bef7b78012/jiter-0.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:09e2e386ebf298547ca3a3704b729471f7ec666c2906c5c26c1a915ea24741ec", size = 203289, upload-time = "2025-10-17T11:30:03.656Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ee/ace64a853a1acbd318eb0ca167bad1cf5ee037207504b83a868a5849747b/jiter-0.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:fe4a431c291157e11cee7c34627990ea75e8d153894365a3bc84b7a959d23ca8", size = 188284, upload-time = "2025-10-17T11:30:05.046Z" }, + { url = "https://files.pythonhosted.org/packages/8d/00/d6006d069e7b076e4c66af90656b63da9481954f290d5eca8c715f4bf125/jiter-0.11.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0fa1f70da7a8a9713ff8e5f75ec3f90c0c870be6d526aa95e7c906f6a1c8c676", size = 304624, upload-time = "2025-10-17T11:30:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/fc/45/4a0e31eb996b9ccfddbae4d3017b46f358a599ccf2e19fbffa5e531bd304/jiter-0.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:569ee559e5046a42feb6828c55307cf20fe43308e3ae0d8e9e4f8d8634d99944", size = 315042, upload-time = "2025-10-17T11:30:08.87Z" }, + { url = "https://files.pythonhosted.org/packages/e7/91/22f5746f5159a28c76acdc0778801f3c1181799aab196dbea2d29e064968/jiter-0.11.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69955fa1d92e81987f092b233f0be49d4c937da107b7f7dcf56306f1d3fcce9", size = 346357, upload-time = "2025-10-17T11:30:10.222Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4f/57620857d4e1dc75c8ff4856c90cb6c135e61bff9b4ebfb5dc86814e82d7/jiter-0.11.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:090f4c9d4a825e0fcbd0a2647c9a88a0f366b75654d982d95a9590745ff0c48d", size = 365057, upload-time = "2025-10-17T11:30:11.585Z" }, + { url = "https://files.pythonhosted.org/packages/ce/34/caf7f9cc8ae0a5bb25a5440cc76c7452d264d1b36701b90fdadd28fe08ec/jiter-0.11.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf3d8cedf9e9d825233e0dcac28ff15c47b7c5512fdfe2e25fd5bbb6e6b0cee", size = 487086, upload-time = "2025-10-17T11:30:13.052Z" }, + { url = "https://files.pythonhosted.org/packages/50/17/85b5857c329d533d433fedf98804ebec696004a1f88cabad202b2ddc55cf/jiter-0.11.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2aa9b1958f9c30d3d1a558b75f0626733c60eb9b7774a86b34d88060be1e67fe", size = 376083, upload-time = "2025-10-17T11:30:14.416Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/2d9f973f828226e6faebdef034097a2918077ea776fb4d88489949024787/jiter-0.11.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42d1ca16590b768c5e7d723055acd2633908baacb3628dd430842e2e035aa90", size = 357825, upload-time = "2025-10-17T11:30:15.765Z" }, + { url = "https://files.pythonhosted.org/packages/f4/55/848d4dabf2c2c236a05468c315c2cb9dc736c5915e65449ccecdba22fb6f/jiter-0.11.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5db4c2486a023820b701a17aec9c5a6173c5ba4393f26662f032f2de9c848b0f", size = 383933, upload-time = "2025-10-17T11:30:17.34Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6c/204c95a4fbb0e26dfa7776c8ef4a878d0c0b215868011cc904bf44f707e2/jiter-0.11.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4573b78777ccfac954859a6eff45cbd9d281d80c8af049d0f1a3d9fc323d5c3a", size = 517118, upload-time = "2025-10-17T11:30:18.684Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/09956644ea5a2b1e7a2a0f665cb69a973b28f4621fa61fc0c0f06ff40a31/jiter-0.11.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7593ac6f40831d7961cb67633c39b9fef6689a211d7919e958f45710504f52d3", size = 508194, upload-time = "2025-10-17T11:30:20.719Z" }, + { url = "https://files.pythonhosted.org/packages/09/49/4d1657355d7f5c9e783083a03a3f07d5858efa6916a7d9634d07db1c23bd/jiter-0.11.1-cp314-cp314-win32.whl", hash = "sha256:87202ec6ff9626ff5f9351507def98fcf0df60e9a146308e8ab221432228f4ea", size = 203961, upload-time = "2025-10-17T11:30:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/76/bd/f063bd5cc2712e7ca3cf6beda50894418fc0cfeb3f6ff45a12d87af25996/jiter-0.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:a5dd268f6531a182c89d0dd9a3f8848e86e92dfff4201b77a18e6b98aa59798c", size = 202804, upload-time = "2025-10-17T11:30:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/52/ca/4d84193dfafef1020bf0bedd5e1a8d0e89cb67c54b8519040effc694964b/jiter-0.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:5d761f863f912a44748a21b5c4979c04252588ded8d1d2760976d2e42cd8d991", size = 188001, upload-time = "2025-10-17T11:30:24.915Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/3b05e5c9d32efc770a8510eeb0b071c42ae93a5b576fd91cee9af91689a1/jiter-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2cc5a3965285ddc33e0cab933e96b640bc9ba5940cea27ebbbf6695e72d6511c", size = 312561, upload-time = "2025-10-17T11:30:26.742Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/335822eb216154ddb79a130cbdce88fdf5c3e2b43dc5dba1fd95c485aaf5/jiter-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b572b3636a784c2768b2342f36a23078c8d3aa6d8a30745398b1bab58a6f1a8", size = 344551, upload-time = "2025-10-17T11:30:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/31/6d/a0bed13676b1398f9b3ba61f32569f20a3ff270291161100956a577b2dd3/jiter-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad93e3d67a981f96596d65d2298fe8d1aa649deb5374a2fb6a434410ee11915e", size = 363051, upload-time = "2025-10-17T11:30:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/a4/03/313eda04aa08545a5a04ed5876e52f49ab76a4d98e54578896ca3e16313e/jiter-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a83097ce379e202dcc3fe3fc71a16d523d1ee9192c8e4e854158f96b3efe3f2f", size = 485897, upload-time = "2025-10-17T11:30:31.429Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/a1011b9d325e40b53b1b96a17c010b8646013417f3902f97a86325b19299/jiter-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7042c51e7fbeca65631eb0c332f90c0c082eab04334e7ccc28a8588e8e2804d9", size = 375224, upload-time = "2025-10-17T11:30:33.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/1b45026b19dd39b419e917165ff0ea629dbb95f374a3a13d2df95e40a6ac/jiter-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a68d679c0e47649a61df591660507608adc2652442de7ec8276538ac46abe08", size = 356606, upload-time = "2025-10-17T11:30:34.572Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9acb0e54d6a8ba59ce923a180ebe824b4e00e80e56cefde86cc8e0a948be/jiter-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b0da75dbf4b6ec0b3c9e604d1ee8beaf15bc046fff7180f7d89e3cdbd3bb51", size = 384003, upload-time = "2025-10-17T11:30:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2b/e5a5fe09d6da2145e4eed651e2ce37f3c0cf8016e48b1d302e21fb1628b7/jiter-0.11.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:69dd514bf0fa31c62147d6002e5ca2b3e7ef5894f5ac6f0a19752385f4e89437", size = 516946, upload-time = "2025-10-17T11:30:37.425Z" }, + { url = "https://files.pythonhosted.org/packages/5f/fe/db936e16e0228d48eb81f9934e8327e9fde5185e84f02174fcd22a01be87/jiter-0.11.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:bb31ac0b339efa24c0ca606febd8b77ef11c58d09af1b5f2be4c99e907b11111", size = 507614, upload-time = "2025-10-17T11:30:38.977Z" }, + { url = "https://files.pythonhosted.org/packages/86/db/c4438e8febfb303486d13c6b72f5eb71cf851e300a0c1f0b4140018dd31f/jiter-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b2ce0d6156a1d3ad41da3eec63b17e03e296b78b0e0da660876fccfada86d2f7", size = 204043, upload-time = "2025-10-17T11:30:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/36/59/81badb169212f30f47f817dfaabf965bc9b8204fed906fab58104ee541f9/jiter-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f4db07d127b54c4a2d43b4cf05ff0193e4f73e0dd90c74037e16df0b29f666e1", size = 204046, upload-time = "2025-10-17T11:30:41.692Z" }, + { url = "https://files.pythonhosted.org/packages/dd/01/43f7b4eb61db3e565574c4c5714685d042fb652f9eef7e5a3de6aafa943a/jiter-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28e4fdf2d7ebfc935523e50d1efa3970043cfaa161674fe66f9642409d001dfe", size = 188069, upload-time = "2025-10-17T11:30:43.23Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -435,14 +571,20 @@ name = "kanboard" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "agno" }, { name = "fastmcp" }, { name = "marimo" }, + { name = "openai" }, + { name = "sqlalchemy" }, ] [package.metadata] requires-dist = [ + { name = "agno", specifier = ">=2.2.8" }, { name = "fastmcp", specifier = ">=2.12.5" }, { name = "marimo", specifier = ">=0.17.0" }, + { name = "openai", specifier = ">=2.7.1" }, + { name = "sqlalchemy", specifier = ">=2.0.44" }, ] [[package]] @@ -679,6 +821,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/34/00c7ae8194074ed82b64e0bb7c24220eac5f77ac90c16e23cf0d2cfd2a03/narwhals-2.9.0-py3-none-any.whl", hash = "sha256:c59f7de4763004ae81691ce16df71b4e55aead0ead7ccde8c8f2ef8c9559c765", size = 422255, upload-time = "2025-10-20T12:19:15.228Z" }, ] +[[package]] +name = "openai" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/a2/f4023c1e0c868a6a5854955b3374f17153388aed95e835af114a17eac95b/openai-2.7.1.tar.gz", hash = "sha256:df4d4a3622b2df3475ead8eb0fbb3c27fd1c070fa2e55d778ca4f40e0186c726", size = 595933, upload-time = "2025-11-04T06:07:23.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/74/6bfc3adc81f6c2cea4439f2a734c40e3a420703bbcdc539890096a732bbd/openai-2.7.1-py3-none-any.whl", hash = "sha256:2f2530354d94c59c614645a4662b9dab0a5b881c5cd767a8587398feac0c9021", size = 1008780, upload-time = "2025-11-04T06:07:20.818Z" }, +] + [[package]] name = "openapi-core" version = "0.19.5" @@ -1114,6 +1275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1123,6 +1293,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1132,6 +1311,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, +] + [[package]] name = "sse-starlette" version = "3.0.2" @@ -1165,6 +1365,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"