feat: Add Loki MCP server scaffold, fix adapter blocking, upgrade model
- Scaffold mcp_servers/loki/ with config and async HTTP client - Fix Slack/Telegram adapters to use non-blocking connections - Upgrade default model to claude-sonnet-4-6 - Improve Agent SDK message collection for empty ResultMessage cases - Add Message-ID to email summaries, increase body truncation limit - Fix .gitignore inline comments that broke sensitive file exclusions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
.gitignore
vendored
24
.gitignore
vendored
@@ -42,18 +42,26 @@ Thumbs.db
|
|||||||
*.local.json
|
*.local.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
# Contains Proxmox root password (legacy)
|
||||||
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
scripts/proxmox_ssh.sh
|
||||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
# Contains Proxmox root password (paramiko)
|
||||||
|
scripts/proxmox_ssh.py
|
||||||
|
# Use scheduled_tasks.example.yaml instead
|
||||||
|
config/scheduled_tasks.yaml
|
||||||
|
|
||||||
# Memory workspace (optional - remove if you want to version control)
|
# Memory workspace — personal data, do NOT commit
|
||||||
memory_workspace/memory/*.md
|
memory_workspace/memory/*.md
|
||||||
memory_workspace/memory_index.db
|
memory_workspace/memory_index.db
|
||||||
memory_workspace/users/*.md # User profiles (jordan.md, etc.)
|
# User profiles (jordan.md, etc.)
|
||||||
|
memory_workspace/users/*.md
|
||||||
memory_workspace/vectors.usearch
|
memory_workspace/vectors.usearch
|
||||||
memory_workspace/obsidian/ # Zettelkasten vault (personal notes)
|
# Zettelkasten vault (personal notes, API keys, credentials)
|
||||||
memory_workspace/SOUL.md # Personal config (use SOUL.example.md)
|
memory_workspace/obsidian/
|
||||||
memory_workspace/MEMORY.md # Personal memory (use MEMORY.example.md)
|
# Personal config (use SOUL.example.md)
|
||||||
|
memory_workspace/SOUL.md
|
||||||
|
# Personal memory
|
||||||
|
memory_workspace/MEMORY.md
|
||||||
|
memory_workspace/MEMORY.md.old
|
||||||
|
|
||||||
# User profiles (personal info)
|
# User profiles (personal info)
|
||||||
users/
|
users/
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ class SlackAdapter(BaseAdapter):
|
|||||||
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
||||||
|
|
||||||
print("[Slack] Starting Socket Mode connection...")
|
print("[Slack] Starting Socket Mode connection...")
|
||||||
await self.handler.start_async()
|
# Connect to Slack (non-blocking)
|
||||||
|
await self.handler.connect_async()
|
||||||
|
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
print("[Slack] Connected and listening for messages")
|
print("[Slack] Connected and listening for messages")
|
||||||
@@ -97,7 +98,7 @@ class SlackAdapter(BaseAdapter):
|
|||||||
"""Stop the Slack Socket Mode connection."""
|
"""Stop the Slack Socket Mode connection."""
|
||||||
if self.handler:
|
if self.handler:
|
||||||
print("[Slack] Stopping Socket Mode connection...")
|
print("[Slack] Stopping Socket Mode connection...")
|
||||||
await self.handler.close_async()
|
await self.handler.disconnect_async()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
print("[Slack] Disconnected")
|
print("[Slack] Disconnected")
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Telegram adapter for ajarbot.
|
|||||||
Uses python-telegram-bot library for async Telegram Bot API integration.
|
Uses python-telegram-bot library for async Telegram Bot API integration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from telegram import Bot, Update
|
from telegram import Bot, Update
|
||||||
@@ -42,6 +43,7 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.application: Optional[Application] = None
|
self.application: Optional[Application] = None
|
||||||
self.bot: Optional[Bot] = None
|
self.bot: Optional[Bot] = None
|
||||||
|
self._polling_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def platform_name(self) -> str:
|
def platform_name(self) -> str:
|
||||||
@@ -86,10 +88,14 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
print("[Telegram] Starting bot...")
|
print("[Telegram] Starting bot...")
|
||||||
await self.application.initialize()
|
await self.application.initialize()
|
||||||
await self.application.start()
|
await self.application.start()
|
||||||
await self.application.updater.start_polling(
|
|
||||||
|
# Run polling in a background task instead of blocking
|
||||||
|
self._polling_task = asyncio.create_task(
|
||||||
|
self.application.updater.start_polling(
|
||||||
allowed_updates=Update.ALL_TYPES,
|
allowed_updates=Update.ALL_TYPES,
|
||||||
drop_pending_updates=True,
|
drop_pending_updates=True,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
@@ -106,6 +112,14 @@ class TelegramAdapter(BaseAdapter):
|
|||||||
await self.application.stop()
|
await self.application.stop()
|
||||||
await self.application.shutdown()
|
await self.application.shutdown()
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
|
||||||
|
if self._polling_task and not self._polling_task.done():
|
||||||
|
self._polling_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._polling_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
print("[Telegram] Bot stopped")
|
print("[Telegram] Bot stopped")
|
||||||
|
|
||||||
def _register_handlers(self) -> None:
|
def _register_handlers(self) -> None:
|
||||||
|
|||||||
@@ -193,12 +193,13 @@ def format_email_summary(emails: List[Dict], include_body: bool = False) -> str:
|
|||||||
lines.append(f"{i}. From: {email_data['from']}")
|
lines.append(f"{i}. From: {email_data['from']}")
|
||||||
lines.append(f" Subject: {email_data['subject']}")
|
lines.append(f" Subject: {email_data['subject']}")
|
||||||
lines.append(f" Date: {email_data['date']}")
|
lines.append(f" Date: {email_data['date']}")
|
||||||
|
lines.append(f" Message-ID: {email_data.get('id', 'N/A')}")
|
||||||
|
|
||||||
if include_body and "body" in email_data:
|
if include_body and "body" in email_data:
|
||||||
# Truncate long bodies
|
# Truncate long bodies
|
||||||
body = email_data["body"]
|
body = email_data["body"]
|
||||||
if len(body) > 500:
|
if len(body) > 2000:
|
||||||
body = body[:500] + "..."
|
body = body[:2000] + "..."
|
||||||
lines.append(f" Body: {body}")
|
lines.append(f" Body: {body}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" Snippet: {email_data['snippet']}")
|
lines.append(f" Snippet: {email_data['snippet']}")
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
Supports two modes for Claude:
|
Supports two modes for Claude:
|
||||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||||
- Set USE_AGENT_SDK=true (default)
|
- Set USE_AGENT_SDK=true (default)
|
||||||
- Model: claude-sonnet-4-5-20250929 (default for all operations)
|
- Model: claude-sonnet-4-6 (default for all operations)
|
||||||
- All tools are MCP-based (no API key needed)
|
- All tools are MCP-based (no API key needed)
|
||||||
- Tools registered via mcp_tools.py MCP server
|
- Tools registered via mcp_tools.py MCP server
|
||||||
- Flat-rate subscription cost
|
- Flat-rate subscription cost
|
||||||
|
|
||||||
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
2. Direct API (pay-per-token) - Set USE_DIRECT_API=true
|
||||||
- Model: claude-sonnet-4-5-20250929
|
- Model: claude-sonnet-4-6
|
||||||
- Requires ANTHROPIC_API_KEY in .env
|
- Requires ANTHROPIC_API_KEY in .env
|
||||||
- Uses traditional tool definitions from tools.py
|
- Uses traditional tool definitions from tools.py
|
||||||
"""
|
"""
|
||||||
@@ -60,8 +60,8 @@ _USE_AGENT_SDK = os.getenv("USE_AGENT_SDK", "true").lower() == "true"
|
|||||||
|
|
||||||
# Default models by provider
|
# Default models by provider
|
||||||
_DEFAULT_MODELS = {
|
_DEFAULT_MODELS = {
|
||||||
"claude": "claude-sonnet-4-5-20250929",
|
"claude": "claude-sonnet-4-6",
|
||||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
"claude_agent_sdk": "claude-sonnet-4-6",
|
||||||
"glm": "glm-4-plus",
|
"glm": "glm-4-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +147,9 @@ class LLMInterface:
|
|||||||
# Set model based on mode
|
# Set model based on mode
|
||||||
if provider == "claude":
|
if provider == "claude":
|
||||||
if self.mode == "agent_sdk":
|
if self.mode == "agent_sdk":
|
||||||
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-5-20250929")
|
self.model = _DEFAULT_MODELS.get("claude_agent_sdk", "claude-sonnet-4-6")
|
||||||
else:
|
else:
|
||||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-6")
|
||||||
else:
|
else:
|
||||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||||
|
|
||||||
@@ -505,6 +505,7 @@ class LLMInterface:
|
|||||||
|
|
||||||
# --- 4. Consume messages until we get a ResultMessage. ---
|
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||||
result_text = ""
|
result_text = ""
|
||||||
|
assistant_messages = [] # Collect assistant responses
|
||||||
message_count = 0
|
message_count = 0
|
||||||
async for data in query_obj.receive_messages():
|
async for data in query_obj.receive_messages():
|
||||||
message = parse_message(data)
|
message = parse_message(data)
|
||||||
@@ -514,14 +515,29 @@ class LLMInterface:
|
|||||||
message_type = type(message).__name__
|
message_type = type(message).__name__
|
||||||
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
logger.debug(f"[LLM] Received message #{message_count}: {message_type}")
|
||||||
|
|
||||||
|
# Collect text from AssistantMessage objects
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
if hasattr(message, 'content') and message.content:
|
||||||
|
# Extract text from content blocks
|
||||||
|
if isinstance(message.content, str):
|
||||||
|
assistant_messages.append(message.content)
|
||||||
|
elif isinstance(message.content, list):
|
||||||
|
for block in message.content:
|
||||||
|
if hasattr(block, 'type') and block.type == 'text':
|
||||||
|
if hasattr(block, 'text'):
|
||||||
|
assistant_messages.append(block.text)
|
||||||
|
|
||||||
if isinstance(message, ResultMessage):
|
if isinstance(message, ResultMessage):
|
||||||
result_text = message.result or ""
|
# Use ResultMessage.result if available, otherwise use collected assistant messages
|
||||||
|
result_text = message.result or "\n".join(assistant_messages)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||||
message_count,
|
message_count,
|
||||||
getattr(message, "total_cost_usd", 0),
|
getattr(message, "total_cost_usd", 0),
|
||||||
getattr(message, "num_turns", "?"),
|
getattr(message, "num_turns", "?"),
|
||||||
)
|
)
|
||||||
|
if not message.result and assistant_messages:
|
||||||
|
logger.debug(f"[LLM] ResultMessage.result was empty, using {len(assistant_messages)} collected assistant messages")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Log non-result messages to detect loops
|
# Log non-result messages to detect loops
|
||||||
|
|||||||
1
mcp_servers/__init__.py
Normal file
1
mcp_servers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# mcp_servers - Standalone MCP server packages
|
||||||
1
mcp_servers/loki/__init__.py
Normal file
1
mcp_servers/loki/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Loki MCP Server - Query homelab logs via Loki's HTTP API
|
||||||
38
mcp_servers/loki/config.py
Normal file
38
mcp_servers/loki/config.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Loki MCP Server - Configuration
|
||||||
|
|
||||||
|
This is where we store settings for connecting to your Loki instance.
|
||||||
|
We use environment variables with sensible defaults so you can override
|
||||||
|
them without editing code.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
LOKI_URL - Base URL for your Loki instance
|
||||||
|
LOKI_TIMEOUT - Request timeout in seconds (default: 30)
|
||||||
|
LOKI_DEFAULT_LIMIT - Default number of log lines to return (default: 100)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# The URL where Loki is reachable. This goes through your Caddy reverse proxy.
|
||||||
|
LOKI_URL = os.getenv("LOKI_URL", "https://loki.apophisnetworking.net")
|
||||||
|
|
||||||
|
# How long (seconds) to wait for Loki to respond before giving up.
|
||||||
|
LOKI_TIMEOUT = int(os.getenv("LOKI_TIMEOUT", "30"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Query defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How many log lines to return if the caller doesn't specify.
|
||||||
|
# 100 is a good balance — enough to see what's happening, not so many
|
||||||
|
# that it floods the response.
|
||||||
|
DEFAULT_LIMIT = int(os.getenv("LOKI_DEFAULT_LIMIT", "100"))
|
||||||
|
|
||||||
|
# Default time range for queries if none specified (in hours).
|
||||||
|
# "1" means "show me the last hour of logs."
|
||||||
|
DEFAULT_RANGE_HOURS = 1
|
||||||
32
mcp_servers/loki/loki_client.py
Normal file
32
mcp_servers/loki/loki_client.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import httpx
|
||||||
|
from config import LokiConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LokiClient:
|
||||||
|
"""Talks to Loki's HTTP API to fetch logs."""
|
||||||
|
|
||||||
|
def __init__(self, config: LokiConfig):
|
||||||
|
# Store the config so we can use it later
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# Create an HTTP client
|
||||||
|
# already knows Loki address and wait time
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=config.url,
|
||||||
|
timeout=config.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
async def query_range(self, query: str, start: str, end: str, limit: int = 100):
|
||||||
|
# Makes GET request to Loki's query endpoint with search parameters
|
||||||
|
response = await self.client.get(
|
||||||
|
"/loki/api/v1/query_range",
|
||||||
|
params={
|
||||||
|
"query": query,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"limit": limit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Returns response into Python Dict
|
||||||
|
return response.json()
|
||||||
Reference in New Issue
Block a user