diff --git a/.gitignore b/.gitignore index 0d6e9b2..81f394f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/adapters/slack/adapter.py b/adapters/slack/adapter.py index 73aaa4c..fbc48bf 100644 --- a/adapters/slack/adapter.py +++ b/adapters/slack/adapter.py @@ -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") diff --git a/adapters/telegram/adapter.py b/adapters/telegram/adapter.py index 25ce633..1b5d348 100644 --- a/adapters/telegram/adapter.py +++ b/adapters/telegram/adapter.py @@ -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.""" diff --git a/google_tools/utils.py b/google_tools/utils.py index d2dbf74..7f1a279 100644 --- a/google_tools/utils.py +++ b/google_tools/utils.py @@ -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']}") diff --git a/llm_interface.py b/llm_interface.py index 786f1db..a6bd95c 100644 --- a/llm_interface.py +++ b/llm_interface.py @@ -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 diff --git a/mcp_servers/__init__.py b/mcp_servers/__init__.py new file mode 100644 index 0000000..f8921aa --- /dev/null +++ b/mcp_servers/__init__.py @@ -0,0 +1 @@ +# mcp_servers - Standalone MCP server packages diff --git a/mcp_servers/loki/__init__.py b/mcp_servers/loki/__init__.py new file mode 100644 index 0000000..d3f292b --- /dev/null +++ b/mcp_servers/loki/__init__.py @@ -0,0 +1 @@ +# Loki MCP Server - Query homelab logs via Loki's HTTP API diff --git a/mcp_servers/loki/config.py b/mcp_servers/loki/config.py new file mode 100644 index 0000000..2c76230 --- /dev/null +++ b/mcp_servers/loki/config.py @@ -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 diff --git a/mcp_servers/loki/loki_client.py b/mcp_servers/loki/loki_client.py new file mode 100644 index 0000000..0aa619b --- /dev/null +++ b/mcp_servers/loki/loki_client.py @@ -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()