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

View File

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

View File

@@ -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,10 +88,14 @@ class TelegramAdapter(BaseAdapter):
print("[Telegram] Starting bot...")
await self.application.initialize()
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,
drop_pending_updates=True,
)
)
self.is_running = True
@@ -106,6 +112,14 @@ class TelegramAdapter(BaseAdapter):
await self.application.stop()
await self.application.shutdown()
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")
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" 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']}")

View File

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