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:
2026-02-22 21:19:28 -07:00
parent fe7c146dc6
commit a9efdc0a01
9 changed files with 135 additions and 23 deletions

24
.gitignore vendored
View File

@@ -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/

View File

@@ -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")

View File

@@ -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:

View File

@@ -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']}")

View File

@@ -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
View File

@@ -0,0 +1 @@
# mcp_servers - Standalone MCP server packages

View File

@@ -0,0 +1 @@
# Loki MCP Server - Query homelab logs via Loki's HTTP API

View 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

View 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()