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
|
||||
.env
|
||||
.env.local
|
||||
scripts/proxmox_ssh.sh # Contains Proxmox root password (legacy)
|
||||
scripts/proxmox_ssh.py # Contains Proxmox root password (paramiko)
|
||||
config/scheduled_tasks.yaml # Use scheduled_tasks.example.yaml instead
|
||||
# Contains Proxmox root password (legacy)
|
||||
scripts/proxmox_ssh.sh
|
||||
# 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_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/obsidian/ # Zettelkasten vault (personal notes)
|
||||
memory_workspace/SOUL.md # Personal config (use SOUL.example.md)
|
||||
memory_workspace/MEMORY.md # Personal memory (use MEMORY.example.md)
|
||||
# Zettelkasten vault (personal notes, API keys, credentials)
|
||||
memory_workspace/obsidian/
|
||||
# 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)
|
||||
users/
|
||||
|
||||
@@ -88,7 +88,8 @@ class SlackAdapter(BaseAdapter):
|
||||
self.handler = AsyncSocketModeHandler(self.app, app_token)
|
||||
|
||||
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
|
||||
print("[Slack] Connected and listening for messages")
|
||||
@@ -97,7 +98,7 @@ class SlackAdapter(BaseAdapter):
|
||||
"""Stop the Slack Socket Mode connection."""
|
||||
if self.handler:
|
||||
print("[Slack] Stopping Socket Mode connection...")
|
||||
await self.handler.close_async()
|
||||
await self.handler.disconnect_async()
|
||||
self.is_running = False
|
||||
print("[Slack] Disconnected")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Telegram adapter for ajarbot.
|
||||
Uses python-telegram-bot library for async Telegram Bot API integration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from telegram import Bot, Update
|
||||
@@ -42,6 +43,7 @@ class TelegramAdapter(BaseAdapter):
|
||||
super().__init__(config)
|
||||
self.application: Optional[Application] = None
|
||||
self.bot: Optional[Bot] = None
|
||||
self._polling_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
@@ -86,9 +88,13 @@ class TelegramAdapter(BaseAdapter):
|
||||
print("[Telegram] Starting bot...")
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
await self.application.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
|
||||
# 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,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
@@ -106,7 +112,15 @@ class TelegramAdapter(BaseAdapter):
|
||||
await self.application.stop()
|
||||
await self.application.shutdown()
|
||||
self.is_running = False
|
||||
print("[Telegram] Bot stopped")
|
||||
|
||||
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")
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
"""Register Telegram message handlers."""
|
||||
|
||||
@@ -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" Subject: {email_data['subject']}")
|
||||
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:
|
||||
# Truncate long bodies
|
||||
body = email_data["body"]
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "..."
|
||||
if len(body) > 2000:
|
||||
body = body[:2000] + "..."
|
||||
lines.append(f" Body: {body}")
|
||||
else:
|
||||
lines.append(f" Snippet: {email_data['snippet']}")
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
Supports two modes for Claude:
|
||||
1. Agent SDK (v0.1.36+) - DEFAULT - Uses query() API with Max subscription
|
||||
- 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)
|
||||
- Tools registered via mcp_tools.py MCP server
|
||||
- Flat-rate subscription cost
|
||||
|
||||
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
|
||||
- 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 = {
|
||||
"claude": "claude-sonnet-4-5-20250929",
|
||||
"claude_agent_sdk": "claude-sonnet-4-5-20250929",
|
||||
"claude": "claude-sonnet-4-6",
|
||||
"claude_agent_sdk": "claude-sonnet-4-6",
|
||||
"glm": "glm-4-plus",
|
||||
}
|
||||
|
||||
@@ -147,9 +147,9 @@ class LLMInterface:
|
||||
# Set model based on mode
|
||||
if provider == "claude":
|
||||
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:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-5-20250929")
|
||||
self.model = _DEFAULT_MODELS.get(provider, "claude-sonnet-4-6")
|
||||
else:
|
||||
self.model = _DEFAULT_MODELS.get(provider, "")
|
||||
|
||||
@@ -505,6 +505,7 @@ class LLMInterface:
|
||||
|
||||
# --- 4. Consume messages until we get a ResultMessage. ---
|
||||
result_text = ""
|
||||
assistant_messages = [] # Collect assistant responses
|
||||
message_count = 0
|
||||
async for data in query_obj.receive_messages():
|
||||
message = parse_message(data)
|
||||
@@ -514,14 +515,29 @@ class LLMInterface:
|
||||
message_type = type(message).__name__
|
||||
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):
|
||||
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(
|
||||
"[LLM] Agent SDK result received after %d messages: cost=$%.4f, turns=%s",
|
||||
message_count,
|
||||
getattr(message, "total_cost_usd", 0),
|
||||
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
|
||||
|
||||
# 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