Explore the codebase at /root/projects/butler. Focus on:
1. The overall directory structure (top-level and key subdirectories)
2. Any "obsidian" related files/directories and their structure
3. Existing "mode" implementations (mode_write, mode_reply, mode_zhihu, mode_note, etc.)
4. How the Slack bot is structured (slack_bot/ directory)
5. Any existing vector DB or embedding code
6. The LLM client setup (how gemini/openrouter is called)

Please provide detailed file listings and key code excerpts from the most important files. Look especially for patterns in how "modes" are implemented and how they call the LLM.


I'll explore the butler codebase systematically to understand its structure, modes, and LLM integration. Let me start with a broad overview and then dive into specific areas.


Bash

find /root/projects/butler -type f -name "*.py" | head -30

Get a sample of Python files in the butler project


ls -la /root/projects/butler/ | grep -E "^d"

List top-level directories in butler


tree -L 2 /root/projects/butler -d 2>/dev/null || find /root/projects/butler -maxdepth 2 -type d | sort

Show directory tree structure


/root/projects/butler
├── data
│   └── health
├── docs
│   └── milestones
├── experiments
├── health
│   ├── analytics
│   ├── cli
│   ├── db
│   ├── models
│   ├── __pycache__
│   ├── services
│   └── utils
├── htmlcov
├── logs
├── scripts
│   └── __pycache__
├── skills
├── slack_bot
│   ├── context
│   ├── llm
│   ├── obsidian
│   ├── __pycache__
│   ├── shell
│   ├── tools
│   └── utils
├── tag20260215
│   ├── docs
│   ├── experiments
│   ├── health
│   ├── logs
│   ├── scripts
│   ├── skills
│   ├── slack_bot
│   ├── tests
│   ├── tools
│   └── zhihu-sample
├── tag20260302
│   ├── docs
│   ├── experiments
│   ├── health
│   ├── scripts
│   ├── skills
│   ├── slack_bot
│   ├── tests
│   ├── tools
│   └── zhihu-sample
├── tests
│   ├── health
│   ├── integration
│   ├── __pycache__
│   ├── slack_bot
│   └── unit
├── tools
├── venv
│   ├── bin
│   ├── include
│   ├── lib
│   └── lib64 -> lib
└── zhihu-sample

60 directories


/root/projects/butler/experiments/compare_dispatchers.py
/root/projects/butler/experiments/dispatcher_clean.py
/root/projects/butler/experiments/test_llm_intelligence.py
/root/projects/butler/health/analytics/__init__.py
/root/projects/butler/health/analytics/engine.py
/root/projects/butler/health/analytics/weekly_report.py
/root/projects/butler/health/cli/__init__.py
/root/projects/butler/health/cli/manual.py
/root/projects/butler/health/cli/obsidian.py
/root/projects/butler/health/cli/query.py
/root/projects/butler/health/cli/sync.py
/root/projects/butler/health/db/__init__.py
/root/projects/butler/health/db/repository.py
/root/projects/butler/health/db/schema.py
/root/projects/butler/health/models/__init__.py
/root/projects/butler/health/models/activity.py
/root/projects/butler/health/models/body_metrics.py
/root/projects/butler/health/models/daily_metrics.py
/root/projects/butler/health/models/manual_log.py
/root/projects/butler/health/models/sync_metadata.py
/root/projects/butler/health/services/__init__.py
/root/projects/butler/health/services/data_sync.py
/root/projects/butler/health/services/garmin_client.py
/root/projects/butler/health/services/manual_log_storage.py
/root/projects/butler/health/services/obsidian.py
/root/projects/butler/health/services/query.py
/root/projects/butler/health/services/storage.py
/root/projects/butler/health/utils/__init__.py
/root/projects/butler/health/utils/date_utils.py
/root/projects/butler/health/utils/env_loader.py


drwxr-xr-x 20 root root    4096 Mar  2 17:44 .
drwxr-xr-x  4  501 games     48 Mar  2 09:16 ..
drwxr-xr-x  3  501 games     37 Jan 22 10:06 .agent
drwxr-xr-x  3  501 games     49 Mar  2 19:08 .claude
drwxr-xr-x  3  501 games     54 Feb  6 15:21 data
drwxr-xr-x  3  501 games   4096 Mar  2 23:18 docs
drwxr-xr-x  2  501 games    168 Mar  2 09:41 experiments
drwxr-xr-x  9  501 games    142 Mar  2 17:26 health
drwxr-xr-x  2 root root    4096 Mar  2 17:44 htmlcov
drwxr-xr-x  2  501 games    179 Mar  3 08:00 logs
drwxr-xr-x  3  501 games     70 Jan 20 08:58 .pytest_cache
drwxr-xr-x  3  501 games   4096 Mar  2 15:45 scripts
drwxr-xr-x  2  501 games    139 Jan 20 17:38 skills
drwxr-xr-x  9  501 games   4096 Mar  2 16:56 slack_bot
drwxr-xr-x 12  501 games   4096 Feb 15 10:47 tag20260215
drwxr-xr-x 14 root root    4096 Mar  2 10:38 tag20260302
drwxr-xr-x  7  501 games   4096 Mar  2 17:42 tests
drwxr-xr-x  2  501 games     24 Feb  5 18:21 tools
drwxr-xr-x  5 root root      74 Mar  2 09:14 venv
drwxr-xr-x  2  501 games    153 Feb  4 10:44 zhihu-sample


Now let me explore the key directories in detail:


find /root/projects/butler/slack_bot -type f -name "*.py" | head -30

List Python files in slack_bot directory


find /root/projects/butler -type f -name "*mode*" -o -name "*dispatch*" -o -name "*llm*" | grep -v __pycache__

Search for mode, dispatch, and LLM files


find /root/projects/butler -type d -name "obsidian*" -o -type f -name "*obsidian*" | grep -v __pycache__

Find obsidian-related files and directories


/root/projects/butler/slack_bot/context/compressor.py
/root/projects/butler/slack_bot/context/storage.py
/root/projects/butler/slack_bot/llm/base.py
/root/projects/butler/slack_bot/llm/gemini.py
/root/projects/butler/slack_bot/obsidian/__init__.py
/root/projects/butler/slack_bot/obsidian/cli.py
/root/projects/butler/slack_bot/obsidian/dispatcher.py
/root/projects/butler/slack_bot/obsidian/generators.py
/root/projects/butler/slack_bot/obsidian/indexer.py
/root/projects/butler/slack_bot/shell/__init__.py
/root/projects/butler/slack_bot/shell/help_text.py
/root/projects/butler/slack_bot/shell/manager.py
/root/projects/butler/slack_bot/shell/session.py
/root/projects/butler/slack_bot/tools/analytics.py
/root/projects/butler/slack_bot/tools/file_ops.py
/root/projects/butler/slack_bot/tools/garmin.py
/root/projects/butler/slack_bot/tools/groups.py
/root/projects/butler/slack_bot/tools/health_read.py
/root/projects/butler/slack_bot/tools/health_write.py
/root/projects/butler/slack_bot/tools/obsidian.py
/root/projects/butler/slack_bot/tools/process_ops.py
/root/projects/butler/slack_bot/tools/registry.py
/root/projects/butler/slack_bot/tools/shell_tool.py
/root/projects/butler/slack_bot/tools/system_ops.py
/root/projects/butler/slack_bot/tools/web.py
/root/projects/butler/slack_bot/utils/mrkdwn.py
/root/projects/butler/slack_bot/__init__.py
/root/projects/butler/slack_bot/dispatcher.backup.py
/root/projects/butler/slack_bot/dispatcher.py
/root/projects/butler/slack_bot/main.py


/root/projects/butler/data/health/obsidian_context
/root/projects/butler/health/cli/obsidian.py
/root/projects/butler/health/services/obsidian.py
/root/projects/butler/logs/obsidian.log
/root/projects/butler/scripts/obsidian_bot.py
/root/projects/butler/scripts/obsidian_distiller.py
/root/projects/butler/scripts/obsidian_tool.py
/root/projects/butler/scripts/verify_obsidian_tools.py
/root/projects/butler/skills/obsidian_sync.py
/root/projects/butler/slack_bot/obsidian
/root/projects/butler/slack_bot/tools/obsidian.py
/root/projects/butler/tag20260215/health/cli/obsidian.py
/root/projects/butler/tag20260215/health/services/obsidian.py
/root/projects/butler/tag20260215/logs/obsidian.log
/root/projects/butler/tag20260215/scripts/obsidian_bot.py
/root/projects/butler/tag20260215/scripts/obsidian_distiller.py
/root/projects/butler/tag20260215/scripts/obsidian_tool.py
/root/projects/butler/tag20260215/scripts/verify_obsidian_tools.py
/root/projects/butler/tag20260215/skills/obsidian_sync.py
/root/projects/butler/tag20260215/slack_bot/obsidian
/root/projects/butler/tag20260215/slack_bot/tools/obsidian.py
/root/projects/butler/tag20260215/tests/test_obsidian_module.py
/root/projects/butler/tests/test_obsidian_module.py
/root/projects/butler/tag20260302/health/cli/obsidian.py
/root/projects/butler/tag20260302/health/services/obsidian.py
/root/projects/butler/tag20260302/scripts/obsidian_bot.py
/root/projects/butler/tag20260302/scripts/obsidian_distiller.py
/root/projects/butler/tag20260302/scripts/obsidian_tool.py
/root/projects/butler/tag20260302/scripts/verify_obsidian_tools.py
/root/projects/butler/tag20260302/skills/obsidian_sync.py
/root/projects/butler/tag20260302/slack_bot/obsidian
/root/projects/butler/tag20260302/slack_bot/tools/obsidian.py
/root/projects/butler/tag20260302/tests/test_obsidian_module.py
/root/projects/butler/htmlcov/z_448e93f87f0aa4cf_obsidian_py.html
/root/projects/butler/htmlcov/z_949674b32aa55708_obsidian_py.html


