diff --git a/projects/discord-bot/.env.example b/projects/discord-bot/.env.example index f7d5905..0149eca 100644 --- a/projects/discord-bot/.env.example +++ b/projects/discord-bot/.env.example @@ -2,20 +2,19 @@ DISCORD_TOKEN=your_discord_bot_token_here DISCORD_GUILD_ID=your_guild_id_here -# Anthropic Claude API -ANTHROPIC_API_KEY=your_anthropic_api_key_here +# Anthropic Claude +# Auth: leave ANTHROPIC_API_KEY unset to use the local Claude Code OAuth +# (Pro/Max subscription, shared with your interactive Claude Code). +# Set it to use the API with metered billing. +# ANTHROPIC_API_KEY= +CLAUDE_MODEL=claude-sonnet-4-6 -# ClaudeTools API -CLAUDETOOLS_API_URL=http://172.16.3.30:8001 -CLAUDETOOLS_API_KEY=your_api_key_here - -# File Paths (Windows paths for BEAST) -VAULT_PATH=D:\vault -CLAUDETOOLS_ROOT=D:\claudetools +# Workspace the agent operates in (its cwd). +# Windows default: c:/Users/guru/ClaudeTools +# Mac: /Users//ClaudeTools +# Linux: /home//claudetools +CLAUDETOOLS_ROOT=c:/Users/guru/ClaudeTools # Logging LOG_LEVEL=INFO LOG_FILE=logs/bot.log - -# Optional: Override Git Bash location -# GIT_BASH_PATH=C:\Program Files\Git\bin\bash.exe diff --git a/projects/discord-bot/.gitignore b/projects/discord-bot/.gitignore index 19267f5..6b15f7b 100644 --- a/projects/discord-bot/.gitignore +++ b/projects/discord-bot/.gitignore @@ -48,3 +48,4 @@ Thumbs.db # Project specific conversations.db artifacts/ +.attachments/ diff --git a/projects/discord-bot/README.md b/projects/discord-bot/README.md index 933cf24..69dc803 100644 --- a/projects/discord-bot/README.md +++ b/projects/discord-bot/README.md @@ -12,13 +12,23 @@ Discord bot providing MSP team access to ClaudeTools database, M365 remediation- ## Architecture +As of Phase 1.5, the bot is "Claude Code in a Discord channel." Each Discord +thread is a persistent `ClaudeSDKClient` session whose `cwd` is the ClaudeTools +repo root, with `.claude/CLAUDE.md` as the system prompt. The agent uses the +Claude Agent SDK's native tools (Read, Edit, Write, Bash, Glob, Grep, etc.) — +the bot does not hand-write tool definitions or call the ClaudeTools HTTP API. + ``` -Discord → Message Handler → Claude API (with Tools) - ↓ - ┌────────────┴────────────┐ - ↓ ↓ - ClaudeTools API Remediation Scripts - (HTTP Client) (Bash Subprocess) +Discord thread ──> MessageHandler ──> ClaudeAgentManager + │ + v + ClaudeSDKClient (per thread) + cwd = ClaudeTools repo + system_prompt = .claude/CLAUDE.md + │ + v + Native SDK tools: + Read / Edit / Write / Bash / Glob / Grep / ... ``` ## Prerequisites @@ -171,13 +181,16 @@ discord-bot/ ## Development Roadmap -### ✅ Phase 1: MVP (Current) +### Phase 1.5: Claude Agent SDK refactor (Current) - [x] Discord bot connection -- [x] Claude API streaming -- [x] Thread-based conversations -- [x] Basic tool definitions -- [ ] **TODO:** Tool execution (ClaudeTools API) -- [ ] **TODO:** Tool execution (Remediation scripts) +- [x] Claude Agent SDK streaming (replaces raw Anthropic SDK) +- [x] Per-thread persistent agent sessions (`ClaudeSDKClient`) +- [x] Workspace = ClaudeTools repo; system prompt = `.claude/CLAUDE.md` +- [x] Native SDK tools (Read/Edit/Write/Bash/Glob/Grep) — no hand-written tools +- The hand-written `query_claudetools_api`, `run_breach_check`, and + `run_tenant_sweep` tools from the Phase 1 scaffold were removed. The agent + invokes those workflows via the existing skills under `.claude/skills/` and + via Bash + the vault wrapper, the same way Claude Code does. ### Phase 2: ClaudeTools API Integration - [ ] HTTP client with JWT auth diff --git a/projects/discord-bot/bot/claude/client.py b/projects/discord-bot/bot/claude/client.py index dd1365a..00aa5ff 100644 --- a/projects/discord-bot/bot/claude/client.py +++ b/projects/discord-bot/bot/claude/client.py @@ -1,149 +1,97 @@ -"""Claude API client with streaming support for Discord.""" -import asyncio -from datetime import datetime -from typing import Callable, Optional, Any +"""Claude Agent SDK wrapper for per-thread Discord conversations.""" +from __future__ import annotations -import discord -from anthropic import AsyncAnthropic -from anthropic.types import MessageStreamEvent +import logging +from pathlib import Path +from typing import AsyncIterator, Awaitable, Callable, Optional + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + TextBlock, + ToolUseBlock, +) from bot.config import settings -from bot.claude.tools import TOOLS, SYSTEM_PROMPT_TEMPLATE + +logger = logging.getLogger(__name__) -class ClaudeClient: - """Wrapper around Anthropic SDK for Discord bot usage.""" +def _load_system_prompt() -> str: + claude_md = settings.claudetools_root / ".claude" / "CLAUDE.md" + return claude_md.read_text(encoding="utf-8") - def __init__(self): - self.client = AsyncAnthropic(api_key=settings.anthropic_api_key) - self.model = settings.claude_model - def format_system_prompt( - self, - discord_user: discord.User, - channel_name: str, - thread_name: str, - user_role: str = "unknown" - ) -> str: - """Format system prompt with current context.""" - return SYSTEM_PROMPT_TEMPLATE.format( - discord_username=discord_user.name, - discord_id=discord_user.id, - role=user_role, - channel_name=channel_name, - thread_name=thread_name, - datetime_utc=datetime.utcnow().isoformat() +class ThreadAgent: + """One persistent Claude Code session bound to a Discord thread.""" + + def __init__(self, system_prompt: str, cwd: Path, model: str) -> None: + self._options = ClaudeAgentOptions( + system_prompt=system_prompt, + cwd=str(cwd), + model=model, ) + self._client: Optional[ClaudeSDKClient] = None - async def stream_response( - self, - messages: list[dict], - system_prompt: str, - tool_executor: Optional[Callable] = None, - progress_callback: Optional[Callable] = None - ) -> tuple[str, list[dict]]: - """ - Stream a response from Claude, executing tools as needed. + async def start(self) -> None: + self._client = ClaudeSDKClient(options=self._options) + await self._client.connect() - Args: - messages: Conversation history - system_prompt: System prompt with context - tool_executor: Async function to execute tool calls - progress_callback: Async function to call with progress updates + async def stop(self) -> None: + if self._client is not None: + await self._client.disconnect() + self._client = None - Returns: - Tuple of (final_response_text, tool_results) - """ - final_text = "" - tool_results = [] - - async with self.client.messages.stream( - model=self.model, - max_tokens=4096, - system=system_prompt, - messages=messages, - tools=TOOLS - ) as stream: - async for event in stream: - if event.type == "content_block_start": - if event.content_block.type == "tool_use": - # Tool call starting - tool_name = event.content_block.name - if progress_callback: - await progress_callback(f"🔧 Calling {tool_name}...") - - elif event.type == "content_block_delta": - if hasattr(event.delta, "text"): - # Text content streaming - final_text += event.delta.text - if progress_callback and len(final_text) % 500 == 0: - # Send progress update every 500 chars - await progress_callback(final_text) - - elif event.type == "message_stop": - # Check for tool uses - message = await stream.get_final_message() - - for block in message.content: - if block.type == "tool_use": - # Execute tool - tool_name = block.name - tool_input = block.input - - if tool_executor: - try: - if progress_callback: - await progress_callback( - f"⚙️ Executing {tool_name}..." - ) - - result = await tool_executor(tool_name, tool_input) - tool_results.append({ - "name": tool_name, - "input": tool_input, - "result": result - }) - - if progress_callback: - await progress_callback( - f"✅ {tool_name} complete" - ) - - except Exception as e: - error_msg = f"Error in {tool_name}: {str(e)}" - tool_results.append({ - "name": tool_name, - "input": tool_input, - "error": error_msg - }) - - if progress_callback: - await progress_callback(f"❌ {error_msg}") - - elif block.type == "text": - final_text += block.text - - return final_text, tool_results - - async def simple_ask( + async def send( self, user_message: str, - conversation_history: list[dict], - system_prompt: str + on_text: Callable[[str], Awaitable[None]], + on_tool_use: Optional[Callable[[str], Awaitable[None]]] = None, ) -> str: - """ - Simple non-streaming request without tools. - Useful for summarization and simple queries. - """ - messages = conversation_history + [ - {"role": "user", "content": user_message} - ] + if self._client is None: + raise RuntimeError("ThreadAgent.send() called before start()") - response = await self.client.messages.create( - model=self.model, - max_tokens=4096, - system=system_prompt, - messages=messages - ) + await self._client.query(user_message) - return response.content[0].text if response.content else "" + full_text = "" + async for message in self._client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + full_text += block.text + await on_text(block.text) + elif isinstance(block, ToolUseBlock) and on_tool_use is not None: + await on_tool_use(block.name) + elif isinstance(message, ResultMessage): + break + + return full_text + + +class ClaudeAgentManager: + """Owns one ThreadAgent per Discord thread id.""" + + def __init__(self) -> None: + self._system_prompt = _load_system_prompt() + self._cwd = settings.claudetools_root + self._model = settings.claude_model + self._agents: dict[int, ThreadAgent] = {} + + async def get_or_create(self, thread_id: int) -> ThreadAgent: + agent = self._agents.get(thread_id) + if agent is None: + logger.info("[INFO] Starting new agent session for thread %d", thread_id) + agent = ThreadAgent(self._system_prompt, self._cwd, self._model) + await agent.start() + self._agents[thread_id] = agent + return agent + + async def shutdown(self) -> None: + for thread_id, agent in list(self._agents.items()): + try: + await agent.stop() + except Exception as e: + logger.warning("[WARNING] Failed to stop agent %d: %s", thread_id, e) + self._agents.clear() diff --git a/projects/discord-bot/bot/claude/tools.py b/projects/discord-bot/bot/claude/tools.py index 6fb5f72..f636045 100644 --- a/projects/discord-bot/bot/claude/tools.py +++ b/projects/discord-bot/bot/claude/tools.py @@ -1,142 +1 @@ -"""Claude API tool definitions for ClaudeTools integration.""" - -TOOLS = [ - { - "name": "query_claudetools_api", - "description": ( - "Query the ClaudeTools MSP database. Use this for ALL data lookups including " - "clients, sessions, tasks, work items, billable time, infrastructure, " - "credentials, projects, and more. Returns JSON data from the API." - ), - "input_schema": { - "type": "object", - "properties": { - "endpoint": { - "type": "string", - "description": ( - "API endpoint path starting with /api/, e.g., '/api/clients', " - "'/api/sessions', '/api/tasks'" - ) - }, - "method": { - "type": "string", - "enum": ["GET", "POST", "PUT", "DELETE"], - "default": "GET", - "description": "HTTP method to use" - }, - "params": { - "type": "object", - "description": ( - "Query parameters as key-value pairs. Common params: " - "skip (offset), limit (page size), client_id, session_id, " - "status_filter, etc." - ) - }, - "body": { - "type": "object", - "description": "Request body for POST/PUT requests (JSON)" - } - }, - "required": ["endpoint"] - } - }, - { - "name": "run_breach_check", - "description": ( - "Run a comprehensive 10-point M365 breach investigation on a single user account. " - "Checks: inbox rules, mailbox forwarding, OAuth consents, auth methods, " - "sign-ins (including foreign countries and legacy auth), directory audits, " - "risky user status, sent items, and deleted items. " - "Returns breach summary and artifact locations. " - "Requires tenant to be onboarded to remediation-tool." - ), - "input_schema": { - "type": "object", - "properties": { - "tenant": { - "type": "string", - "description": ( - "Tenant domain or GUID (e.g., 'cascadestucson.com' or " - "'4fcbb1f4-fbf9-4548-a93e-7d14a3c091e6')" - ) - }, - "upn": { - "type": "string", - "description": ( - "User Principal Name - the user's email address " - "(e.g., 'john.trozzi@cascadestucson.com')" - ) - } - }, - "required": ["tenant", "upn"] - } - }, - { - "name": "run_tenant_sweep", - "description": ( - "Sweep an entire M365 tenant for security issues. " - "Checks: failed sign-ins from multiple foreign countries, " - "successful non-US sign-ins, B2B guest invitations, " - "consent/auth-method/role changes in directory audits, " - "and risky users (if IdentityRiskyUser consent granted). " - "Returns priority-sorted findings. " - "Requires tenant to be onboarded to remediation-tool." - ), - "input_schema": { - "type": "object", - "properties": { - "tenant": { - "type": "string", - "description": ( - "Tenant domain or GUID (e.g., 'dataforth.com' or " - "'dd4a82e8-85a3-44ac-8800-07945ab4d95f')" - ) - } - }, - "required": ["tenant"] - } - } -] - - -SYSTEM_PROMPT_TEMPLATE = """You are the ClaudeTools MSP Assistant for Arizona Computer Guru. - -Available Tools: -1. query_claudetools_api - MSP database (clients, sessions, tasks, infrastructure, credentials) -2. run_breach_check - M365 user breach investigation (10-point audit) -3. run_tenant_sweep - M365 tenant-wide security sweep - -Current Context: -- User: {discord_username} (Discord ID: {discord_id}) -- Role: {role} (admin or tech) -- Channel: #{channel_name} -- Thread: {thread_name} -- DateTime: {datetime_utc} - -Response Guidelines: -- Use Discord markdown: **bold**, `code`, ```language blocks``` -- Keep responses under 2000 chars (Discord limit) - split into multiple messages if needed -- For structured data, use clear formatting or request embeds -- Ask before listing >5 items -- Security-conscious: NEVER expose credentials in responses -- Provide 1Password vault paths instead of actual secrets - -Access Control: -- All team members: read-only queries, breach checks, tenant sweeps -- Mike/Howard only: remediation actions (require explicit confirmation) -- Dev/coding questions: refer to Mike or Howard -- NEVER execute destructive operations without explicit YES confirmation - -Tool Usage: -- Use query_claudetools_api for ALL database lookups (don't make up data) -- Use run_breach_check for single-user M365 investigation -- Use run_tenant_sweep for tenant-wide M365 security analysis -- Chain tools when needed for complex multi-step queries -- Always cite which tool you used when presenting results - -Remember: -- You're an MSP assistant - understand client/project/session/work item concepts -- Be concise but thorough -- If unsure, ask clarifying questions -- Guide users through multi-step processes -""" +"""Deprecated. Tools are provided by the Claude Agent SDK natively.""" diff --git a/projects/discord-bot/bot/config.py b/projects/discord-bot/bot/config.py index fa9e200..2a71f7f 100644 --- a/projects/discord-bot/bot/config.py +++ b/projects/discord-bot/bot/config.py @@ -1,4 +1,4 @@ -"""Configuration management for ClaudeTools Discord Bot.""" +"""Configuration for the ClaudeTools Discord Bot.""" from pathlib import Path from typing import Optional @@ -7,76 +7,43 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - """Bot configuration from environment variables.""" - - # Discord discord_token: str = Field(..., description="Discord bot token") discord_guild_id: Optional[int] = Field(None, description="Discord guild/server ID") - # Anthropic Claude API - anthropic_api_key: str = Field(..., description="Anthropic API key") - claude_model: str = Field( - default="claude-sonnet-4-5-20250929", - description="Claude model to use" - ) + # Optional: leave unset to use the local Claude Code OAuth credential + # (Pro/Max subscription). Set to use the API with metered billing. + anthropic_api_key: Optional[str] = Field(default=None, description="Anthropic API key") + claude_model: str = Field(default="claude-sonnet-4-6", description="Claude model") - # ClaudeTools API - claudetools_api_url: str = Field( - default="http://172.16.3.30:8001", - description="ClaudeTools API base URL" - ) - claudetools_api_key: str = Field(..., description="ClaudeTools API key") - - # File Paths - vault_path: Path = Field( - default=Path("D:/vault"), - description="Path to SOPS vault" - ) + # Workspace the agent operates in. Default is the Windows BEAST path; override + # via CLAUDETOOLS_ROOT env var on Mac/Linux. claudetools_root: Path = Field( - default=Path("D:/claudetools"), - description="Path to ClaudeTools repository" + default=Path("c:/Users/guru/ClaudeTools"), + description="Path to ClaudeTools repository (agent cwd)", ) - # Git Bash (for remediation scripts on Windows) - git_bash_path: Path = Field( - default=Path("C:/Program Files/Git/bin/bash.exe"), - description="Path to Git Bash executable" - ) - - # Logging log_level: str = Field(default="INFO", description="Logging level") - log_file: Optional[Path] = Field( - default=Path("logs/bot.log"), - description="Log file path" - ) + log_file: Optional[Path] = Field(default=Path("logs/bot.log"), description="Log file") model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, - extra="ignore" + extra="ignore", ) def validate_paths(self) -> None: - """Validate that required paths exist.""" - if not self.git_bash_path.exists(): - raise FileNotFoundError( - f"Git Bash not found at {self.git_bash_path}. " - "Set GIT_BASH_PATH environment variable." - ) - - if not self.vault_path.exists(): - raise FileNotFoundError( - f"Vault not found at {self.vault_path}. " - "Set VAULT_PATH environment variable." - ) - if not self.claudetools_root.exists(): raise FileNotFoundError( f"ClaudeTools not found at {self.claudetools_root}. " "Set CLAUDETOOLS_ROOT environment variable." ) + claude_md = self.claudetools_root / ".claude" / "CLAUDE.md" + if not claude_md.exists(): + raise FileNotFoundError( + f"CLAUDE.md not found at {claude_md}. " + "Agent system prompt cannot be loaded." + ) -# Global settings instance settings = Settings() diff --git a/projects/discord-bot/bot/handlers/message_handler.py b/projects/discord-bot/bot/handlers/message_handler.py index f0390ce..747e9e1 100644 --- a/projects/discord-bot/bot/handlers/message_handler.py +++ b/projects/discord-bot/bot/handlers/message_handler.py @@ -1,186 +1,193 @@ -"""Discord message handler for @mentions and conversations.""" +"""Discord message handler. Maps each thread to a persistent Claude Agent session.""" +from __future__ import annotations + import asyncio -from typing import Optional +import logging +import shutil +from pathlib import Path import discord -from bot.claude.client import ClaudeClient +from bot.claude.client import ClaudeAgentManager +from bot.config import settings + +logger = logging.getLogger(__name__) + +DISCORD_MAX_LEN = 2000 +EDIT_THROTTLE_SECONDS = 0.75 +MAX_FILE_BYTES = 25 * 1024 * 1024 +MAX_TURN_BYTES = 100 * 1024 * 1024 +ATTACHMENT_ROOT = settings.claudetools_root / "projects" / "discord-bot" / ".attachments" class MessageHandler: - """Handles Discord messages and coordinates with Claude API.""" - - def __init__(self, bot: discord.Client, claude_client: ClaudeClient): + def __init__(self, bot: discord.Client, agents: ClaudeAgentManager) -> None: self.bot = bot - self.claude = claude_client - # Store conversation history per thread - self.conversations: dict[int, list[dict]] = {} + self.agents = agents - async def handle_mention(self, message: discord.Message): - """Handle a message that mentions the bot.""" - # Don't respond to self + async def handle_mention(self, message: discord.Message) -> None: if message.author == self.bot.user: return - # Extract the actual message content (remove bot mention) content = message.content for mention in message.mentions: if mention == self.bot.user: - content = content.replace(f"<@{mention.id}>", "").strip() + content = content.replace(f"<@{mention.id}>", "").replace( + f"<@!{mention.id}>", "" + ).strip() - if not content: - await message.reply("Hey! How can I help you?") + if not content and not message.attachments: + await message.reply("Hey! How can I help?") return - # Create a thread for this conversation if not already in one - thread = None if isinstance(message.channel, discord.Thread): thread = message.channel else: - # Create new thread - thread_name = self._generate_thread_name(content) + name = self._thread_name(content) if content else "Attachment" thread = await message.create_thread( - name=thread_name, - auto_archive_duration=1440 # 24 hours + name=name, + auto_archive_duration=1440, ) - # Handle the conversation in the thread - await self.handle_conversation(thread, message.author, content) + attachment_paths = await self._download_attachments(message, thread.id) + if attachment_paths: + lines = "\n".join(f"- {p}" for p in attachment_paths) + content = (content + f"\n\nUser attached files:\n{lines}").strip() - def _generate_thread_name(self, message: str) -> str: - """Generate a thread name from the first message.""" - # Take first 50 chars, remove newlines + if not content: + content = "User uploaded file(s) without a message." + + await self._run_turn(thread, content) + + @staticmethod + async def _download_attachments( + message: discord.Message, thread_id: int + ) -> list[Path]: + if not message.attachments: + return [] + + target_dir = ATTACHMENT_ROOT / str(thread_id) + target_dir.mkdir(parents=True, exist_ok=True) + + saved: list[Path] = [] + running_total = 0 + for att in message.attachments: + if att.size > MAX_FILE_BYTES: + logger.warning( + "[WARNING] skipping %s: %d bytes exceeds per-file cap %d", + att.filename, att.size, MAX_FILE_BYTES, + ) + continue + if running_total + att.size > MAX_TURN_BYTES: + logger.warning( + "[WARNING] skipping %s: would exceed per-turn cap %d", + att.filename, MAX_TURN_BYTES, + ) + continue + safe_name = Path(att.filename).name or f"attachment_{att.id}" + target = target_dir / safe_name + try: + await att.save(target) + except discord.HTTPException as e: + logger.warning("[WARNING] failed to download %s: %s", att.filename, e) + continue + saved.append(target) + running_total += att.size + logger.info("[INFO] saved attachment %s (%d bytes)", target, att.size) + return saved + + @staticmethod + def _cleanup_attachments(thread_id: int) -> None: + target_dir = ATTACHMENT_ROOT / str(thread_id) + if target_dir.exists(): + try: + shutil.rmtree(target_dir) + except OSError as e: + logger.warning("[WARNING] attachment cleanup failed for %d: %s", thread_id, e) + + @staticmethod + def _thread_name(message: str) -> str: name = message[:50].replace("\n", " ").strip() if len(message) > 50: name += "..." return name or "ClaudeTools Conversation" - async def handle_conversation( - self, - thread: discord.Thread, - user: discord.User, - user_message: str - ): - """Handle a conversation turn in a thread.""" - # Get or initialize conversation history for this thread - thread_id = thread.id - if thread_id not in self.conversations: - self.conversations[thread_id] = [] + async def _run_turn(self, thread: discord.Thread, user_message: str) -> None: + # The Claude Code CLI cold-start can take a few seconds; show feedback. + status_msg = await thread.send("[INFO] Thinking...") - # Add user message to history - self.conversations[thread_id].append({ - "role": "user", - "content": user_message - }) + agent = await self.agents.get_or_create(thread.id) - # Send initial "thinking" message - thinking_msg = await thread.send("⏳ Thinking...") + buffer: list[str] = [] + last_edit = 0.0 + lock = asyncio.Lock() - # Format system prompt - channel_name = thread.parent.name if thread.parent else "Unknown" - system_prompt = self.claude.format_system_prompt( - discord_user=user, - channel_name=channel_name, - thread_name=thread.name, - user_role="unknown" # TODO: Look up actual role from database - ) + async def on_text(chunk: str) -> None: + nonlocal last_edit + buffer.append(chunk) + now = asyncio.get_event_loop().time() + if now - last_edit < EDIT_THROTTLE_SECONDS: + return + async with lock: + last_edit = asyncio.get_event_loop().time() + preview = "".join(buffer) + if len(preview) > DISCORD_MAX_LEN: + preview = preview[-DISCORD_MAX_LEN:] + try: + await status_msg.edit(content=preview or "[INFO] Thinking...") + except discord.errors.NotFound: + pass - # Stream response from Claude - current_content = "" - - async def update_progress(text: str): - """Update the Discord message with progress.""" - nonlocal current_content - if text.startswith(("🔧", "⚙️", "✅", "❌")): - # Tool execution status update - await self._safe_edit(thinking_msg, text) - else: - # Text content update - current_content = text - await self._safe_edit(thinking_msg, current_content) - - async def execute_tool(tool_name: str, tool_input: dict) -> str: - """Execute a tool call (placeholder for now).""" - # TODO: Implement actual tool execution - return f"[Tool {tool_name} executed with {tool_input}]" + async def on_tool_use(tool_name: str) -> None: + # Server-side log only — Discord-side notices clutter the thread + # and ordering issues push them below the streaming answer. + logger.info("[INFO] thread=%d tool=%s", thread.id, tool_name) try: - # Stream the response - final_text, tool_results = await self.claude.stream_response( - messages=self.conversations[thread_id], - system_prompt=system_prompt, - tool_executor=execute_tool, - progress_callback=update_progress - ) - - # Format final response - response_text = final_text - - # Add tool results if any - if tool_results: - response_text += "\n\n**Tools Used:**\n" - for result in tool_results: - if "error" in result: - response_text += f"- ❌ {result['name']}: {result['error']}\n" - else: - response_text += f"- ✅ {result['name']}\n" - - # Update final message - await self._safe_edit(thinking_msg, response_text) - - # Add assistant response to history - self.conversations[thread_id].append({ - "role": "assistant", - "content": final_text - }) - - # Trim conversation history if too long (keep last 20 messages) - if len(self.conversations[thread_id]) > 20: - self.conversations[thread_id] = self.conversations[thread_id][-20:] - + full_text = await agent.send(user_message, on_text, on_tool_use) except Exception as e: - error_msg = f"❌ Error: {str(e)}" - await self._safe_edit(thinking_msg, error_msg) + logger.exception("[ERROR] Agent turn failed in thread %d", thread.id) + await status_msg.edit(content=f"[ERROR] {e}") + self._cleanup_attachments(thread.id) + return - async def _safe_edit(self, message: discord.Message, content: str): - """Safely edit a Discord message, handling 2000 char limit.""" - if len(content) <= 2000: - try: - await message.edit(content=content) - except discord.errors.NotFound: - # Message was deleted, ignore - pass - else: - # Split into multiple messages - chunks = self._split_message(content) - try: - await message.edit(content=chunks[0]) - # Send remaining chunks as new messages - for chunk in chunks[1:]: - await message.channel.send(chunk) - except discord.errors.NotFound: - pass + await self._post_final(status_msg, thread, full_text or "[INFO] (no response)") + self._cleanup_attachments(thread.id) - def _split_message(self, content: str, max_length: int = 2000) -> list[str]: - """Split a message into chunks under max_length.""" - if len(content) <= max_length: + async def _post_final( + self, + status_msg: discord.Message, + thread: discord.Thread, + text: str, + ) -> None: + chunks = self._split(text) + try: + await status_msg.edit(content=chunks[0]) + except discord.errors.NotFound: + await thread.send(chunks[0]) + for chunk in chunks[1:]: + await thread.send(chunk) + + @staticmethod + def _split(content: str, max_len: int = DISCORD_MAX_LEN) -> list[str]: + if len(content) <= max_len: return [content] - chunks = [] - current_chunk = "" - - # Split by lines to avoid breaking mid-sentence - lines = content.split("\n") - - for line in lines: - if len(current_chunk) + len(line) + 1 <= max_length: - current_chunk += line + "\n" + chunks: list[str] = [] + current = "" + for line in content.split("\n"): + # A single line longer than the limit must be hard-split mid-line. + while len(line) > max_len: + if current: + chunks.append(current.rstrip()) + current = "" + chunks.append(line[:max_len]) + line = line[max_len:] + if len(current) + len(line) + 1 > max_len: + chunks.append(current.rstrip()) + current = line + "\n" else: - if current_chunk: - chunks.append(current_chunk.rstrip()) - current_chunk = line + "\n" - - if current_chunk: - chunks.append(current_chunk.rstrip()) - + current += line + "\n" + if current: + chunks.append(current.rstrip()) return chunks diff --git a/projects/discord-bot/bot/main.py b/projects/discord-bot/bot/main.py index d50acc0..83eade8 100644 --- a/projects/discord-bot/bot/main.py +++ b/projects/discord-bot/bot/main.py @@ -2,30 +2,24 @@ import asyncio import logging import sys -from pathlib import Path import discord from discord.ext import commands from dotenv import load_dotenv from bot.config import settings -from bot.claude.client import ClaudeClient +from bot.claude.client import ClaudeAgentManager from bot.handlers.message_handler import MessageHandler -# Load environment variables load_dotenv() -# Configure logging logging.basicConfig( level=getattr(logging, settings.log_level.upper()), format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[ - logging.StreamHandler(sys.stdout), - ] + handlers=[logging.StreamHandler(sys.stdout)], ) -# Add file handler if log file specified if settings.log_file: settings.log_file.parent.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler(settings.log_file) @@ -37,99 +31,100 @@ if settings.log_file: logger = logging.getLogger(__name__) -# Discord bot setup with required intents intents = discord.Intents.default() -intents.message_content = True # Required to read message content +intents.message_content = True intents.guilds = True intents.members = True bot = commands.Bot(command_prefix="!", intents=intents) - -# Initialize Claude client -claude_client = ClaudeClient() - -# Initialize message handler -message_handler: MessageHandler = None +agent_manager = ClaudeAgentManager() +message_handler: MessageHandler | None = None @bot.event async def on_ready(): - """Called when the bot successfully connects to Discord.""" global message_handler - logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})") - logger.info(f"Connected to {len(bot.guilds)} guild(s)") + logger.info("[OK] Bot connected as %s (ID: %d)", bot.user.name, bot.user.id) + logger.info("[INFO] Connected to %d guild(s)", len(bot.guilds)) - # Validate paths try: settings.validate_paths() - logger.info("Path validation successful") - logger.info(f"Vault path: {settings.vault_path}") - logger.info(f"ClaudeTools root: {settings.claudetools_root}") - logger.info(f"Git Bash: {settings.git_bash_path}") + logger.info("[OK] ClaudeTools workspace: %s", settings.claudetools_root) except FileNotFoundError as e: - logger.error(f"Path validation failed: {e}") - logger.error("Bot will continue but some features may not work") + logger.error("[ERROR] Path validation failed: %s", e) - # Initialize message handler - message_handler = MessageHandler(bot, claude_client) + message_handler = MessageHandler(bot, agent_manager) - # Set bot status await bot.change_presence( activity=discord.Activity( type=discord.ActivityType.watching, - name="for @mentions | ClaudeTools MSP Assistant" + name="for @mentions | ClaudeTools Agent", ) ) - logger.info("Bot is ready and listening for mentions!") + logger.info("[OK] Bot is ready and listening for mentions") + + for guild in bot.guilds: + logger.info("[DEBUG] Guild: %s (id=%d) channels=%d", guild.name, guild.id, len(guild.text_channels)) + for ch in guild.text_channels[:10]: + perms = ch.permissions_for(guild.me) + logger.info("[DEBUG] #%s view=%s read=%s send=%s", ch.name, perms.view_channel, perms.read_message_history, perms.send_messages) @bot.event async def on_message(message: discord.Message): - """Called when a message is sent in a channel the bot can see.""" - # Ignore messages from bots + logger.info("[DEBUG] on_message fired: author=%s bot=%s channel=%s content_len=%d mentions=%d", + message.author.name, message.author.bot, getattr(message.channel, 'name', 'DM'), + len(message.content), len(message.mentions)) + if message.author.bot: return - # Check if bot was mentioned - if bot.user in message.mentions: + is_mention = bot.user in message.mentions + is_in_bot_thread = ( + isinstance(message.channel, discord.Thread) + and message.channel.owner_id == bot.user.id + ) + + if is_mention or is_in_bot_thread: + trigger = "mention" if is_mention else "thread-followup" logger.info( - f"Mentioned by {message.author.name} in #{message.channel.name}: " - f"{message.content[:100]}" + "[INFO] %s by %s in #%s: %s", + trigger, + message.author.name, + message.channel.name, + message.content[:100], ) await message_handler.handle_mention(message) return - # Process commands (for future slash commands) await bot.process_commands(message) @bot.event async def on_error(event: str, *args, **kwargs): - """Called when an error occurs.""" - logger.error(f"Error in {event}", exc_info=sys.exc_info()) + logger.error("[ERROR] Error in %s", event, exc_info=sys.exc_info()) async def main(): - """Main entry point.""" try: - logger.info("Starting ClaudeTools Discord Bot...") - logger.info(f"Claude Model: {settings.claude_model}") - logger.info(f"ClaudeTools API: {settings.claudetools_api_url}") + logger.info("[INFO] Starting ClaudeTools Discord Bot") + logger.info("[INFO] Claude model: %s", settings.claude_model) + logger.info("[INFO] Workspace: %s", settings.claudetools_root) - # Start the bot async with bot: await bot.start(settings.discord_token) except KeyboardInterrupt: - logger.info("Received keyboard interrupt, shutting down...") + logger.info("[INFO] Keyboard interrupt, shutting down") except Exception as e: - logger.error(f"Fatal error: {e}", exc_info=True) + logger.error("[ERROR] Fatal error: %s", e, exc_info=True) raise finally: - logger.info("Bot shut down complete") + await agent_manager.shutdown() + logger.info("[OK] Bot shut down complete") if __name__ == "__main__": diff --git a/projects/discord-bot/requirements.txt b/projects/discord-bot/requirements.txt index 4bdce0a..df43f52 100644 --- a/projects/discord-bot/requirements.txt +++ b/projects/discord-bot/requirements.txt @@ -1,24 +1,13 @@ # Discord Bot for ClaudeTools # Python 3.11+ -# Discord discord.py==2.3.2 -# Anthropic Claude API -anthropic==0.30.0 +claude-agent-sdk==0.1.72 -# HTTP Client -httpx==0.27.0 +pydantic>=2.11.0 +pydantic-settings>=2.5.2 -# Data Validation -pydantic==2.7.0 -pydantic-settings==2.3.0 - -# Async File I/O -aiofiles==23.2.1 - -# Environment Variables -python-dotenv==1.0.0 - -# Logging -structlog==24.1.0 +aiofiles>=23.2.1 +python-dotenv>=1.0.0 +structlog>=24.1.0 diff --git a/projects/radio-show/audio-processor/server/main.py b/projects/radio-show/audio-processor/server/main.py index c84b6fe..1f1494a 100644 --- a/projects/radio-show/audio-processor/server/main.py +++ b/projects/radio-show/audio-processor/server/main.py @@ -549,7 +549,8 @@ def _episode_html(episode_id: int) -> str: # the segment stream chronologically. qa_rows = [dict(r) for r in qa] qa_starts = sorted( - ((r["question_start_sec"] or 0.0), r) for r in qa_rows + (((r["question_start_sec"] or 0.0), r) for r in qa_rows), + key=lambda x: x[0], ) # Right rail summary lists @@ -595,7 +596,8 @@ def _episode_html(episode_id: int) -> str: # Intros also get inline anchors so the right-rail jump links work intro_by_time = sorted( - ((r["intro_time_sec"] or 0.0), r) for r in intros + (((r["intro_time_sec"] or 0.0), r) for r in intros), + key=lambda x: x[0], ) intro_iter = iter(intro_by_time) next_intro = next(intro_iter, None) diff --git a/projects/radio-show/session-logs/2026-05-01-ui-redesign-recovery.md b/projects/radio-show/session-logs/2026-05-01-ui-redesign-recovery.md index 5e3509f..6bbe58b 100644 --- a/projects/radio-show/session-logs/2026-05-01-ui-redesign-recovery.md +++ b/projects/radio-show/session-logs/2026-05-01-ui-redesign-recovery.md @@ -151,3 +151,93 @@ git push origin main - Episode page (renders fine): `http://172.16.3.20:8765/episode/139` - Audio endpoint (404 — file not deployed): `http://172.16.3.20:8765/api/audio/139` - Mike's reported broken URL with hash: `http://172.16.3.20:8765/episode/139#qa-377` + +--- + +## Update: 06:31 PT — Local deployment + intro/QA sort bug fix + +### What happened + +Mike was unclear on the relative status of the UI redesign vs. the audio-not-playing bug — they were two independent things and prior reports had conflated them. Clarified: redesign was committed (`d7ce9cb`) and pushed to Gitea but **never deployed to Jupiter**, so `172.16.3.20:8765` was still serving the prior visual design; and the audio 404 was a pre-existing Jupiter deployment gap (no `/data/episodes/` tree on that host) that was independent of any UI version. + +Mike chose to defer the Jupiter audio-tree fix and instead **deploy the new interface locally** so he could see the redesign with working audio. Local probe found everything needed already on disk under `projects/radio-show/audio-processor/`: + +- `.venv/Scripts/python.exe` (FastAPI 0.115.6, uvicorn 0.34.0 already installed) +- `archive-data/archive.db` — 572 episodes (full archive, not just the 6 test episodes from the 2026-04-27 session) +- `archive-data/episodes/` — full MP3 tree, including episode 139 (`2011/3 - March/3-26-11 HR 2.mp3`, 9.9 MB) + +Booted uvicorn at `127.0.0.1:8765` in the background: + +```bash +cd c:/Users/guru/ClaudeTools/projects/radio-show/audio-processor +ARCHIVE_DB=archive-data/archive.db EPISODES_DIR=archive-data/episodes PORT=8765 \ + .venv/Scripts/python.exe -m uvicorn server.main:app \ + --host 127.0.0.1 --port 8765 --log-level info +``` + +Smoke tests confirmed the new UI was live and audio worked end-to-end: + +``` +GET / : 200 (6 new-UI markers: --accent #c39733, browse-toggle, loading::after) +GET /episode/139 : 200 (8 new-UI markers: now-playing, preload="metadata", qaBlocks) +GET /api/audio/139 (0-127) : 206 audio/mpeg -- Range streaming working +``` + +Mike then loaded `http://127.0.0.1:8765/episode/479#qa-1134` and got **500 Internal Server Error**. Server traceback pinpointed: + +``` +File "server/main.py", line 597, in _episode_html + intro_by_time = sorted( + ((r["intro_time_sec"] or 0.0), r) for r in intros + ) +TypeError: '<' not supported between instances of 'sqlite3.Row' and 'sqlite3.Row' +``` + +Root cause: `sorted()` over `(float, sqlite3.Row)` tuples with no `key=`. When two intros share the same `intro_time_sec`, Python's tuple comparison falls through to the second element — `sqlite3.Row` does not implement `__lt__`, so it raises. Episode 479 happens to have an intro-time collision; episode 139 didn't, which is why the bug surfaced now and not earlier. The bug is **not** caused by today's UI redesign — the offending `sorted()` call predates it. Same bug existed at line 551 for `qa_starts` (where the second element is a `dict` from `[dict(r) for r in qa]`; dict comparison is also unsupported in Python 3) — would have surfaced eventually on a QA timestamp collision. + +Minimal fix: added `key=lambda x: x[0]` to both `sorted()` calls so the sort is strictly by timestamp. Ties are kept in DB-row order (stable sort), which is fine — the consumer (`_flush_inline_at`) only cares that items at-or-before the current segment time are flushed in non-decreasing order. + +After restart, retested: + +``` +GET /episode/479 : 200 +GET /episode/139 : 200 (regression check — still works) +GET /api/audio/479 (range) : 206 audio/mpeg +ep 479 page contains qa-1134 anchor: yes +``` + +The fix is currently uncommitted in the working tree; Mike has not yet OK'd a commit for it. + +### Key Decisions (this update) + +- **Use port 8765 locally** to mirror Jupiter's port — preserves any browser bookmarks / muscle memory; no conflict because the local server binds `127.0.0.1` while Jupiter is `172.16.3.20`. +- **`--host 127.0.0.1` (not `0.0.0.0`)** for the local server. No reason to expose this dev instance on the LAN; Jupiter is the canonical host. +- **Minimal-diff bug fix (`key=lambda x: x[0]`) over a refactor.** The wider `sorted(...) for r in ...` shape is fine; the only defect is the tie-break behavior. Changing the data shape (e.g. dropping the tuple, using `key=lambda r: r["intro_time_sec"] or 0.0`) would have rippled into the `next_intro[0]` / `next_intro[1]` indexing further down. Two-line fix landed instead. + +### Problems Encountered (this update) + +- **`/episode/479` returned 500.** Root cause analyzed above — pre-existing `sorted()` tie-break bug in `_episode_html`, exposed by ep 479's intro-time collision. Fixed at lines 551 and 597 of `main.py` by adding `key=lambda x: x[0]`. + +### Configuration Changes (this update) + +#### Files modified (uncommitted) +- `projects/radio-show/audio-processor/server/main.py` — added `key=lambda x: x[0]` to both `sorted()` calls at lines 551–554 (qa_starts) and 597–600 (intro_by_time). Net: +4 / −2. + +#### Background process +- `uvicorn` running locally on `127.0.0.1:8765`. Bash background task ID `bj1leiit0`. Log at `/tmp/radio-server.log`. Will need to be killed when Mike's done viewing (`taskkill //F //PID ` or just close the terminal). + +### Pending / Incomplete Tasks (this update) + +- [ ] **Commit the intro/QA sort tie-break fix.** Two-line diff at lines 551 and 597 of `server/main.py`. Suggested commit subject: `radio: fix episode page 500 when intro/QA timestamps collide`. Awaiting Mike's OK. +- [ ] **Kill the local uvicorn** (`bj1leiit0`) when Mike is done viewing. PID will be in `/tmp/radio-server.log` first line ("Started server process [N]"). +- [ ] **(Carried) Audio fix for Jupiter** — still deferred per Mike's "we'll deal with the Jupiter file tree later." Three options unchanged: rsync archive (~30–40 GB), proxy `/api/audio/{id}` to IX, point `