generado bot de telegram con capacidades de kanboard

This commit is contained in:
2025-11-06 02:14:52 +01:00
parent 009b1975dc
commit b351e56614
15 changed files with 2154 additions and 74 deletions
+178
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
# Kanboard Agent
## Telegram Bot
1. Copia tu token de bot en `.env` usando las siguientes variables:
```
TELEGRAM_BOT_TOKEN="<token_bot>"
TELEGRAM_ALLOWED_CHAT_ID="<chat_id>"
TELEGRAM_ALLOWED_USER_ID="<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.
+24
View File
@@ -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",
]
+293
View File
@@ -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
+168
View File
@@ -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]
+118
View File
@@ -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,
)
+249
View File
@@ -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()
+359
View File
@@ -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()
+73 -78
View File
@@ -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="""
# ========================
# ⚙️ 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()
# 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 expuestas ===
# =====================================================
# 🧩 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,
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,
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 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:
"""
✏️ 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,
""" 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)
# === Punto de entrada del servidor ===
@mcp.tool
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."""
return editar_tarea(API_URL, USER, TOKEN, task_id, titulo, descripcion,
swimlane_id, assignee_id, tags, priority)
# =====================================================
# 🚀 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()
+358
View File
@@ -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),
},
},
)
Binary file not shown.
+3
View File
@@ -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]
View File
+89
View File
@@ -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
Generated
+227
View File
@@ -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"