Now let me read the key files to understand the structure:


     1→import os
     2→import json
     3→from datetime import date
     4→from typing import List, Dict, Any, Optional, Tuple
     5→from .base import BaseLLM
     6→from health.utils.logging_config import setup_logger
     7→
     8→logger = setup_logger(__name__)
     9→
    10→# Base system prompt for health assistant
    11→BUTLER_SYSTEM_INSTRUCTION_BASE = """You are Butler, a personal health assistant for tracking and analyzing health data.
    12→
    13→You help the user manage:
    14→- Sleep quality and patterns (from Garmin watch)
    15→- Exercise data: steps, activities, heart rate
    16→- Diet logs: meals, supplements, fasting modes (OMAD, PSMF)
    17→- Diet logs: meals, supplements, fasting modes (OMAD, PSMF)
    18→- Body feelings and symptoms
    19→
    20→CRITICAL ANTI-HALLUCINATION RULES:
    21→1. **TOOL-FIRST POLICY**: When user asks about CURRENT or SPECIFIC health data (e.g., "how did I sleep last night?", "what was my steps yesterday?"), you MUST call the appropriate tool FIRST. NEVER answer based on conversation history or memory.
    22→2. **NO DATA INVENTION**: If a tool returns "No data found" or null values, YOU MUST SAY "I cannot find data for this date" or "No data available". Do NOT guess, estimate, or invent numbers.
    23→3. **VISION LIMITATIONS**: When analyzing images, if unclear or unreadable, say "I cannot identify the food in this image". Do NOT guess ingredients you do not see.
    24→4. **EXPLICIT UNCERTAINTY**: If you're unsure whether data exists, call the tool to check. Say "Let me check your data" rather than guessing.
    25→
    26→Guidelines:
    27→- Be concise and data-driven
    28→- When user wants to log something (diet, supplement, etc), use the appropriate tool
    29→- Provide actionable insights based ONLY on retrieved data
    30→- Support both English and Chinese
    31→
    32→CONVERSATIONAL CONTEXT HANDLING:
    33→- When user gives vague confirmation replies like "好的，可以记录" / "ok, log it" / "yes, record that":
    34→  1. FIRST check the conversation history to see what they were discussing
    35→  2. If they mentioned food/diet in recent messages, extract the details and call `log_diet` with:
    36→     - description: the food items they mentioned
    37→     - meal_type: infer from context (breakfast/lunch/dinner/snack)
    38→     - target_date: today unless specified otherwise
    39→  3. If you cannot extract clear details, politely ask: "请问要记录什么内容？可以告诉我具体吃了什么吗？"
    40→- NEVER return empty responses. If unsure what to do, ask a clarifying question.
    41→
    42→WEB SEARCH USAGE:
    43→- You have access to real-time web search via `search_web`.
    44→- USE IT WHEN:
    45→  1. User asks for "latest" research, news, or biohacking trends.
    46→  2. You need to verify external facts (e.g. "benefits of berberine").
    47→  3. User specifically asks to check online.
    48→- DO NOT USE IT WHEN:
    49→  1. User asks for PERSONAL data (steps, sleep). Use `health_read` tools.
    50→
    51→Available data sources:
    52→- Garmin health metrics (sleep, steps, heart rate, etc.)
    53→- Manual logs (diet, supplements, fasting, body feelings)
    54→- Real-time Web Search
    55→- Obsidian daily notes (can be synced)
    56→
    57→5. **ACTION COMMANDS**: If the user asks to "sync", "update", or "fetch" data, you MUST call the corresponding tool (e.g., `sync_garmin`). Do NOT say "done" without calling the tool. If the tool call fails or you cannot find the tool, report the error.
    58→
    59→IMPORTANT: Today's date is {today}. When logging data without a specified date, use today's date."""
    60→
    61→
    62→from health.utils.time_utils import get_current_time_str
    63→
    64→def get_system_instruction() -> str:
    65→    """Get system instruction with current date and health profile."""
    66→    # Use UTC+8 string directly
    67→    base_prompt = BUTLER_SYSTEM_INSTRUCTION_BASE.format(today=get_current_time_str())
    68→
    69→    # Try to load custom health profile
    70→    try:
    71→        health_md_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "health.md")
    72→        if os.path.exists(health_md_path):
    73→            with open(health_md_path, "r", encoding="utf-8") as f:
    74→                health_profile = f.read()
    75→            return f"{base_prompt}\n\n=== USER HEALTH PROTOCOLS ===\n{health_profile}"
    76→    except Exception as e:
    77→        logger.error(f"Failed to load health.md: {e}")
    78→
    79→    return base_prompt
    80→
    81→
    82→class GeminiLLM(BaseLLM):
    83→    """Gemini Implementation via OpenAI-compatible API (for local proxy support)."""
    84→
    85→    def __init__(self, system_instruction: Optional[str] = None):
    86→        api_key = os.environ.get("GEMINI_API_KEY")
    87→        base_url = os.environ.get("GEMINI_BASE_URL")
    88→
    89→        if not api_key:
    90→            logger.error("GEMINI_API_KEY not found in environment.")
    91→            raise ValueError("GEMINI_API_KEY not found")
    92→
    93→        # Determine system instruction (default to Health/Butler if not provided)
    94→        self.system_instruction = system_instruction if system_instruction else get_system_instruction()
    95→
    96→        # Use OpenAI client for OpenAI-compatible proxies
    97→        try:
    98→            from openai import OpenAI
    99→        except ImportError:
   100→            logger.error("openai package not found. Install with: pip install openai")
   101→            raise
   102→
   103→        # Initialize OpenAI client pointing to local proxy or OpenRouter
   104→        if base_url:
   105→            # Check if using OpenRouter (requires special headers)
   106→            is_openrouter = "openrouter.ai" in base_url.lower()
   107→
   108→            if is_openrouter:
   109→                # OpenRouter requires HTTP-Referer and X-Title headers
   110→                headers = {
   111→                    "HTTP-Referer": "https://github.com/your-username/butler",
   112→                    "X-Title": "Butler Health Assistant"
   113→                }
   114→
   115→                # Add Google API key if available to bypass rate limits
   116→                google_key = os.environ.get("GOOGLE_API_KEY")
   117→                if google_key:
   118→                    headers["X-Google-API-Key"] = google_key
   119→                    logger.info("Using Google API key for OpenRouter")
   120→
   121→                self.client = OpenAI(
   122→                    api_key=api_key,
   123→                    base_url=f"{base_url}/v1",
   124→                    default_headers=headers
   125→                )
   126→                logger.info(f"Using OpenRouter: {base_url}")
   127→            else:
   128→                self.client = OpenAI(api_key=api_key, base_url=f"{base_url}/v1")
   129→                logger.info(f"Using proxy: {base_url}")
   130→        else:
   131→            # Fallback: use Google's generativeai for direct connection
   132→            import google.generativeai as genai
   133→            genai.configure(api_key=api_key)
   134→            self.client = None
   135→            self.genai_model = genai.GenerativeModel(
   136→                os.environ.get("GEMINI_MODEL", "gemini-3-flash"),
   137→                system_instruction=self.system_instruction
   138→            )
   139→            logger.info("Using direct Google API")
   140→
   141→        self.model_name = os.environ.get("GEMINI_MODEL", "gemini-3-flash")
   142→        self.use_proxy = bool(base_url)
   143→
   144→        logger.info(f"Initialized Gemini model: {self.model_name}")
   145→
   146→    def get_model_name(self) -> str:
   147→        return self.model_name
   148→
   149→    def generate_response(
   150→        self,
   151→        message: str,
   152→        context: List[Dict[str, Any]],
   153→        tools: Optional[List[Dict[str, Any]]] = None,
   154→        images: Optional[List[Dict[str, Any]]] = None
   155→    ) -> Tuple[str, Optional[List[Dict]]]:
   156→        """
   157→        Generate response with optional tool calling and images.
   158→
   159→        Args:
   160→            message: Text prompt
   161→            context: Chat history
   162→            tools: Tool definitions
   163→            images: List of dicts {'mime_type': str, 'data': bytes} (for direct API)
   164→
   165→        Returns:
   166→            Tuple of (response_text, tool_calls)
   167→        """
   168→        try:
   169→            if self.use_proxy:
   170→                return self._generate_with_openai(message, context, tools, images)
   171→            else:
   172→                return self._generate_with_google(message, context, tools, images)
   173→
   174→        except Exception as e:
   175→            logger.error(f"Gemini generation error: {e}")
   176→            return f"[Gemini Error] {str(e)}", None
   177→
   178→        return text_response, tool_calls
   179→
   180→    def _retry_with_backoff(self, func, *args, **kwargs):
   181→        """Simple retry logic with exponential backoff."""
   182→        import time
   183→        import random
   184→
   185→        max_retries = 3
   186→        base_delay = 2
   187→
   188→        for attempt in range(max_retries + 1):
   189→            try:
   190→                return func(*args, **kwargs)
   191→            except Exception as e:
   192→                error_str = str(e)
   193→                # Check for known transient errors
   194→                is_transient = (
   195→                    "Token acquisition timeout" in error_str or
   196→                    "limit" in error_str.lower() or
   197→                    "busy" in error_str.lower() or
   198→                    "500" in error_str or
   199→                    "503" in error_str or
   200→                    "deadlock" in error_str.lower()
   201→                )
   202→
   203→                if not is_transient or attempt == max_retries:
   204→                    raise e
   205→
   206→                delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
   207→                logger.warning(f"API Request failed ({error_str}). Retrying in {delay:.2f}s... (Attempt {attempt+1}/{max_retries})")
   208→                time.sleep(delay)
   209→
   210→    def _generate_with_openai(
   211→        self,
   212→        message: str,
   213→        context: List[Dict[str, Any]],
   214→        tools: Optional[List[Dict[str, Any]]],
   215→        images: Optional[List[Dict[str, Any]]] = None
   216→    ) -> Tuple[str, Optional[List[Dict]]]:
   217→        """Generate using OpenAI-compatible API (for proxy)."""
   218→        # Refresh system instruction with current date/time on every request.
   219→        # This prevents the LLM from using a stale date when the bot runs across midnight.
   220→        current_system_instruction = get_system_instruction()
   221→        messages = [{"role": "system", "content": current_system_instruction}]
   222→        messages.extend(context)
   223→
   224→        if images:
   225→            import base64
   226→
   227→            content_list = []
   228→            if message:
   229→                content_list.append({"type": "text", "text": message})
   230→
   231→            for img in images:
   232→                # Convert bytes to base64
   233→                b64_data = base64.b64encode(img["data"]).decode("utf-8")
   234→                mime_type = img["mime_type"]
   235→
   236→                content_list.append({
   237→                    "type": "image_url",
   238→                    "image_url": {
   239→                        "url": f"data:{mime_type};base64,{b64_data}"
   240→                    }
   241→                })
   242→
   243→            messages.append({"role": "user", "content": content_list})
   244→        else:
   245→            if message:
   246→                messages.append({"role": "user", "content": message})
   247→
   248→        # Call API
   249→        kwargs = {
   250→            "model": self.model_name,
   251→            "messages": messages,
   252→        }
   253→
   254→        if tools:
   255→            kwargs["tools"] = tools
   256→            # NOTE: Removed tool_choice parameter - some proxies don't handle it correctly
   257→            # kwargs["tool_choice"] = "auto"
   258→
   259→        try:
   260→            # DEBUG: Log payload for diagnosis
   261→            import json
   262→            debug_mode = os.environ.get("DEBUG_GEMINI_PAYLOAD", "").lower() in ["true", "1", "yes"]
   263→            if debug_mode:
   264→                payload_preview = json.dumps(kwargs, default=str, ensure_ascii=False)
   265→                logger.info(f"[DEBUG] Payload size: {len(payload_preview)} chars")
   266→                logger.info(f"[DEBUG] Model: {kwargs.get('model')}")
   267→                logger.info(f"[DEBUG] Messages: {len(kwargs.get('messages', []))}")
   268→                logger.info(f"[DEBUG] Tools: {len(kwargs.get('tools', []))}")
   269→
   270→                # Save full payload for inspection
   271→                debug_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug_request.json")
   272→                with open(debug_file, "w") as f:
   273→                    json.dump(kwargs, f, indent=2, default=str, ensure_ascii=False)
   274→                logger.info(f"[DEBUG] Full payload saved to {debug_file}")
   275→
   276→            # Use retry wrapper
   277→            response = self._retry_with_backoff(self.client.chat.completions.create, **kwargs)
   278→
   279→            # PROXY WORKAROUND: If response is empty and we provided tools, retry without tools
   280→            message_obj = response.choices[0].message
   281→            text_response = message_obj.content or ""
   282→            has_tool_calls = hasattr(message_obj, 'tool_calls') and message_obj.tool_calls
   283→
   284→            if tools and not text_response.strip() and not has_tool_calls:
   285→                logger.warning("Empty response with tools - retrying WITHOUT tools as workaround")
   286→                kwargs_no_tools = {
   287→                    "model": self.model_name,
   288→                    "messages": messages,
   289→                }
   290→                try:
   291→                    response = self._retry_with_backoff(self.client.chat.completions.create, **kwargs_no_tools)
   292→                    message_obj = response.choices[0].message
   293→                    text_response = message_obj.content or ""
   294→                    logger.info(f"Retry without tools: got {len(text_response)} chars")
   295→                except Exception as retry_error:
   296→                    logger.error(f"Retry without tools also failed: {retry_error}")
   297→
   298→        except Exception as api_error:
   299→            logger.error(f"OpenAI API Request Failed: {api_error}")
   300→
   301→            # DEBUG: Dump payload to file for inspection
   302→            try:
   303→                debug_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug_failed_payload.json")
   304→                with open(debug_file, "w") as f:
   305→                    import json
   306→                    json.dump(kwargs, f, indent=2, default=str)
   307→                logger.error(f"Dumped failed payload to {debug_file}")
   308→            except Exception as dump_err:
   309→                logger.error(f"Failed to dump payload: {dump_err}")
   310→
   311→            # If the API response itself is malformed (e.g. proxy returns bad JSON),
   312→            # the openai client raises a JSONDecodeError (which has 'Extra data' etc).
   313→            error_str = str(api_error)
   314→            if len(error_str) > 200:
   315→                # Check for HTML content in error (common with proxy auth failures)
   316→                if "<html" in error_str.lower() or "<!doctype" in error_str.lower():
   317→                     error_str = error_str[:100] + "... [HTML Error Truncated]"
   318→                else:
   319→                     error_str = error_str[:200] + "... [Truncated]"
   320→
   321→            return f"[Gemini API Error] {error_str}", None
   322→
   323→        try:
   324→            # Extract response
   325→            message_obj = response.choices[0].message
   326→            text_response = message_obj.content or ""
   327→
   328→            # DEBUG: Log response details
   329→            debug_mode = os.environ.get("DEBUG_GEMINI_PAYLOAD", "").lower() in ["true", "1", "yes"]
   330→            if debug_mode:
   331→                logger.info(f"[DEBUG] Response text length: {len(text_response)}")
   332→                logger.info(f"[DEBUG] Response has tool_calls: {hasattr(message_obj, 'tool_calls') and message_obj.tool_calls}")
   333→                logger.info(f"[DEBUG] Response preview: {text_response[:200] if text_response else '(empty)'}")
   334→
   335→                # Save response for inspection
   336→                debug_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "debug_response.json")
   337→                with open(debug_file, "w") as f:
   338→                    import json
   339→                    json.dump({
   340→                        "text": text_response,
   341→                        "has_tool_calls": hasattr(message_obj, 'tool_calls') and bool(message_obj.tool_calls),
   342→                        "tool_calls_count": len(message_obj.tool_calls) if hasattr(message_obj, 'tool_calls') and message_obj.tool_calls else 0,
   343→                        "finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None,
   344→                    }, f, indent=2, ensure_ascii=False)
   345→                logger.info(f"[DEBUG] Response details saved to {debug_file}")
   346→
   347→            # Extract tool calls
   348→            tool_calls = None
   349→            if hasattr(message_obj, 'tool_calls') and message_obj.tool_calls:
   350→                tool_calls = []
   351→                for tc in message_obj.tool_calls:
   352→                    # Ensure arguments is a string before parsing
   353→                    args_str = tc.function.arguments
   354→                    if not isinstance(args_str, str):
   355→                        logger.warning(f"Tool arguments not a string: {type(args_str)}")
   356→                        args_str = str(args_str)
   357→
   358→                    tool_calls.append({
   359→                        "name": tc.function.name,
   360→                        "args": self._safe_parse_json(args_str)
   361→                    })
   362→
   363→            return text_response, tool_calls
   364→
   365→        except Exception as parse_error:
   366→            logger.error(f"Error parsing OpenAI response object: {parse_error}")
   367→            return f"[Gemini Response Parse Error] {str(parse_error)}", None
   368→
   369→        return text_response, tool_calls
   370→
   371→    def _safe_parse_json(self, json_str: str) -> Dict[str, Any]:
   372→        """Parse JSON with fallback for common LLM formatting errors."""
   373→        if not json_str:
   374→            return {}
   375→
   376→        try:
   377→            return json.loads(json_str)
   378→        except Exception as e:
   379→            # Log the problematic string for debugging
   380→            logger.warning(f"JSON parse error: {e}. String: {json_str[:100]}...")
   381→
   382→            # Try to decode just the first valid JSON object using raw_decode
   383→            try:
   384→                decoder = json.JSONDecoder()
   385→                obj, _ = decoder.raw_decode(json_str)
   386→                logger.info("Successfully recovered JSON using raw_decode")
   387→                return obj
   388→            except Exception:
   389→                pass
   390→
   391→            # Try finding the first '{'
   392→            try:
   393→                start_idx = json_str.find("{")
   394→                if start_idx != -1:
   395→                    decoder = json.JSONDecoder()
   396→                    obj, _ = decoder.raw_decode(json_str[start_idx:])
   397→                    logger.info("Successfully recovered JSON by skipping prefix")
   398→                    return obj
   399→            except Exception:
   400→                pass
   401→
   402→            logger.error(f"Failed to recover JSON arguments. Returning empty dict.")
   403→            return {}
   404→
   405→    def _generate_with_google(
   406→        self,
   407→        message: str,
   408→        context: List[Dict[str, Any]],
   409→        tools: Optional[List[Dict[str, Any]]],
   410→        images: Optional[List[Dict[str, Any]]] = None
   411→    ) -> Tuple[str, Optional[List[Dict]]]:
   412→        """Generate using Google's generativeai (direct connection)."""
   413→        import google.generativeai as genai
   414→
   415→        # Convert context to Gemini format
   416→        history = []
   417→        for msg in context:
   418→            role = "user" if msg["role"] == "user" else "model"
   419→            history.append({"role": role, "parts": [msg["content"]]})
   420→
   421→        # Convert tools to Gemini format
   422→        gemini_tools = None
   423→        if tools:
   424→            gemini_tools = self._convert_tools_to_gemini(tools)
   425→
   426→        chat = self.genai_model.start_chat(history=history)
   427→
   428→        # Prepare content parts (Text + Images)
   429→        content_parts = [message]
   430→        if images:
   431→            logger.info(f"Adding {len(images)} images to request")
   432→            for img in images:
   433→                content_parts.append({
   434→                    "mime_type": img["mime_type"],
   435→                    "data": img["data"]
   436→                })
   437→
   438→        # Send message
   439→        try:
   440→            if gemini_tools:
   441→                response = chat.send_message(
   442→                    content_parts,
   443→                    tools=gemini_tools,
   444→                    tool_config={'function_calling_config': 'AUTO'}
   445→                )
   446→            else:
   447→                response = chat.send_message(content_parts)
   448→        except Exception as e:
   449→            # Handle cases where image upload fails or model rejects it
   450→            logger.error(f"Gemini send_message failed (with images={bool(images)}): {e}")
   451→            return f"[Error] Failed to generate response: {e}", None
   452→
   453→        # Extract tool calls
   454→        tool_calls = None
   455→        if response.candidates and response.candidates[0].content.parts:
   456→            for part in response.candidates[0].content.parts:
   457→                if hasattr(part, 'function_call') and part.function_call:
   458→                    if tool_calls is None:
   459→                        tool_calls = []
   460→                    fc = part.function_call
   461→                    tool_calls.append({
   462→                        "name": fc.name,
   463→                        "args": dict(fc.args)
   464→                    })
   465→
   466→        # Get text (when there are function calls, response.text might fail)
   467→        try:
   468→            text_response = response.text
   469→        except Exception as e:
   470→            # This is normal when response only contains function calls
   471→            logger.debug(f"No text in response (likely only function calls): {e}")
   472→            text_response = ""
   473→
   474→        return text_response, tool_calls
   475→
   476→    def _convert_tools_to_gemini(self, openai_tools: List[Dict[str, Any]]) -> List:
   477→        """Convert OpenAI-style tools to Gemini format (for direct Google API)."""
   478→        import google.generativeai as genai
   479→
   480→        gemini_functions = []
   481→        for tool in openai_tools:
   482→            if tool.get("type") == "function":
   483→                func_def = tool["function"]
   484→                gemini_functions.append(
   485→                    genai.protos.FunctionDeclaration(
   486→                        name=func_def["name"],
   487→                        description=func_def["description"],
   488→                        parameters=genai.protos.Schema(
   489→                            type=genai.protos.Type.OBJECT,
   490→                            properties={
   491→                                k: genai.protos.Schema(
   492→                                    type=self._convert_type(v.get("type", "string")),
   493→                                    description=v.get("description", "")
   494→                                )
   495→                                for k, v in func_def["parameters"]["properties"].items()
   496→                            },
   497→                            required=func_def["parameters"].get("required", [])
   498→                        )
   499→                    )
   500→                )
   501→
   502→        if gemini_functions:
   503→            return [genai.protos.Tool(function_declarations=gemini_functions)]
   504→        return None
   505→
   506→    def _convert_type(self, openai_type: str):
   507→        """Convert OpenAI type to Gemini type."""
   508→        import google.generativeai as genai
   509→
   510→        type_mapping = {
   511→            "string": genai.protos.Type.STRING,
   512→            "number": genai.protos.Type.NUMBER,
   513→            "integer": genai.protos.Type.INTEGER,
   514→            "boolean": genai.protos.Type.BOOLEAN,
   515→            "object": genai.protos.Type.OBJECT,
   516→            "array": genai.protos.Type.ARRAY
   517→        }
   518→        return type_mapping.get(openai_type.lower(), genai.protos.Type.STRING)
   519→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→from abc import ABC, abstractmethod
     2→from typing import List, Dict, Any, Optional
     3→
     4→class BaseLLM(ABC):
     5→    """Abstract base class for LLM implementations."""
     6→
     7→    @abstractmethod
     8→    def get_model_name(self) -> str:
     9→        """Return the display name of the model."""
    10→        pass
    11→
    12→    @abstractmethod
    13→    def generate_response(
    14→        self,
    15→        message: str,
    16→        context: List[Dict[str, Any]],
    17→        tools: Optional[List[Dict[str, Any]]] = None
    18→    ) -> str:
    19→        """
    20→        Generate a response for the given message and context.
    21→
    22→        Args:
    23→            message: The user's input message.
    24→            context: Conversation history (list of dicts with 'role' and 'content').
    25→            tools: Optional list of tool definitions.
    26→
    27→        Returns:
    28→            The text response from the LLM.
    29→        """
    30→        pass
    31→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import os
     2→import sys
     3→import click
     4→from slack_bolt import App
     5→from slack_bolt.adapter.socket_mode import SocketModeHandler
     6→from dotenv import load_dotenv
     7→
     8→# Add project root to path
     9→sys.path.append(os.getcwd())
    10→
    11→from health.utils.logging_config import setup_logger
    12→from health.utils.env_loader import load_env_with_extras
    13→from slack_bot.dispatcher import MessageDispatcher
    14→from slack_bot.context.storage import ContextStorage
    15→
    16→logger = setup_logger(__name__)
    17→
    18→# Load environment variables
    19→load_env_with_extras()
    20→
    21→
    22→@click.command()
    23→@click.option("--model", default=None, help="Gemini model to use (overrides .env)")
    24→def main(model: str):
    25→    """Butler Slack Bot - Gemini Edition."""
    26→
    27→    # Override model in environment only if specified via CLI
    28→    if model:
    29→        os.environ["GEMINI_MODEL"] = model
    30→        logger.info(f"Using model from CLI: {model}")
    31→
    32→    # Get the actual model being used (from CLI or .env)
    33→    actual_model = os.environ.get("GEMINI_MODEL", "gemini-2.0-flash-exp")
    34→    base_url = os.environ.get("GEMINI_BASE_URL", "Not set (will use direct API)")
    35→    config_path = os.environ.get("GEMINI_CONFIG_PATH", ".env")
    36→
    37→    logger.info(f"Starting Butler Bot with configuration:")
    38→    logger.info(f"  Config file: {config_path}")
    39→    logger.info(f"  Model: {actual_model}")
    40→    logger.info(f"  Base URL: {base_url}")
    41→    logger.info(f"  Using proxy: {'Yes' if base_url and 'http' in base_url else 'No (Direct Google API)'}")
    42→
    43→    # Initialize Slack App
    44→    bot_token = os.environ.get("SLACK_BOT_TOKEN")
    45→    app_token = os.environ.get("SLACK_APP_TOKEN")
    46→
    47→    if not bot_token or not app_token:
    48→        logger.error("Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN in .env")
    49→        sys.exit(1)
    50→
    51→    app = App(token=bot_token)
    52→
    53→    # Tool mode: 'none' (no tools), 'light' (5), 'standard' (10), 'full' (15), 'all' (21)
    54→    # Use 'none' if your proxy doesn't support function calling
    55→    tool_mode = os.environ.get("TOOL_MODE", "light")
    56→    dispatcher = MessageDispatcher(tool_mode=tool_mode)
    57→    logger.info(f"Dispatcher initialized in '{tool_mode}' mode")
    58→
    59→    # Message deduplication: Track processed message IDs
    60→    processed_messages = set()
    61→    MAX_TRACKED_MESSAGES = 1000
    62→
    63→    # Command Handler: Clear Context
    64→    @app.message(r"^/(clear|reset)$")
    65→    def handle_clear_context(message, say):
    66→        """Clear conversation context for the current channel."""
    67→        try:
    68→            channel = message["channel"]
    69→            storage = ContextStorage(channel)
    70→
    71→            # Get context size before clearing
    72→            old_context = storage.get_context()
    73→            old_count = len(old_context)
    74→
    75→            storage.clear()
    76→
    77→            logger.info(f"✅ Context cleared for channel {channel} (removed {old_count} messages)")
    78→            say(f"✅ 对话历史已清空,我们重新开始!\n\n_Context cleared ({old_count} messages removed). Starting fresh conversation._")
    79→        except Exception as e:
    80→            logger.error(f"Error clearing context: {e}", exc_info=True)
    81→            say(f"❌ 清空对话历史失败: {e}")
    82→
    83→    # Command Handler: Show Context Stats
    84→    @app.message(r"^/stats$")
    85→    def handle_context_stats(message, say):
    86→        """Show conversation context statistics."""
    87→        try:
    88→            channel = message["channel"]
    89→            storage = ContextStorage(channel)
    90→            context = storage.get_context()
    91→
    92→            # Calculate token estimate (rough: 1 token ≈ 3 chars for Chinese/English mix)
    93→            total_chars = sum(len(msg.get("content", "")) for msg in context)
    94→            estimated_tokens = total_chars // 3
    95→
    96→            stats_msg = (
    97→                f"📊 **对话上下文统计 (Context Stats)**\n\n"
    98→                f"• 消息数量 (Messages): {len(context)}\n"
    99→                f"• 总字符数 (Total chars): {total_chars:,}\n"
   100→                f"• 预估 Token (Est. tokens): ~{estimated_tokens:,}\n\n"
   101→                f"_使用 `/clear` 可以清空对话历史_"
   102→            )
   103→
   104→            say(stats_msg)
   105→            logger.info(f"Context stats for {channel}: {len(context)} messages, ~{estimated_tokens} tokens")
   106→        except Exception as e:
   107→            logger.error(f"Error getting context stats: {e}", exc_info=True)
   108→            say(f"❌ 获取统计信息失败: {e}")
   109→
   110→    # Message Handler
   111→    @app.event("message")
   112→    def handle_message(message, say):
   113→
   114→        try:
   115→            # Skip command messages (they're handled by command handlers)
   116→            text = message.get("text", "")
   117→            if text.startswith("/clear") or text.startswith("/reset") or text.startswith("/stats"):
   118→                logger.debug("Skipping command message in general handler")
   119→                return
   120→
   121→            # Deduplicate using client_msg_id or event_ts
   122→            msg_id = message.get("client_msg_id") or message.get("ts")
   123→
   124→            if msg_id in processed_messages:
   125→                logger.debug(f"Skipping duplicate message: {msg_id}")
   126→                return
   127→
   128→            # Add to processed set
   129→            processed_messages.add(msg_id)
   130→
   131→            # Limit memory usage by keeping only recent message IDs
   132→            if len(processed_messages) > MAX_TRACKED_MESSAGES:
   133→                # Remove oldest half
   134→                to_remove = list(processed_messages)[:MAX_TRACKED_MESSAGES // 2]
   135→                for old_id in to_remove:
   136→                    processed_messages.discard(old_id)
   137→
   138→            text = message.get("text", "")
   139→            channel = message["channel"]
   140→            user = message["user"]
   141→            files = message.get("files", [])
   142→
   143→            # Generate Request ID
   144→            import uuid
   145→            import time
   146→            request_id = str(uuid.uuid4())[:8]
   147→            start_time = time.time()
   148→
   149→            logger.info(f"[{request_id}] 📥 Received message from {user} in {channel}: {text} (Files: {len(files)})")
   150→
   151→            # Send temporary "Processing..." message
   152→            ack_response = say(f"⏳ Butler is thinking...")
   153→            ack_ts = ack_response["ts"]
   154→
   155→            # Dispatch to Gemini with the timestamp to update
   156→            dispatcher.dispatch(text, channel, user, response_ts=ack_ts, request_id=request_id, files=files)
   157→
   158→            elapsed = time.time() - start_time
   159→            logger.info(f"[{request_id}] ✅ Request completed in {elapsed:.2f}s")
   160→
   161→        except Exception as e:
   162→            logger.error(f"Error handling message: {e}", exc_info=True)
   163→            try:
   164→                say(f"🐛 Butler encountered an error: {e}")
   165→            except Exception:
   166→                pass
   167→
   168→    # Start Socket Mode
   169→    try:
   170→        handler = SocketModeHandler(app, app_token)
   171→        logger.info("⚡ Butler (Gemini) is connected to Slack!")
   172→        handler.start()
   173→    except Exception as e:
   174→        logger.error(f"Failed to start SocketModeHandler: {e}")
   175→        sys.exit(1)
   176→
   177→
   178→if __name__ == "__main__":
   179→    main()
   180→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→from typing import Optional, List, Dict, Any
     2→from slack_sdk import WebClient
     3→import os
     4→
     5→from health.utils.logging_config import setup_logger
     6→from slack_bot.llm.gemini import GeminiLLM
     7→from slack_bot.context.storage import ContextStorage
     8→from slack_bot.tools.registry import TOOLS_SCHEMA, TOOL_FUNCTIONS
     9→from slack_bot.tools.groups import get_tool_preset
    10→
    11→logger = setup_logger(__name__)
    12→
    13→
    14→class MessageDispatcher:
    15→    """Routes incoming Slack messages to Gemini and executes tools."""
    16→
    17→    def __init__(self, bot_token: Optional[str] = None, system_instruction: Optional[str] = None, tools: Optional[List[Dict]] = None, tool_mode: str = "light"):
    18→        """
    19→        Initialize dispatcher.
    20→        Args:
    21→            bot_token: Optional specific Slack token
    22→            system_instruction: Custom system prompt (e.g. for Shell Bot)
    23→            tools: specific tools list to use (if None, uses preset based on tool_mode)
    24→            tool_mode: 'none' (no tools), 'light' (5 tools), 'standard' (10 tools), 'full' (15 tools), 'all' (21 tools)
    25→        """
    26→        self.llm = GeminiLLM(system_instruction=system_instruction)
    27→
    28→        # Use tool preset if tools not explicitly provided
    29→        if tools is not None:
    30→            self.tools = tools
    31→            self.tool_functions = TOOL_FUNCTIONS
    32→        elif tool_mode == "none":
    33→            self.tools = None
    34→            self.tool_functions = {}
    35→            logger.info("Running in NO-TOOLS mode (pure text)")
    36→        else:
    37→            self.tools, self.tool_functions = get_tool_preset(tool_mode)
    38→            logger.info(f"Using tool preset: {tool_mode} ({len(self.tools)} tools)")
    39→
    40→        # Initialize Slack WebClient for posting replies
    41→        self.bot_token = bot_token or os.environ.get("SLACK_BOT_TOKEN")
    42→        self.client = WebClient(token=self.bot_token)
    43→
    44→        logger.info("Dispatcher initialized with Gemini")
    45→
    46→    def dispatch(self, message_text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None, request_id: str = "N/A", files: Optional[List[Dict]] = None) -> None:
    47→        """
    48→        Process a message and trigger responses.
    49→        """
    50→        import time
    51→        t0 = time.time()
    52→
    53→        prefix = f"[{request_id}]"
    54→        logger.info(f"{prefix} Processing message in {channel_id} from {user_id}")
    55→        if files:
    56→            logger.info(f"{prefix} Message contains {len(files)} files")
    57→
    58→        # 1. Initialize Context
    59→        storage = ContextStorage(channel_id)
    60→
    61→        # Handle "clear" command
    62→        if message_text.strip().lower() in ["clear", "reset", "清除"]:
    63→            storage.clear()
    64→            self.client.chat_postMessage(channel=channel_id, text="🧹 Context cleared. (对话历史已清除)")
    65→            return
    66→
    67→        storage.add_message("user", message_text)
    68→
    69→        # Download images if any
    70→        image_data_list = []
    71→        download_failed = False
    72→        if files:
    73→            image_data_list = self._download_files(files, prefix)
    74→            # Anti-Hallucination: Check if download failed
    75→            if len(image_data_list) == 0:
    76→                failure_msg = "\n[SYSTEM WARNING: User uploaded an image but it FAILED to download/process. You CANNOT see the image. DO NOT GUESS. Inform the user that the image access failed.]"
    77→                logger.warning(f"{prefix} Image download failed! Appending warning to LLM.")
    78→                message_text += failure_msg
    79→                download_failed = True
    80→
    81→        try:
    82→            # 2. Get conversation context
    83→            context = storage.get_context()
    84→            logger.debug(f"{prefix} Context size: {len(context)}")
    85→
    86→            # 3. Process with Gemini (with tools)
    87→            # CRITICAL: If image download failed, disable tools to prevent "400 INVALID_ARGUMENT"
    88→            # (which happens when mixing text-only warning prompts with complex tool schemas in some proxies)
    89→            #
    90→            # NOTE: gemini-3-flash has a bug where it returns empty response with tools,
    91→            # but we use SAFETY OVERRIDE below to manually trigger tools based on keywords
    92→            current_tools = None if download_failed else self.tools
    93→
    94→            logger.info(f"{prefix} Calling Gemini for initial response...")
    95→            logger.info(f"{prefix} Request config: model={self.llm.get_model_name()}, tools={len(current_tools) if current_tools else 0}, context={len(context)-1} msgs")
    96→            t_llm_start = time.time()
    97→            response_text, tool_calls = self.llm.generate_response(
    98→                message=message_text,
    99→                context=context[:-1],
   100→                tools=current_tools,
   101→                images=image_data_list
   102→            )
   103→            # Defensive check for None/empty response
   104→            if response_text is None:
   105→                logger.error(f"{prefix} Gemini returned None for response_text!")
   106→                response_text = ""
   107→            elif not response_text.strip():
   108→                logger.warning(f"{prefix} Gemini returned empty response_text! (This usually means the proxy has issues)")
   109→
   110→            # Initialize tool_calls
   111→            if tool_calls is None:
   112→                tool_calls = []
   113→
   114→            # Log what we got back
   115→            logger.info(f"{prefix} Gemini response: text={len(response_text)} chars, tool_calls={len(tool_calls) if tool_calls else 0}")
   116→
   117→            # --- SAFETY OVERRIDE START ---
   118→            # If the LLM failed to trigger tools (common with local proxies + tools bug),
   119→            # manually detect intent and force tool calls based on keywords.
   120→            # This is a FALLBACK mechanism when Gemini returns empty response or MALFORMED_FUNCTION_CALL.
   121→            if not tool_calls:
   122→                logger.info(f"{prefix} No tool calls from Gemini - checking SAFETY OVERRIDE keywords...")
   123→                lower_msg = message_text.lower()
   124→
   125→                # 1. Sync Garmin data
   126→                if any(k in lower_msg for k in ["sync", "update", "fetch", "同步", "拉取", "更新"]) and \
   127→                   any(k in lower_msg for k in ["garmin", "佳明", "data", "数据"]):
   128→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing sync_garmin tool")
   129→                    from datetime import datetime, timedelta
   130→                    from health.utils.time_utils import get_cst_today
   131→                    # Try to infer sync target date from user message keywords
   132→                    if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "yesterday", "last night"]):
   133→                        _sync_date = (get_cst_today() - timedelta(days=1)).isoformat()
   134→                    elif any(k in lower_msg for k in ["前天", "day before yesterday"]):
   135→                        _sync_date = (get_cst_today() - timedelta(days=2)).isoformat()
   136→                    else:
   137→                        _sync_date = get_cst_today().isoformat()  # today in CST
   138→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: sync target_date={_sync_date}")
   139→                    tool_calls.append({
   140→                        "name": "sync_garmin",
   141→                        "args": {"target_date": _sync_date}
   142→                    })
   143→
   144→                # 2. Query health data (sleep, steps, activities, etc.)
   145→                elif any(k in lower_msg for k in ["睡眠", "sleep", "步数", "steps", "心率", "heart", "hrv", "运动", "activity", "workout", "锻炼", "昨天", "yesterday", "今天", "today", "上午", "下午", "morning", "afternoon", "查询", "query", "变化", "趋势", "trend"]):
   146→                    from datetime import timedelta
   147→                    from health.utils.time_utils import get_cst_now, get_cst_today
   148→
   149→                    # CRITICAL: First check for single-day keywords to avoid false positives
   150→                    has_single_day_keyword = any(k in lower_msg for k in [
   151→                        "昨天", "昨晚", "昨日", "last night", "yesterday",
   152→                        "今天", "今晚", "今日", "today", "tonight",
   153→                        "前天", "前晚", "day before yesterday",
   154→                        "大前天", "three days ago"
   155→                    ])
   156→
   157→                    # Check if asking for time-range trends (past X days/weeks/months)
   158→                    is_trend_query = (not has_single_day_keyword) and any(k in lower_msg for k in [
   159→                        "过去", "最近", "近", "past", "recent",
   160→                        "变化", "趋势", "trend", "change", "历史", "history",
   161→                        "个月", "month", "周", "week", "年", "year",
   162→                    ])
   163→
   164→                    if is_trend_query:
   165→                        # Parse time range from query
   166→                        import re
   167→                        today_cst = get_cst_today()
   168→                        end_date = today_cst.strftime("%Y-%m-%d")
   169→                        days_ago = 30  # default
   170→
   171→                        if re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg):
   172→                            months = int(re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(1) or re.search(r'(\d+)\s*个?月|(\d+)\s*month', lower_msg).group(2))
   173→                            days_ago = months * 30
   174→                        elif re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg):
   175→                            weeks = int(re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(1) or re.search(r'(\d+)\s*周|(\d+)\s*week', lower_msg).group(2))
   176→                            days_ago = weeks * 7
   177→                        elif re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg):
   178→                            days_ago = int(re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(1) or re.search(r'(\d+)\s*天|(\d+)\s*day', lower_msg).group(2))
   179→                        elif re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg):
   180→                            years = int(re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(1) or re.search(r'(\d+)\s*年|(\d+)\s*year', lower_msg).group(2))
   181→                            days_ago = years * 365
   182→                        elif "半年" in lower_msg or "six month" in lower_msg:
   183→                            days_ago = 180
   184→                        elif "三个月" in lower_msg or "three month" in lower_msg:
   185→                            days_ago = 90
   186→
   187→                        from datetime import timedelta
   188→                        start_date = (today_cst - timedelta(days=days_ago)).strftime("%Y-%m-%d")
   189→
   190→                        # Determine metric type
   191→                        metric_type = "rhr"  # default
   192→                        if any(k in lower_msg for k in ["hrv", "心率变异"]):
   193→                            metric_type = "hrv"
   194→                        elif any(k in lower_msg for k in ["睡眠", "sleep"]):
   195→                            metric_type = "sleep"
   196→                        elif any(k in lower_msg for k in ["步数", "steps"]):
   197→                            metric_type = "steps"
   198→                        elif any(k in lower_msg for k in ["压力", "stress"]):
   199→                            metric_type = "stress"
   200→
   201→                        logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_metric_history for {metric_type} ({start_date} to {end_date})")
   202→                        tool_calls.append({
   203→                            "name": "get_metric_history",
   204→                            "args": {
   205→                                "metric_type": metric_type,
   206→                                "start_date": start_date,
   207→                                "end_date": end_date
   208→                            }
   209→                        })
   210→                    else:
   211→                        # Single-day query — all dates computed in CST (UTC+8)
   212→                        today_cst = get_cst_today()
   213→                        if any(k in lower_msg for k in ["昨天", "昨晚", "昨日", "last night", "yesterday"]):
   214→                            target = (today_cst - timedelta(days=1)).strftime("%Y-%m-%d")
   215→                        elif any(k in lower_msg for k in ["前天", "前晚", "day before yesterday"]):
   216→                            target = (today_cst - timedelta(days=2)).strftime("%Y-%m-%d")
   217→                        else:
   218→                            target = today_cst.strftime("%Y-%m-%d")
   219→
   220→                        # Check if specifically asking for activity history
   221→                        if any(k in lower_msg for k in ["运动", "activity", "workout", "锻炼"]):
   222→                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_activity_history for {target}")
   223→                            tool_calls.append({
   224→                                "name": "get_activity_history",
   225→                                "args": {"start_date": target, "end_date": target}
   226→                            })
   227→                        else:
   228→                            logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing get_daily_detailed_stats for {target}")
   229→                            tool_calls.append({
   230→                                "name": "get_daily_detailed_stats",
   231→                                "args": {"target_date": target}
   232→                            })
   233→
   234→                # 3. Web search
   235→                elif any(k in lower_msg for k in ["搜索", "search", "查一下", "look up", "最新", "latest"]):
   236→                    query = message_text
   237→                    logger.warning(f"{prefix} 🔧 SAFETY OVERRIDE: Forcing search_web")
   238→                    tool_calls.append({
   239→                        "name": "search_web",
   240→                        "args": {"query": query}
   241→                    })
   242→
   243→                # 4. Detect diet logging intent (for logging, not forcing tool call yet)
   244→                food_items = ["鸡", "肉", "菜", "蛋", "饭", "面", "粉", "汤", "鱼", "虾", "蟹", "牛", "猪", "羊", "豆", "奶"]
   245→                meal_indicators = ["晚餐", "午餐", "早餐", "夜宵", "零食", "breakfast", "lunch", "dinner", "snack"]
   246→                has_ate_pattern = any(k in lower_msg for k in ["吃了", "ate", "吃的", "eating"])
   247→                has_meal_indicator = any(k in lower_msg for k in meal_indicators)
   248→                has_food_item = any(k in lower_msg for k in food_items)
   249→
   250→                if (has_ate_pattern and has_food_item) or has_meal_indicator:
   251→                    logger.warning(f"{prefix} Detected diet logging intent: ate={has_ate_pattern}, meal={has_meal_indicator}, food={has_food_item}")
   252→                    # Don't force tool - let user confirm first or LLM will handle in second round
   253→                else:
   254→                    logger.info(f"{prefix} No SAFETY OVERRIDE match found - will return empty response or text")
   255→            else:
   256→                logger.info(f"{prefix} Tool calls successfully generated by Gemini (no SAFETY OVERRIDE needed)")
   257→            # --- SAFETY OVERRIDE END ---
   258→
   259→            tool_count = len(tool_calls) if tool_calls else 0
   260→            logger.info(f"{prefix} Gemini response in {time.time()-t_llm_start:.2f}s: {len(response_text)} chars, {tool_count} tools")
   261→
   262→            # 4. Execute tools if requested
   263→            tool_results = []
   264→            if tool_calls:
   265→                logger.info(f"{prefix} Executing {len(tool_calls)} tools")
   266→
   267→                for tool_call in tool_calls:
   268→                    tool_name = tool_call["name"]
   269→                    tool_args = tool_call["args"]
   270→                    logger.info(f"{prefix} Tool: {tool_name} Args: {tool_args}")
   271→
   272→                    t_tool_start = time.time()
   273→                    if tool_name in self.tool_functions:
   274→                        try:
   275→                            # Inject channel_id for shell tool
   276→                            if tool_name == "execute_shell":
   277→                                tool_args["channel_id"] = channel_id
   278→
   279→                            result = self.tool_functions[tool_name](**tool_args)
   280→
   281→                            # Safety Truncation: Prevent massive JSONs from blowing up context
   282→                            str_result = str(result)
   283→                            if len(str_result) > 8000:
   284→                                str_result = str_result[:8000] + "... (truncated)"
   285→
   286→                            tool_results.append({
   287→                                "tool": tool_name,
   288→                                "args": tool_args,
   289→                                "result": str_result
   290→                            })
   291→                            logger.info(f"{prefix} ✓ {tool_name} finished in {time.time()-t_tool_start:.2f}s")
   292→                        except Exception as e:
   293→                            error_msg = f"Error executing {tool_name}: {str(e)}"
   294→                            logger.error(f"{prefix} {error_msg}")
   295→                            tool_results.append({
   296→                                "tool": tool_name,
   297→                                "args": tool_args,
   298→                                "result": f"❌ {error_msg}"
   299→                            })
   300→                    else:
   301→                        logger.warning(f"{prefix} Unknown tool: {tool_name}")
   302→
   303→                # 4b. Second round: Get final response with tool results
   304→                logger.info(f"{prefix} Requesting final analysis...")
   305→
   306→                tool_names = ", ".join([tr['tool'] for tr in tool_results])
   307→
   308→                # Format specific tool results for context
   309→                tool_results_text = "\n".join([
   310→                    f"Tool '{tr['tool']}' (Args: {tr['args']}) returned:\n{tr['result']}"
   311→                    for tr in tool_results
   312→                ])
   313→
   314→                # Inject thoughts into memory
   315→                storage.add_message("assistant", f"I checked: {tool_names}", model="gemini")
   316→
   317→                # Stronger Prompt for Analysis
   318→                analysis_prompt = (
   319→                    f"Here are the execution results from the tools:\n{tool_results_text}\n\n"
   320→                    f"CRITICAL INSTRUCTION: You represent the 'Butler' bot. You must now answer the user's original question based ONLY on the above results.\n"
   321→                    f"1. Summarize the key data points found (e.g., steps, sleep score).\n"
   322→                    f"2. Provide direct, natural language insights.\n"
   323→                    f"3. If the tool indicated an error, explain it to the user.\n\n"
   324→                    f"Do NOT output raw JSON. Write a helpful paragraph."
   325→                )
   326→
   327→                # Do NOT add this massive data blob to persistent storage.
   328→                # Just use it for this generation context.
   329→                final_context = storage.get_context()
   330→
   331→                t_llm_2_start = time.time()
   332→                response_text, _ = self.llm.generate_response(
   333→                    message=analysis_prompt,
   334→                    context=final_context,
   335→                    tools=None
   336→                )
   337→                logger.info(f"{prefix} Final analysis received in {time.time()-t_llm_2_start:.2f}s")
   338→
   339→                # Fallback
   340→                if not response_text or not response_text.strip():
   341→                    logger.warning(f"{prefix} Empty analysis received!")
   342→                    if files:
   343→                        response_text = "⚠ 图片已接收，但 AI 模型未能返回描述。这可能是由于网络波动或模型暂时无法识别该图像，请稍后重试。"
   344→                    else:
   345→                        response_text = "✅ 数据已同步，但我似乎暂时无法生成文字分析。请参考上方的工具执行结果。"
   346→
   347→            # 5. Format and send response
   348→            # Relaxing internal limit to allow for multi-message splitting (max 3 messages of ~4000 chars)
   349→            APP_LIMIT = 12000
   350→            full_response = self._format_response(response_text, tool_results, max_length=APP_LIMIT)
   351→
   352→            # Split into chunks
   353→            chunks = self._split_text(full_response, max_chunk_size=3800)
   354→
   355→            # Limit to 3 chunks as requested
   356→            if len(chunks) > 3:
   357→                chunks = chunks[:3]
   358→                chunks[-1] += "\n...(remaining content truncated)"
   359→
   360→            # Post to Slack
   361→            if response_ts:
   362→                # Update the first message (Thinking...)
   363→                try:
   364→                    self.client.chat_update(channel=channel_id, ts=response_ts, text=chunks[0])
   365→                    logger.info(f"{prefix} 📤 Message updated {response_ts}")
   366→                except Exception as e:
   367→                    logger.warning(f"{prefix} Update failed, posting new: {e}")
   368→                    self.client.chat_postMessage(channel=channel_id, text=chunks[0])
   369→
   370→                # Post subsequent chunks as new messages
   371→                for chunk in chunks[1:]:
   372→                    # Small delay to ensure order
   373→                    time.sleep(0.5)
   374→                    self.client.chat_postMessage(channel=channel_id, text=chunk)
   375→                    logger.info(f"{prefix} 📤 Continuation message posted")
   376→
   377→            else:
   378→                # Post all as new messages
   379→                for i, chunk in enumerate(chunks):
   380→                    if i > 0: time.sleep(0.5)
   381→                    self.client.chat_postMessage(channel=channel_id, text=chunk)
   382→                logger.info(f"{prefix} 📤 {len(chunks)} message(s) posted")
   383→
   384→
   385→            # 6. Save to Context
   386→            storage.add_message("assistant", full_response, model="gemini")
   387→            logger.info(f"{prefix} Dispatch completed in {time.time()-t0:.2f}s")
   388→
   389→        except Exception as e:
   390→            logger.error(f"{prefix} Dispatch failed: {e}", exc_info=True)
   391→            try:
   392→                self.client.chat_postMessage(
   393→                    channel=channel_id,
   394→                    text=f"⚠ Internal Error: {str(e)}"
   395→                )
   396→                logger.info("Error message posted to Slack")
   397→            except Exception as slack_error:
   398→                logger.error(f"Failed to post error message to Slack: {slack_error}", exc_info=True)
   399→
   400→    def _split_text(self, text: str, max_chunk_size: int = 3800) -> List[str]:
   401→        """Split text into chunks avoiding breaking code blocks if possible."""
   402→        if len(text) <= max_chunk_size:
   403→            return [text]
   404→
   405→        chunks = []
   406→        current_chunk = ""
   407→        lines = text.split('\n')
   408→
   409→        in_code_block = False
   410→
   411→        for line in lines:
   412→            # Check for code block toggle
   413→            if line.strip().startswith('```'):
   414→                in_code_block = not in_code_block
   415→
   416→            # If adding this line exceeds chunk size
   417→            if len(current_chunk) + len(line) + 1 > max_chunk_size:
   418→                # Close code block if open
   419→                if in_code_block:
   420→                    current_chunk += "\n```"
   421→
   422→                chunks.append(current_chunk)
   423→
   424→                # Start new chunk
   425→                current_chunk = ""
   426→                # Re-open code block if it was open
   427→                if in_code_block:
   428→                    current_chunk += "```\n" + "(...continued)\n"
   429→
   430→            if current_chunk:
   431→                current_chunk += "\n"
   432→            current_chunk += line
   433→
   434→        if current_chunk:
   435→            chunks.append(current_chunk)
   436→
   437→        return chunks
   438→
   439→    def _format_response(self, text: str, tool_results: list, max_length: int = 39900) -> str:
   440→        """
   441→        Format the response with tool execution results.
   442→
   443→        Args:
   444→            text: The main response text
   445→            tool_results: List of tool execution results
   446→            max_length: Maximum allowed message length (default for new messages)
   447→
   448→        Returns:
   449→            Formatted response string, guaranteed to be under max_length
   450→        """
   451→        parts = []
   452→
   453→        # Add tool execution results first (with smart truncation)
   454→        if tool_results:
   455→            parts.append("🛠 *Tool Executions:*")
   456→            for tr in tool_results:
   457→                # Format args nicely
   458→                args_str = ", ".join(f"{k}={v}" for k, v in tr['args'].items())
   459→
   460→                # Truncate individual tool result if needed (more aggressive for multiple tools)
   461→                result_str = str(tr['result'])
   462→                # Allow larger tool outputs since we have multi-message support
   463→                max_per_tool = 5000
   464→                if len(result_str) > max_per_tool:
   465→                    result_str = result_str[:max_per_tool] + "... (truncated)"
   466→
   467→                parts.append(f"• `{tr['tool']}({args_str})`: {result_str}")
   468→            parts.append("")  # Blank line
   469→
   470→        # Add text response with formatting
   471→        if text:
   472→            from slack_bot.utils.mrkdwn import SlackFormatter
   473→            formatted_text = SlackFormatter.convert(text)
   474→            logger.info(f"Formatted text (len {len(text)} -> {len(formatted_text)})")
   475→            parts.append(formatted_text)
   476→
   477→        # Join all parts
   478→        if parts:
   479→            full_response = "\n".join(parts)
   480→        else:
   481→            # No tools and no response - this is unusual
   482→            logger.warning("No response text and no tool results - returning error message")
   483→
   484→            # Enhanced debugging info for empty Gemini responses
   485→            debug_info = f"\n\n🔍 调试信息:\n- 响应文本长度: {len(text)} 字符\n- 工具调用数: {len(tool_results)}\n- 模型: {self.llm.get_model_name()}"
   486→
   487→            full_response = (
   488→                "⚠ 抱歉，我似乎没有理解你的请求。请尝试更具体的描述，或者直接说明你想查询什么数据（例如：今天的步数、昨天的睡眠、上午的运动等）。"
   489→                + debug_info
   490→            )
   491→
   492→        # Final safety check: ensure we're under the limit
   493→        if len(full_response) > max_length:
   494→            truncation_msg = "\n\n⚠ (Response truncated due to Slack length limit)"
   495→            allowed_length = max_length - len(truncation_msg)
   496→            full_response = full_response[:allowed_length] + truncation_msg
   497→            logger.warning(f"Final response truncated from {len(full_response)} to {max_length} chars")
   498→
   499→        return full_response
   500→
   501→    def _download_files(self, files: List[Dict], prefix: str) -> List[Dict[str, Any]]:
   502→        """Download files from Slack."""
   503→        import requests
   504→
   505→        token = self.bot_token
   506→        from PIL import Image
   507→        import io
   508→
   509→        downloaded_files = []
   510→
   511→        for file_info in files:
   512→            logger.info(f"{prefix} found file: {file_info.get('name')} Type: {file_info.get('mimetype')} URL_P: {bool(file_info.get('url_private'))}")
   513→            # Check if it's an image
   514→            if 'mimetype' in file_info and file_info['mimetype'].startswith('image/'):
   515→                url = file_info.get('url_private_download') or file_info.get('url_private')
   516→                if not url:
   517→                    continue
   518→
   519→                headers = {'Authorization': f'Bearer {token}'}
   520→                # Debug Auth (Masked)
   521→                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
   522→                logger.info(f"{prefix} Attempting download from {url} with token {token_masked}")
   523→
   524→                # Try using url_private with token in query param
   525→                # This often bypasses redirect auth stripping issues
   526→                base_url = file_info.get('url_private')
   527→                if not base_url:
   528→                    continue
   529→
   530→                # Use Session to handle cookies/redirects better
   531→                session = requests.Session()
   532→                session.headers.update({'Authorization': f'Bearer {token}'})
   533→
   534→                # Debug Auth (Masked)
   535→                token_masked = f"{token[:5]}...{token[-5:]}" if token else "None"
   536→                logger.info(f"{prefix} Attempting download from {url} using Session (Token: {token_masked})")
   537→
   538→                try:
   539→                    response = session.get(url, timeout=10)
   540→
   541→                    # Log final URL to check for login page redirect
   542→                    if response.url != url:
   543→                        logger.info(f"{prefix} Redirected to: {response.url}")
   544→
   545→                    if response.status_code == 200:
   546→                        # DEBUG: Check for HTML (Login page)
   547→                        ct = response.headers.get('Content-Type', 'unknown')
   548→                        if 'html' in ct.lower():
   549→                            logger.error(f"{prefix} Download returned HTML (likely Login Page) instead of image. Auth failed.")
   550→                            continue
   551→
   552→                        # Process image: Resize and Compress
   553→                        try:
   554→                            img = Image.open(io.BytesIO(response.content))
   555→                        except Exception as img_err:
   556→                            logger.error(f"{prefix} PIL Open Failed! Content preamble: {response.content[:200]!r}")
   557→                            continue # Skip this file
   558→
   559→                        # Convert to RGB if necessary (e.g., PNG with alpha)
   560→                        if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
   561→                            bg = Image.new('RGB', img.size, (255, 255, 255))
   562→                            if img.mode != 'RGBA':
   563→                                img = img.convert('RGBA')
   564→                            bg.paste(img, mask=img.split()[3])
   565→                            img = bg
   566→                        elif img.mode != 'RGB':
   567→                            img = img.convert('RGB')
   568→
   569→                        # Resize if too large (max dimension 1024)
   570→                        max_dim = 1024
   571→                        if max(img.size) > max_dim:
   572→                            img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
   573→
   574→                        # Compress to JPEG
   575→                        buf = io.BytesIO()
   576→                        img.save(buf, format='JPEG', quality=85)
   577→                        optimized_data = buf.getvalue()
   578→
   579→                        logger.info(f"{prefix} Downloaded and optimized image: {file_info.get('name')} "
   580→                                  f"({len(response.content)/1024:.1f}KB -> {len(optimized_data)/1024:.1f}KB)")
   581→
   582→                        downloaded_files.append({
   583→                            "mime_type": "image/jpeg", # Always normalize to JPEG for consistency
   584→                            "data": optimized_data
   585→                        })
   586→                    else:
   587→                        logger.error(f"{prefix} Failed to download file: status {response.status_code}")
   588→                except Exception as e:
   589→                    logger.error(f"{prefix} Error downloading file: {e}")
   590→
   591→        return downloaded_files
   592→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Now let me explore the obsidian-related code and modes:


find /root/projects/butler -type f -name "*.py" -path "*mode*" | head -10

Find files with "mode" in the name


ls -la /root/projects/butler/slack_bot/tools/ | head -20

List tools in slack_bot


     1→import os
     2→from typing import Optional, List, Dict, Any
     3→from slack_sdk import WebClient
     4→
     5→from health.utils.logging_config import setup_logger
     6→from slack_bot.obsidian.indexer import ObsidianIndexer
     7→from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
     8→from slack_bot.context.storage import ContextStorage as BaseStorage
     9→
    10→logger = setup_logger(__name__)
    11→
    12→class ObsidianContextStorage(BaseStorage):
    13→    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    14→    def __init__(self, channel_id: str):
    15→        super().__init__(channel_id)
    16→        from health import config
    17→        self.context_dir = config.DATA_DIR / "obsidian_context"
    18→        self.context_dir.mkdir(parents=True, exist_ok=True)
    19→        self.file_path = self.context_dir / f"{channel_id}.json"
    20→        self.state_file = self.context_dir / f"{channel_id}_state.json"
    21→
    22→    def save_state(self, state: Dict[str, Any]):
    23→        import json
    24→        with open(self.state_file, "w") as f:
    25→            json.dump(state, f)
    26→
    27→    def load_state(self) -> Dict[str, Any]:
    28→        import json
    29→        if not self.state_file.exists():
    30→            return {"mode": "none"}
    31→        try:
    32→            with open(self.state_file, "r") as f:
    33→                return json.load(f)
    34→        except:
    35→            return {"mode": "none"}
    36→
    37→class ObsidianDispatcher:
    38→    """Routes Obsidian Slack Bot messages."""
    39→
    40→    MODE_WRITE = "write"
    41→    MODE_REPLY = "reply"
    42→    MODE_DECIDE = "decide"
    43→    MODE_SEARCH = "search"
    44→    MODE_DEAI = "deai"
    45→    MODE_ZHIHU = "zhihu"
    46→
    47→    def __init__(self, vault_path: str):
    48→        self.indexer = ObsidianIndexer(vault_path)
    49→        self.indexer.scan_vault()
    50→
    51→        self.generators = {
    52→            self.MODE_WRITE: WritingAssistant(self.indexer),
    53→            self.MODE_REPLY: ReplyGenerator(self.indexer),
    54→            self.MODE_DECIDE: DecisionSupport(self.indexer),
    55→            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
    56→            self.MODE_DEAI: DeAIReviser(self.indexer),
    57→            self.MODE_ZHIHU: ZhihuGenerator(self.indexer)
    58→        }
    59→
    60→        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
    61→        self.client = WebClient(token=token)
    62→        logger.info("ObsidianDispatcher initialized")
    63→
    64→    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
    65→        storage = ObsidianContextStorage(channel_id)
    66→        state = storage.load_state()
    67→        current_mode = state.get("mode", "none")
    68→
    69→        cmd = text.strip().lower()
    70→
    71→        # 1. Handle Commands
    72→        if cmd.startswith("mode "):
    73→            new_mode = cmd.split(" ", 1)[1].strip()
    74→            if new_mode in self.generators:
    75→                storage.clear()
    76→                storage.save_state({"mode": new_mode})
    77→                self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", response_ts)
    78→                return
    79→            else:
    80→                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(self.generators.keys())}", response_ts)
    81→                return
    82→
    83→        if cmd == "clear":
    84→            storage.clear()
    85→            self._reply(channel_id, "🧹 Context cleared.", response_ts)
    86→            return
    87→
    88→        if cmd == "reload":
    89→            self.indexer.scan_vault()
    90→            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
    91→            return
    92→
    93→        # 2. Handle Content based on mode
    94→        if current_mode == "none":
    95→            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu` to start.", response_ts)
    96→            return
    97→
    98→        # Get history
    99→        history = storage.get_context()
   100→        generator = self.generators[current_mode]
   101→
   102→        try:
   103→            logger.info(f"Generating Obsidian response in mode: {current_mode}")
   104→            # The chat method handles first turn logic and returns updated history
   105→            # But wait, our generators' chat method appends to history internally.
   106→            # In our Slack setup, we want to append the result to ContextStorage.
   107→
   108→            # Note: generator.chat(text, history) returns (response, new_history_list)
   109→            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
   110→            response_text, updated_history = generator.chat(text, history)
   111→
   112→            # Since generator.chat already did the LLM call and returned history,
   113→            # we just need to save the ONLY NEW messages to our storage.
   114→            # However, during FIRST TURN, text is replaced by rich_prompt.
   115→
   116→            # For simplicity: completely overwrite storage with updated_history
   117→            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
   118→            # Better: Modify ObsidianContextStorage to support set_context.
   119→
   120→            # Actually, let's just use the updated_history and manually add to storage
   121→            # Warning: first turn 'user' message in storage will be the RICH PROMPT
   122→            # This is actually better for context continuity in Slack too.
   123→
   124→            storage.clear()
   125→            for msg in updated_history:
   126→                storage.add_message(msg["role"], msg["content"])
   127→
   128→            self._reply(channel_id, response_text, response_ts)
   129→
   130→        except Exception as e:
   131→            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
   132→            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
   133→
   134→    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
   135→        from slack_bot.utils.mrkdwn import SlackFormatter
   136→
   137→        # Slack API limits (official documentation):
   138→        # - chat.postMessage: 40,000 chars
   139→        # - chat.update: 4,000 chars
   140→        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
   141→        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
   142→        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"
   143→
   144→        # Calculate safe pre-formatting limit with aggressive overhead buffer
   145→        if ts:
   146→            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
   147→            max_raw_length = 1500
   148→        else:
   149→            # For new messages: 40000 limit - 40% overhead = ~24000 chars
   150→            max_raw_length = 24000
   151→
   152→        original_length = len(text)
   153→        should_upload_file = original_length > max_raw_length
   154→
   155→        if should_upload_file:
   156→            # Upload full text as file first
   157→            self._upload_as_file(channel_id, text, ts)
   158→
   159→            # Then send a truncated preview
   160→            truncate_at = text.rfind('\n\n', 0, 800)
   161→            if truncate_at == -1 or truncate_at < 400:
   162→                truncate_at = text.rfind('\n', 0, 800)
   163→            if truncate_at == -1 or truncate_at < 400:
   164→                truncate_at = 800
   165→
   166→            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
   167→            formatted_text = SlackFormatter.convert(preview_text)
   168→            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
   169→        else:
   170→            # Text is short enough, just format and send
   171→            formatted_text = SlackFormatter.convert(text)
   172→
   173→            # Final safety check after formatting
   174→            final_limit = 3900 if ts else 39900
   175→            if len(formatted_text) > final_limit:
   176→                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
   177→                self._upload_as_file(channel_id, text, ts)
   178→                # Send simple notification
   179→                formatted_text = FILE_UPLOAD_HINT
   180→
   181→        # Send the message
   182→        try:
   183→            if ts:
   184→                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
   185→            else:
   186→                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
   187→        except Exception as e:
   188→            if "msg_too_long" in str(e):
   189→                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
   190→                # Ultimate fallback: send error message only
   191→                try:
   192→                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
   193→                    if ts:
   194→                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   195→                    else:
   196→                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
   197→                except:
   198→                    logger.error("Failed to send even error message", exc_info=True)
   199→            else:
   200→                raise
   201→
   202→    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
   203→        """Fallback: upload long responses as text files."""
   204→        try:
   205→            import tempfile
   206→            from datetime import datetime
   207→
   208→            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
   209→
   210→            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
   211→                tmp.write(content)
   212→                tmp_path = tmp.name
   213→
   214→            self.client.files_upload_v2(
   215→                channel=channel_id,
   216→                file=tmp_path,
   217→                filename=filename,
   218→                initial_comment="📄 Response too long for Slack message, uploaded as file:",
   219→                thread_ts=ts
   220→            )
   221→
   222→            os.unlink(tmp_path)
   223→            logger.info(f"Uploaded long response as file: {filename}")
   224→        except Exception as e:
   225→            logger.error(f"Failed to upload file: {e}", exc_info=True)
   226→            # Ultimate fallback: send error message
   227→            error_msg = "❌ Response too long to display. Please try a more specific query."
   228→            if ts:
   229→                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   230→            else:
   231→                self.client.chat_postMessage(channel=channel_id, text=error_msg)
   232→
   233→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import os
     2→import random
     3→import re
     4→from typing import List, Dict, Optional, Set
     5→from health.utils.logging_config import setup_logger
     6→
     7→logger = setup_logger(__name__)
     8→
     9→class ObsidianIndexer:
    10→    """
    11→    Scans an Obsidian Vault to index files and extract samples based on tags.
    12→    """
    13→
    14→    WRITING_SAMPLE_TAG = "#writing_sample"
    15→    REPLY_SAMPLE_TAG = "#reply_sample"
    16→
    17→    def __init__(self, vault_path: str):
    18→        self.vault_path = os.path.expanduser(vault_path) if vault_path else None
    19→        if not self.vault_path or not os.path.exists(self.vault_path):
    20→            logger.warning(f"Obsidian Vault path invalid: {self.vault_path}")
    21→
    22→        self.writing_samples: List[str] = []
    23→        self.reply_samples: List[str] = []
    24→        self.file_index: Dict[str, str] = {} # filepath -> lowercase content (for simple search)
    25→        self.file_metadata: Dict[str, float] = {} # filepath -> mtime
    26→        self.files_scanned = 0
    27→
    28→    def scan_vault(self):
    29→        """Scans the vault for markdown files and populates indexes."""
    30→        if not self.vault_path or not os.path.exists(self.vault_path):
    31→            logger.error(f"Cannot scan: Invalid vault path {self.vault_path}")
    32→            return
    33→
    34→        logger.info(f"Scanning Obsidian Vault at: {self.vault_path}")
    35→        self.writing_samples = []
    36→        self.reply_samples = []
    37→        self.file_index = {}
    38→        self.file_metadata = {}
    39→        self.files_scanned = 0
    40→
    41→        for root, dirs, files in os.walk(self.vault_path):
    42→            # Skip hidden folders (like .obsidian, .git)
    43→            dirs[:] = [d for d in dirs if not d.startswith('.')]
    44→
    45→            for file in files:
    46→                if file.endswith(".md"):
    47→                    full_path = os.path.join(root, file)
    48→                    self._process_file(full_path)
    49→                    self.files_scanned += 1
    50→
    51→        logger.info(f"Scan complete. Scanned {self.files_scanned} files.")
    52→        logger.info(f"Found {len(self.writing_samples)} writing samples.")
    53→        logger.info(f"Found {len(self.reply_samples)} reply samples.")
    54→
    55→    def _process_file(self, file_path: str):
    56→        """Reads a file and checks for tags."""
    57→        try:
    58→            # Capture metadata
    59→            mtime = os.path.getmtime(file_path)
    60→            self.file_metadata[file_path] = mtime
    61→
    62→            with open(file_path, "r", encoding="utf-8") as f:
    63→                content = f.read()
    64→
    65→            # Basic tag detection (handles #tag inside text)
    66→            # We don't need a full-blown frontmatter parser for this,
    67→            # as Obsidian tags can be anywhere.
    68→
    69→            if self.WRITING_SAMPLE_TAG in content:
    70→                self.writing_samples.append(file_path)
    71→
    72→            if self.REPLY_SAMPLE_TAG in content:
    73→                self.reply_samples.append(file_path)
    74→
    75→            # Store for search (naive in-memory index)
    76→            # Truncating huge files to avoid memory boom if necessary,
    77→            # but for text notes 100k char limit is generous.
    78→            self.file_index[file_path] = content[:100000].lower()
    79→
    80→        except Exception as e:
    81→            logger.warning(f"Failed to read file {file_path}: {e}")
    82→
    83→    def get_writing_samples(self, count: int = 3) -> List[str]:
    84→        """Returns content of random writing samples."""
    85→        if not self.writing_samples:
    86→            return []
    87→
    88→        selected_paths = random.sample(self.writing_samples, min(len(self.writing_samples), count))
    89→        return self._read_files(selected_paths)
    90→
    91→    def get_reply_samples(self, count: int = 3) -> List[str]:
    92→        """Returns content of random reply samples."""
    93→        if not self.reply_samples:
    94→            return []
    95→
    96→        selected_paths = random.sample(self.reply_samples, min(len(self.reply_samples), count))
    97→        return self._read_files(selected_paths)
    98→
    99→    def search(self, query: str, limit: int = 5) -> List[str]:
   100→        """
   101→        Simple keyword search. Returns content of matching files.
   102→        RAG Strategy: Find notes containing the query keywords.
   103→        """
   104→        query_lower = query.lower()
   105→
   106→        # Split query into tokens
   107→        # Improved regex to handle mixed English/Chinese (e.g. "XLSmart遇到了")
   108→        # Matches:
   109→        # 1. English/Number/Underscore sequences: [a-zA-Z0-9_]+
   110→        # 2. Chinese characters (unigrams): [\u4e00-\u9fff]
   111→        tokens = re.findall(r'[a-zA-Z0-9_]+|[\u4e00-\u9fff]', query_lower)
   112→        if not tokens:
   113→            return []
   114→
   115→        logger.debug(f"Search tokens: {tokens}")
   116→
   117→        # Naive scoring: count token occurrences
   118→        scored_files = []
   119→        for path, content in self.file_index.items():
   120→            score = 0
   121→            unique_matches = 0
   122→
   123→            # Combine filename and content for search context
   124→            filename = os.path.basename(path).lower()
   125→            search_text = f"{filename} {filename} {content}" # Weight filename higher
   126→
   127→            for token in tokens:
   128→                if token in search_text:
   129→                    # Cubic weighting by length to heavily favor longer keywords (like "XLSmart")
   130→                    # over common single characters.
   131→                    count = search_text.count(token)
   132→                    score += count * (len(token) ** 3)
   133→                    unique_matches += 1
   134→
   135→            # Boost score based on how many unique tokens matched
   136→            if unique_matches > 0:
   137→                score = score * (unique_matches ** 2)
   138→                scored_files.append((score, path))
   139→
   140→        # Sort by score desc
   141→        scored_files.sort(key=lambda x: x[0], reverse=True)
   142→
   143→        top_paths = [path for _, path in scored_files[:limit]]
   144→        return self._read_files(top_paths)
   145→
   146→    def get_recent_files(self, days: int = 5, limit: int = 10) -> str:
   147→        """
   148→        Returns a formatted list of files modified in the last N days.
   149→        """
   150→        import time
   151→        current_time = time.time()
   152→        cutoff_time = current_time - (days * 86400)
   153→
   154→        recent_files = []
   155→        for path, mtime in self.file_metadata.items():
   156→            if mtime >= cutoff_time:
   157→                recent_files.append((mtime, path))
   158→
   159→        # Sort by mtime DESC (newest first)
   160→        recent_files.sort(key=lambda x: x[0], reverse=True)
   161→
   162→        # Limit
   163→        recent_files = recent_files[:limit]
   164→
   165→        if not recent_files:
   166→            return f"No files modified in the last {days} days."
   167→
   168→        output = [f"📂 **Updated in last {days} days:**"]
   169→        for mtime, path in recent_files:
   170→            filename = os.path.basename(path)
   171→            date_str = time.strftime('%Y-%m-%d %H:%M', time.localtime(mtime))
   172→            output.append(f"- `{filename}` ({date_str})")
   173→
   174→        return "\n".join(output)
   175→
   176→    def _read_files(self, paths: List[str]) -> List[str]:
   177→        """Helper to read complete content of list of paths."""
   178→        contents = []
   179→        for p in paths:
   180→            try:
   181→                with open(p, "r", encoding="utf-8") as f:
   182→                    # Provide context about the source
   183→                    filename = os.path.basename(p)
   184→                    contents.append(f"--- Source: {filename} ---\n{f.read()}")
   185→            except Exception:
   186→                pass
   187→        return contents
   188→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>