#!/usr/bin/env python3
"""Teleo Telegram Bot — Rio as analytical agent in community groups.

Architecture:
- Always-on ingestion: captures all messages, batch triage every N minutes
- Tag-based response: Opus-quality KB-grounded responses when @tagged
- Conversation-window triage: identifies coherent claims across message threads
- Full eval tracing: Rio's responses are logged as KB claims, accountable

Two paths (Ganymede architecture):
- Fast path (read): tag → KB query → Opus response → post to group
- Slow path (write): batch triage → archive to inbox/ → pipeline extracts

Separate systemd service: teleo-telegram.service
Does NOT integrate with pipeline daemon.

Epimetheus owns this module.
"""

import argparse
import asyncio
import logging
import os
import re
import sqlite3
import sys
import time

import yaml
from collections import defaultdict
from datetime import datetime, timezone
from html import escape
from pathlib import Path

# Add pipeline lib to path for shared modules
sys.path.insert(0, "/opt/teleo-eval/pipeline")

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json as _json
from kb_retrieval import KBIndex, retrieve_context, retrieve_vector_context
from retrieval import orchestrate_retrieval
from market_data import get_token_price, format_price_context, extract_market_data_tokens
from x_client import search_tweets, check_research_rate_limit, record_research_usage, get_research_remaining
from http_chat_proxy import (
    DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES,
    build_chat_proxy_payload,
    build_smart_research_proxy_payload,
    classify_smart_research_auto_resume_response,
    extract_auto_smart_research_followup_goal,
    extract_auto_smart_research_goal,
    extract_checkout_qr_photo_message,
    extract_paid_work_order_id,
    extract_smart_research_goal,
    post_chat_proxy,
    should_attach_structured_market_context,
    smart_research_chat_matches_gate,
    smart_research_payment_fields_for_message,
    smart_research_command_names,
    split_smart_research_auto_resume_reply,
)

# ─── Config ─────────────────────────────────────────────────────────────

BOT_TOKEN_FILE = "/opt/teleo-eval/secrets/telegram-bot-token"
OPENROUTER_KEY_FILE = "/opt/teleo-eval/secrets/openrouter-key"
PIPELINE_DB = "/opt/teleo-eval/pipeline/pipeline.db"
KB_READ_DIR = "/opt/teleo-eval/workspaces/main"   # For KB retrieval (clean main branch)
ARCHIVE_DIR = "/opt/teleo-eval/telegram-archives"  # Write outside worktree to avoid read-only errors
MAIN_WORKTREE = "/opt/teleo-eval/workspaces/main"  # For git operations only
LEARNINGS_FILE = "/opt/teleo-eval/workspaces/main/agents/rio/learnings.md"  # Agent memory (Option D)
LOG_FILE = "/opt/teleo-eval/logs/telegram-bot.log"

# Persistent audit connection — opened once at startup, reused for all writes
# (Ganymede + Rhea: no per-response sqlite3.connect / migrate)
_audit_conn: sqlite3.Connection | None = None

# Triage interval (seconds)
TRIAGE_INTERVAL = 900  # 15 minutes

# Models
RESPONSE_MODEL = "anthropic/claude-opus-4-6"  # Opus for tagged responses
TRIAGE_MODEL = "anthropic/claude-haiku-4.5"  # Haiku for batch triage

# KB scope — None means all domains (Rio default). Set from YAML config for other agents.
AGENT_KB_SCOPE: list[str] | None = None
AGENT_NAME = "Rio"
AGENT_HANDLE = "@FutAIrdBot"
AGENT_X_HANDLE = "@futaRdIO"
AGENT_DOMAIN_EXPERTISE = (
    "futarchy, prediction markets, token governance, and the MetaDAO ecosystem"
)
AGENT_HTTP_CHAT_PROXY_URL: str | None = None
AGENT_HTTP_RESEARCH_PROXY_URL: str | None = None
AGENT_RESPOND_TO_PRIVATE_CHATS = False
AGENT_MENTION_ALIASES = ["@teleo", "@FutAIrdBot"]
AGENT_SMART_RESEARCH_COMMAND_PREFIXES = list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES)
AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = False
smart_research_auto_resume_tasks: set[tuple[int, int]] = set()

# Rate limits
MAX_RESPONSE_PER_USER_PER_HOUR = 30
MIN_MESSAGE_LENGTH = 20  # Skip very short messages

# ─── Logging ────────────────────────────────────────────────────────────

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(name)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(LOG_FILE),
        logging.StreamHandler(),
    ],
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram-bot")

# ─── State ──────────────────────────────────────────────────────────────

# Message buffer for batch triage
message_buffer: list[dict] = []

# Rate limiting
user_response_times: dict[int, list[float]] = defaultdict(list)

# Allowed group IDs (set after first message received, or configure)
allowed_groups: set[int] = set()

TELEGRAM_REPLY_CHUNK_LIMIT = 3500
MARKDOWN_LINK_RE = re.compile(r"\[([^\]\n]{1,180})\]\((https?://[^\s<>)\"]{1,600})\)")


def _markdown_link_to_telegram_html(match: re.Match[str]) -> str:
    label = match.group(1)
    url = match.group(2)
    if label.startswith("`") and label.endswith("`") and len(label) > 1:
        label = label[1:-1]
    return f'<a href="{url}">{label}</a>'


def _telegram_native_html(text: str) -> str:
    """Render common LLM Markdown as Telegram HTML without trusting raw HTML."""
    rendered = escape(text, quote=False)
    rendered = MARKDOWN_LINK_RE.sub(_markdown_link_to_telegram_html, rendered)
    rendered = re.sub(r"(?m)^#{1,6}\s+(.+)$", r"<b>\1</b>", rendered)
    rendered = re.sub(r"\*\*([^*\n]{1,240})\*\*", r"<b>\1</b>", rendered)
    rendered = re.sub(r"`([^`\n]{1,240})`", r"<code>\1</code>", rendered)
    return rendered


def _plain_telegram_fallback(text: str) -> str:
    text = MARKDOWN_LINK_RE.sub(r"\1 (\2)", text)
    text = re.sub(r"(?m)^#{1,6}\s+", "", text)
    text = re.sub(r"\*\*([^*\n]{1,240})\*\*", r"\1", text)
    text = re.sub(r"`([^`\n]{1,240})`", r"\1", text)
    return text


def _telegram_reply_chunks(text: str) -> list[str]:
    chunks: list[str] = []
    current = ""
    for part in re.split(r"(\n\n+)", text):
        if len(part) > TELEGRAM_REPLY_CHUNK_LIMIT:
            if current:
                chunks.append(current)
                current = ""
            chunks.extend(
                part[i : i + TELEGRAM_REPLY_CHUNK_LIMIT]
                for i in range(0, len(part), TELEGRAM_REPLY_CHUNK_LIMIT)
            )
            continue
        if len(current) + len(part) > TELEGRAM_REPLY_CHUNK_LIMIT and current:
            chunks.append(current)
            current = part
        else:
            current += part
    if current:
        chunks.append(current)
    return chunks or [""]


async def _reply_text_native(msg, text: str, *, do_quote: bool = True):
    first = True
    for chunk in _telegram_reply_chunks(text):
        try:
            await msg.reply_text(
                _telegram_native_html(chunk),
                parse_mode="HTML",
                do_quote=do_quote and first,
            )
        except Exception as e:
            logger.warning("Telegram native-format reply failed; using plain fallback: %s", e)
            await msg.reply_text(
                _plain_telegram_fallback(chunk),
                do_quote=do_quote and first,
            )
        first = False


async def _reply_checkout_qr_photo(msg, proxy_body: dict | None, *, do_quote: bool = True) -> bool:
    card = extract_checkout_qr_photo_message(proxy_body)
    if not card:
        return False
    reply_markup = None
    if card.get("button_url"):
        reply_markup = InlineKeyboardMarkup(
            [[InlineKeyboardButton(card.get("button_text") or "Pay with x402", url=card["button_url"])]]
        )
    try:
        await msg.reply_photo(
            photo=card["photo_url"],
            caption=card["caption_html"],
            parse_mode=card.get("parse_mode") or "HTML",
            reply_markup=reply_markup,
            do_quote=do_quote,
        )
        return True
    except Exception as e:
        logger.warning("Telegram checkout QR photo reply failed: %s", e)
        return False


async def _typing_keepalive(chat, stop_event: asyncio.Event, interval_seconds: float = 4.0) -> None:
    while not stop_event.is_set():
        try:
            await chat.send_action("typing")
        except Exception as exc:
            logger.debug("typing keepalive failed: %s", exc)
        try:
            await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
        except asyncio.TimeoutError:
            continue


async def _post_chat_proxy_with_typing(chat, **kwargs):
    stop_event = asyncio.Event()
    keepalive = asyncio.create_task(_typing_keepalive(chat, stop_event))
    try:
        return await post_chat_proxy(**kwargs)
    finally:
        stop_event.set()
        keepalive.cancel()
        try:
            await keepalive
        except asyncio.CancelledError:
            pass

# Shared KB index (built once, refreshed on mtime change)
kb_index = KBIndex(KB_READ_DIR)

# Conversation windows — track active conversations per (chat_id, user_id)
# Rhea's model: count unanswered messages, reset on bot response, expire at threshold
CONVERSATION_WINDOW = 5  # expire after 5 unanswered messages
unanswered_count: dict[tuple[int, int], int] = {}  # (chat_id, user_id) → unanswered count

# Conversation history — last N exchanges for prompt context (Ganymede: high-value change)
MAX_HISTORY_USER = 5
MAX_HISTORY_CHAT = 30  # Group chats: multiple users, longer threads
conversation_history: dict[tuple[int, int], list[dict]] = {}  # (chat_id, user_id) → [{user, bot}]

# Full transcript store — all messages in all chats, dumped every 6 hours
# Keyed by chat_id. No cap — dumped and cleared on schedule.
chat_transcripts: dict[int, list[dict]] = {}
TRANSCRIPT_DIR = "/opt/teleo-eval/transcripts"


# ─── Content Classification ─────────────────────────────────────────────

# Sub-topic keywords for internet-finance sources
_TOPIC_KEYWORDS = {
    "futarchy": ["futarchy", "autocrat", "conditional market", "twap", "pass/fail",
                 "decision market", "futard", "metadao governance"],
    "ownership-coins": ["ownership coin", "ico", "fundraise", "launch", "launchpad",
                        "permissioned", "permissionless", "unruggable", "treasury management",
                        "buyback", "token split"],
    "defi": ["amm", "liquidity", "swap", "lending", "borrowing", "yield", "tvl",
             "dex", "lp", "staking", "vault", "protocol"],
    "governance": ["proposal", "vote", "governance", "dao", "subcommittee",
                   "treasury", "resolution", "benevolent dictator"],
    "market-analysis": ["price", "market cap", "fdv", "oversubscribed", "committed",
                        "trading", "volume", "bullish", "bearish", "thesis"],
    "crypto-infra": ["solana", "ethereum", "base", "bridge", "wallet", "on-ramp",
                     "off-ramp", "fiat", "stablecoin", "usdc"],
}

# Domain keywords for non-internet-finance content
_DOMAIN_KEYWORDS = {
    "ai-alignment": ["ai safety", "alignment", "superintelligence", "llm", "frontier model",
                     "interpretability", "rlhf", "anthropic", "openai", "deepmind"],
    "health": ["glp-1", "healthcare", "clinical", "pharma", "biotech", "fda",
               "medicare", "hospital", "diagnosis", "therapeutic"],
    "space-development": ["spacex", "starship", "orbital", "lunar", "satellite",
                          "launch cost", "rocket", "nasa", "artemis"],
    "entertainment": ["streaming", "creator economy", "ip", "nft", "gaming",
                      "content", "media", "studio", "audience"],
}


# Author handle → domain map (Ganymede: counts as 1 keyword match)
_AUTHOR_DOMAIN_MAP = {
    "metadaoproject": "internet-finance",
    "metadaofi": "internet-finance",
    "futardio": "internet-finance",
    "p2pdotme": "internet-finance",
    "oxranga": "internet-finance",
    "metanallok": "internet-finance",
    "proph3t_": "internet-finance",
    "01resolved": "internet-finance",
    "anthropicai": "ai-alignment",
    "openai": "ai-alignment",
    "daborai": "ai-alignment",
    "deepmind": "ai-alignment",
    "spacex": "space-development",
    "blaborig": "space-development",
    "nasa": "space-development",
}


def _classify_content(text: str, author: str = "") -> tuple[str, list[str]]:
    """Classify content into domain + sub-tags based on keywords + author.

    Returns (domain, [sub-tags]). Default: internet-finance with no sub-tags.
    """
    text_lower = text.lower()
    author_lower = author.lower().lstrip("@")

    # Author handle gives 1 keyword match toward domain threshold
    author_domain = _AUTHOR_DOMAIN_MAP.get(author_lower, "")

    # Check non-IF domains first
    for domain, keywords in _DOMAIN_KEYWORDS.items():
        matches = sum(1 for kw in keywords if kw in text_lower)
        if author_domain == domain:
            matches += 1  # Author signal counts as 1 match
        if matches >= 2:
            return domain, []

    # Default to internet-finance, classify sub-topics
    sub_tags = []
    for tag, keywords in _TOPIC_KEYWORDS.items():
        if any(kw in text_lower for kw in keywords):
            sub_tags.append(tag)

    return "internet-finance", sub_tags


# ─── Transcript Management ──────────────────────────────────────────────


def _record_transcript(msg, text: str, is_bot: bool = False,
                       rio_response: str = None, internal: dict = None):
    """Record a message to the full transcript for this chat."""
    chat_id = msg.chat_id
    transcript = chat_transcripts.setdefault(chat_id, [])

    entry = {
        "ts": msg.date.isoformat() if hasattr(msg, "date") and msg.date else datetime.now(timezone.utc).isoformat(),
        "chat_id": chat_id,
        "chat_title": msg.chat.title if hasattr(msg, "chat") and msg.chat else str(chat_id),
        "message_id": msg.message_id if hasattr(msg, "message_id") else None,
    }

    if is_bot:
        entry["type"] = "bot_response"
        entry["rio_response"] = rio_response or text
        if internal:
            entry["internal"] = internal  # KB matches, searches, learnings
    else:
        user = msg.from_user if hasattr(msg, "from_user") else None
        entry["type"] = "user_message"
        entry["username"] = f"@{user.username}" if user and user.username else "unknown"
        entry["display_name"] = user.full_name if user else "unknown"
        entry["user_id"] = user.id if user else None
        entry["message"] = text[:2000]
        entry["reply_to"] = msg.reply_to_message.message_id if hasattr(msg, "reply_to_message") and msg.reply_to_message else None

    transcript.append(entry)


_last_dump_index: dict[int, int] = {}  # chat_id → index of last dumped message


async def _dump_transcripts(context=None):
    """Append new transcript entries to per-chat JSONL files. Runs every hour.

    Append-only: each dump writes only new messages since last dump (Ganymede review).
    One JSONL file per chat per day. Each line is one message.
    """
    if not chat_transcripts:
        return

    os.makedirs(TRANSCRIPT_DIR, exist_ok=True)
    now = datetime.now(timezone.utc)
    today = now.strftime("%Y-%m-%d")

    import json as _json
    for chat_id, entries in list(chat_transcripts.items()):
        if not entries:
            continue

        # Only write new entries since last dump
        last_idx = _last_dump_index.get(chat_id, 0)
        new_entries = entries[last_idx:]
        if not new_entries:
            continue

        # Get chat title from first entry
        chat_title = entries[0].get("chat_title", str(chat_id))
        chat_slug = re.sub(r"[^a-z0-9]+", "-", chat_title.lower()).strip("-") or str(chat_id)

        # Create per-chat directory
        chat_dir = os.path.join(TRANSCRIPT_DIR, chat_slug)
        os.makedirs(chat_dir, exist_ok=True)

        # Append to today's JSONL file
        filename = f"{today}.jsonl"
        filepath = os.path.join(chat_dir, filename)

        try:
            with open(filepath, "a") as f:
                for entry in new_entries:
                    f.write(_json.dumps(entry, default=str) + "\n")
            _last_dump_index[chat_id] = len(entries)
            logger.info("Transcript appended: %s (+%d messages, %d total)",
                        filepath, len(new_entries), len(entries))
        except Exception as e:
            logger.warning("Failed to dump transcript for %s: %s", chat_slug, e)


def _create_inline_source(source_text: str, user_message: str, user, msg):
    """Create a source file from Rio's SOURCE: tag. Verbatim user content, attributed."""
    try:
        username = user.username if user else "anonymous"
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        slug = re.sub(r"[^a-z0-9]+", "-", source_text[:50].lower()).strip("-")
        filename = f"{date_str}-tg-source-{username}-{slug}.md"
        source_path = Path(ARCHIVE_DIR) / filename
        if source_path.exists():
            return

        content = f"""---
type: source
source_type: telegram-contribution
title: "Source from @{username} — {source_text[:80]}"
author: "@{username}"
date: {date_str}
domain: {_classify_content(source_text + " " + user_message)[0]}
format: contribution
status: unprocessed
proposed_by: "@{username}"
contribution_type: source-submission
tags: {["telegram-contribution", "inline-source"] + _classify_content(source_text + " " + user_message)[1]}
---

# Source: {source_text[:100]}

Contributed by @{username} in Telegram chat.
Flagged by Rio as relevant source material.

## Verbatim User Message

{user_message}

## Rio's Context

{source_text}
"""
        source_path.write_text(content)
        logger.info("Inline source created: %s (by @%s)", filename, username)
    except Exception as e:
        logger.warning("Failed to create inline source: %s", e)


def _create_inline_claim(claim_text: str, user_message: str, user, msg):
    """Create a draft claim file from Rio's CLAIM: tag. Attributed to contributor."""
    try:
        username = user.username if user else "anonymous"
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        slug = re.sub(r"[^a-z0-9]+", "-", claim_text[:60].lower()).strip("-")
        filename = f"{date_str}-tg-claim-{username}-{slug}.md"
        source_path = Path(ARCHIVE_DIR) / filename
        if source_path.exists():
            return

        domain, sub_tags = _classify_content(claim_text + " " + user_message)

        content = f"""---
type: source
source_type: telegram-claim
title: "Claim from @{username} — {claim_text[:80]}"
author: "@{username}"
date: {date_str}
domain: {domain}
format: claim-draft
status: unprocessed
proposed_by: "@{username}"
contribution_type: claim-proposal
tags: [telegram-claim, inline-claim]
---

# Draft Claim: {claim_text}

Contributed by @{username} in Telegram chat.
Flagged by Rio as a specific, disagreeable assertion worth extracting.

## Verbatim User Message

{user_message}

## Proposed Claim

{claim_text}
"""
        source_path.write_text(content)
        logger.info("Inline claim drafted: %s (by @%s)", filename, username)
    except Exception as e:
        logger.warning("Failed to create inline claim: %s", e)


# ─── Helpers ────────────────────────────────────────────────────────────



def get_db_stats() -> dict:
    """Get basic KB stats from pipeline DB."""
    try:
        conn = sqlite3.connect(PIPELINE_DB, timeout=5)
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA query_only=ON")
        merged = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='merged'").fetchone()["n"]
        contributors = conn.execute("SELECT COUNT(*) as n FROM contributors").fetchone()["n"]
        conn.close()
        return {"merged_claims": merged, "contributors": contributors}
    except Exception:
        return {"merged_claims": "?", "contributors": "?"}


from eval_checks import (
    _LLMResponse, estimate_cost, check_url_fabrication, apply_confidence_floor,
    COST_ALERT_THRESHOLD,
)


async def call_openrouter(model: str, prompt: str, max_tokens: int = 2048) -> _LLMResponse | None:
    """Call OpenRouter API. Returns _LLMResponse with token counts and cost."""
    import aiohttp

    key = Path(OPENROUTER_KEY_FILE).read_text().strip()
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": max_tokens,
        "temperature": 0.3,
    }
    try:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
                json=payload,
                timeout=aiohttp.ClientTimeout(total=120),
            ) as resp:
                if resp.status >= 400:
                    logger.error("OpenRouter %s → %d", model, resp.status)
                    return None
                data = await resp.json()
                content = data.get("choices", [{}])[0].get("message", {}).get("content")
                if content is None:
                    return None
                # Extract token usage from OpenRouter response
                usage = data.get("usage", {})
                pt = usage.get("prompt_tokens", 0)
                ct = usage.get("completion_tokens", 0)
                cost = estimate_cost(model, pt, ct)
                return _LLMResponse(content, prompt_tokens=pt, completion_tokens=ct,
                                    cost=cost, model=model)
    except Exception as e:
        logger.error("OpenRouter error: %s", e)
        return None


async def call_openrouter_with_tools(model: str, prompt: str, tools: list[dict],
                                     tool_executor, max_tokens: int = 2048,
                                     max_iterations: int = 3) -> tuple[_LLMResponse | None, list[dict]]:
    """Agentic loop: call LLM with tools, execute tool calls, feed back results.

    Returns (final_response, tool_call_audit_list).
    Token counts and cost are ACCUMULATED across all iterations, not just the final call.
    Tool audit includes LLM reasoning text between tool calls for full observability.
    Falls back to plain call_openrouter if model returns 400 with tool errors.
    """
    import aiohttp
    import json

    key = Path(OPENROUTER_KEY_FILE).read_text().strip()
    messages = [{"role": "user", "content": prompt}]
    tool_audit = []

    # Accumulate tokens/cost across ALL iterations (not just final call)
    total_prompt_tokens = 0
    total_completion_tokens = 0
    total_cost = 0.0

    for iteration in range(max_iterations):
        payload = {
            "model": model,
            "messages": messages,
            "max_tokens": max_tokens,
            "temperature": 0.3,
            "tools": tools,
        }
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(
                    "https://openrouter.ai/api/v1/chat/completions",
                    headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
                    json=payload,
                    timeout=aiohttp.ClientTimeout(total=120),
                ) as resp:
                    if resp.status >= 400:
                        body = await resp.text()
                        if "tool" in body.lower():
                            logger.warning("Model doesn't support tools, falling back to plain call")
                            result = await call_openrouter(model, prompt, max_tokens)
                            return result, tool_audit
                        logger.error("OpenRouter with tools %s → %d", model, resp.status)
                        return None, tool_audit
                    data = await resp.json()
        except Exception as e:
            logger.error("OpenRouter with tools error: %s", e)
            return None, tool_audit

        # Accumulate this iteration's token usage
        usage = data.get("usage", {})
        iter_pt = usage.get("prompt_tokens", 0)
        iter_ct = usage.get("completion_tokens", 0)
        iter_cost = estimate_cost(model, iter_pt, iter_ct)
        total_prompt_tokens += iter_pt
        total_completion_tokens += iter_ct
        total_cost += iter_cost

        choice = data.get("choices", [{}])[0]
        message = choice.get("message", {})

        # If model wants to call tools (check presence only — finish_reason varies by model)
        tool_calls_in_response = message.get("tool_calls", [])
        if tool_calls_in_response:
            # Capture LLM reasoning text alongside tool calls (the "thinking" between searches)
            reasoning_text = message.get("content", "")
            if reasoning_text:
                tool_audit.append({
                    "type": "reasoning", "iteration": iteration + 1,
                    "text": reasoning_text[:2000],
                    "tokens": {"prompt": iter_pt, "completion": iter_ct, "cost": round(iter_cost, 6)},
                })

            messages.append(message)  # Add assistant message with tool calls
            for tc in tool_calls_in_response:
                fn_name = tc["function"]["name"]
                try:
                    fn_args = json.loads(tc["function"]["arguments"])
                except (json.JSONDecodeError, KeyError):
                    fn_args = {}

                t0 = time.monotonic()
                result = tool_executor(fn_name, fn_args)
                duration_ms = int((time.monotonic() - t0) * 1000)

                # Truncate tool results
                result_str = str(result)[:4000]
                tool_audit.append({
                    "type": "tool_call", "iteration": iteration + 1,
                    "tool": fn_name, "input": fn_args,
                    "output_preview": result_str[:500],
                    "output_length": len(result_str), "duration_ms": duration_ms,
                })
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc["id"],
                    "content": result_str,
                })
            continue  # Next iteration with tool results

        # Model returned a text response (done)
        content = message.get("content")
        if content is None:
            return None, tool_audit
        return _LLMResponse(content, prompt_tokens=total_prompt_tokens,
                            completion_tokens=total_completion_tokens,
                            cost=total_cost, model=model), tool_audit

    # Exhausted iterations — force one final call WITHOUT tools to get a text answer
    logger.warning("Tool loop exhausted %d iterations, forcing final plain call", max_iterations)
    try:
        messages.append({"role": "user", "content": "Please provide your final answer now based on the information gathered."})
        payload_final = {
            "model": model,
            "messages": messages,
            "max_tokens": max_tokens,
            "temperature": 0.3,
        }
        async with aiohttp.ClientSession() as session:
            async with session.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
                json=payload_final,
                timeout=aiohttp.ClientTimeout(total=120),
            ) as resp:
                if resp.status < 400:
                    data = await resp.json()
                    content = data.get("choices", [{}])[0].get("message", {}).get("content")
                    if content:
                        usage = data.get("usage", {})
                        total_prompt_tokens += usage.get("prompt_tokens", 0)
                        total_completion_tokens += usage.get("completion_tokens", 0)
                        total_cost += estimate_cost(model, usage.get("prompt_tokens", 0),
                                                    usage.get("completion_tokens", 0))
                        return _LLMResponse(content, prompt_tokens=total_prompt_tokens,
                                            completion_tokens=total_completion_tokens,
                                            cost=total_cost, model=model), tool_audit
    except Exception as e:
        logger.error("Final plain call after tool exhaustion failed: %s", e)
    return None, tool_audit


def is_rate_limited(user_id: int) -> bool:
    """Check if a user has exceeded the response rate limit."""
    now = time.time()
    times = user_response_times[user_id]
    # Prune old entries
    times[:] = [t for t in times if now - t < 3600]
    return len(times) >= MAX_RESPONSE_PER_USER_PER_HOUR


def sanitize_message(text: str) -> str:
    """Sanitize message content before sending to LLM. (Ganymede: security)"""
    # Strip code blocks (potential prompt injection)
    text = re.sub(r"```.*?```", "[code block removed]", text, flags=re.DOTALL)
    # Strip anything that looks like system instructions
    text = re.sub(r"(system:|assistant:|human:|<\|.*?\|>)", "", text, flags=re.IGNORECASE)
    # Truncate
    return text[:2000]


def _smart_research_payment_gate(chat_id: int, chat_title: str | None = None) -> dict:
    """Return paid smart-research fields only when all server-side gates pass."""
    max_allowed_usd = 0.06
    if os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_ALLOW_PAID") != "1":
        return {"allow_paid_execution": False}

    allowed_chat_id = os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_ALLOWED_CHAT_ID", "").strip()
    allowed_chat_title = os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_ALLOWED_CHAT_TITLE", "").strip()
    if not smart_research_chat_matches_gate(
        chat_id=chat_id,
        chat_title=chat_title,
        allowed_chat_id=allowed_chat_id,
        allowed_chat_title=allowed_chat_title,
    ):
        return {"allow_paid_execution": False}

    try:
        max_amount_usd = float(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_MAX_USD", "0.01"))
    except ValueError:
        return {"allow_paid_execution": False}
    if max_amount_usd <= 0 or max_amount_usd > max_allowed_usd:
        return {"allow_paid_execution": False}

    approval_ref_file = os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_APPROVAL_REF_FILE", "").strip()
    if not approval_ref_file:
        return {"allow_paid_execution": False}

    try:
        approval_ref = Path(approval_ref_file).read_text().strip()
    except OSError:
        return {"allow_paid_execution": False}
    if not approval_ref:
        return {"allow_paid_execution": False}

    return {
        "allow_paid_execution": True,
        "approval_ref": approval_ref,
        "max_amount_usd": max_amount_usd,
    }


def _smart_research_auto_resume_enabled() -> bool:
    return os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME", "1") != "0"


def _smart_research_auto_resume_attempts() -> int:
    try:
        attempts = int(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_ATTEMPTS", "24"))
    except ValueError:
        return 24
    return min(max(attempts, 1), 120)


def _smart_research_auto_resume_interval_seconds() -> float:
    try:
        interval = float(os.getenv("LIVINGIP_LEO_TELEGRAM_SMART_RESEARCH_AUTO_RESUME_INTERVAL_SECONDS", "10"))
    except ValueError:
        return 10.0
    return min(max(interval, 2.0), 120.0)


def _should_start_smart_research_auto_resume_poll(
    *,
    paid_work_order_id: str | None,
    http_status: int,
    proxy_body: dict,
) -> bool:
    if paid_work_order_id or not _smart_research_auto_resume_enabled():
        return False
    if not isinstance(proxy_body, dict):
        return False
    if proxy_body.get("status") != "payment_authorization_required":
        return False
    checkout = proxy_body.get("checkout")
    return isinstance(checkout, dict) and bool(checkout.get("checkoutUrl") or checkout.get("checkout_url"))


async def _poll_smart_research_auto_resume(
    msg,
    context: ContextTypes.DEFAULT_TYPE,
    *,
    research_goal: str,
    username: str | None,
) -> None:
    """Poll Leo's chat-bound research route until the x402 work order is visible."""
    if not AGENT_HTTP_RESEARCH_PROXY_URL:
        return
    key = (msg.chat_id, msg.message_id)
    if key in smart_research_auto_resume_tasks:
        return
    smart_research_auto_resume_tasks.add(key)
    attempts = _smart_research_auto_resume_attempts()
    interval = _smart_research_auto_resume_interval_seconds()
    try:
        for attempt in range(attempts):
            await asyncio.sleep(interval)
            payload = build_smart_research_proxy_payload(
                research_goal=research_goal,
                source="telegram",
                agent=AGENT_NAME.lower(),
                chat_id=msg.chat_id,
                message_id=msg.message_id,
                username=username,
                include_synthesis=True,
                auto_resume_poll=True,
            )
            try:
                status, proxy_body, proxy_reply = await post_chat_proxy(
                    url=AGENT_HTTP_RESEARCH_PROXY_URL,
                    payload=payload,
                    timeout_seconds=90,
                )
            except Exception as exc:
                logger.info(
                    "%s smart research auto-resume poll %d/%d failed: %s",
                    AGENT_NAME,
                    attempt + 1,
                    attempts,
                    exc,
                )
                continue

            classification = classify_smart_research_auto_resume_response(status, proxy_body)
            if classification == "pending":
                continue
            if classification == "ready" and proxy_reply:
                ack_reply, answer_reply = split_smart_research_auto_resume_reply(
                    proxy_body,
                    proxy_reply,
                )
                if ack_reply:
                    await _reply_text_native(msg, ack_reply, do_quote=True)
                if answer_reply:
                    await _reply_text_native(msg, answer_reply, do_quote=not ack_reply)
                visible_reply = "\n\n".join(
                    part for part in (ack_reply, answer_reply) if part
                ) or proxy_reply
                if msg.from_user:
                    entry = {
                        "user": research_goal[:500],
                        "bot": visible_reply[:500],
                        "username": username or "anonymous",
                    }
                    user_key = (msg.chat_id, msg.from_user.id)
                    history = conversation_history.setdefault(user_key, [])
                    history.append(entry)
                    if len(history) > MAX_HISTORY_USER:
                        history.pop(0)
                    chat_key = (msg.chat_id, 0)
                    chat_history = conversation_history.setdefault(chat_key, [])
                    chat_history.append(entry)
                    if len(chat_history) > MAX_HISTORY_CHAT:
                        chat_history.pop(0)
                    user_response_times[msg.from_user.id].append(time.time())
                _record_transcript(
                    msg,
                    visible_reply,
                    is_bot=True,
                    rio_response=visible_reply,
                    internal={
                        "agent": AGENT_NAME.lower(),
                        "http_research_proxy_auto_resume": True,
                        "http_status": status,
                        "schema": proxy_body.get("schema") if isinstance(proxy_body, dict) else None,
                    },
                )
                logger.info(
                    "%s auto-resumed paid smart research in Telegram chat_id=%s message_id=%s",
                    AGENT_NAME,
                    msg.chat_id,
                    msg.message_id,
                )
                return
            logger.info(
                "%s smart research auto-resume poll stopped on classification=%s status=%s",
                AGENT_NAME,
                classification,
                status,
            )
            return
        logger.info(
            "%s smart research auto-resume poll expired chat_id=%s message_id=%s attempts=%s",
            AGENT_NAME,
            msg.chat_id,
            msg.message_id,
            attempts,
        )
    finally:
        smart_research_auto_resume_tasks.discard(key)


async def _market_context_for_message(
    text: str,
    extra_terms: list[str] | tuple[str, ...] = (),
) -> tuple[str, dict, int, list[str]]:
    """Fetch structured market data for token questions without blocking the answer path."""
    token_mentions = extract_market_data_tokens(text, extra_terms=extra_terms)
    market_context = ""
    market_data_audit = {}
    t_market = time.monotonic()
    for token in token_mentions:
        try:
            data = await get_token_price(token)
            if data:
                price_str = format_price_context(data, token)
                if price_str:
                    market_context += price_str + "\n"
                    market_data_audit[token] = data
        except Exception as e:
            logger.warning("Market context lookup failed for %s: %s", token, e)
    market_duration = int((time.monotonic() - t_market) * 1000)
    return market_context.strip(), market_data_audit, market_duration, token_mentions


def _git_commit_archive(archive_path, filename: str):
    """Commit archived source to git so it survives git clean. (Rio review: data loss bug)"""
    import subprocess
    try:
        cwd = MAIN_WORKTREE
        subprocess.run(["git", "add", str(archive_path)], cwd=cwd, timeout=10,
                        capture_output=True, check=False)
        result = subprocess.run(
            ["git", "commit", "-m", f"telegram: archive {filename}\n\n"
             "Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>"],
            cwd=cwd, timeout=10, capture_output=True, check=False,
        )
        if result.returncode == 0:
            # Push with retry (Ganymede: abort rebase on failure, don't lose the file)
            for attempt in range(3):
                rebase = subprocess.run(["git", "pull", "--rebase", "origin", "main"],
                                        cwd=cwd, timeout=30, capture_output=True, check=False)
                if rebase.returncode != 0:
                    subprocess.run(["git", "rebase", "--abort"], cwd=cwd, timeout=10,
                                   capture_output=True, check=False)
                    logger.warning("Git rebase failed for archive %s (attempt %d), aborted", filename, attempt + 1)
                    continue
                push = subprocess.run(["git", "push", "origin", "main"],
                                      cwd=cwd, timeout=30, capture_output=True, check=False)
                if push.returncode == 0:
                    logger.info("Git committed archive: %s", filename)
                    return
            # All retries failed — file is still on filesystem (safety net), commit is uncommitted
            logger.warning("Git push failed for archive %s after 3 attempts (file preserved on disk)", filename)
    except Exception as e:
        logger.warning("Git commit archive failed: %s", e)


def _load_learnings() -> str:
    """Load Rio's learnings file for prompt injection. Sanitized (Ganymede: prompt injection risk).

    Dated entries older than 7 days are filtered out (Ganymede: stale learning TTL).
    Permanent entries (undated) always included.
    """
    try:
        raw = Path(LEARNINGS_FILE).read_text()[:4000]
        today = datetime.now(timezone.utc).date()
        lines = []
        for line in raw.split("\n"):
            # Check for dated entries [YYYY-MM-DD]
            date_match = re.search(r"\[(\d{4}-\d{2}-\d{2})\]", line)
            if date_match:
                try:
                    entry_date = datetime.strptime(date_match.group(1), "%Y-%m-%d").date()
                    if (today - entry_date).days > 7:
                        continue  # stale, skip
                except ValueError:
                    pass
            lines.append(line)
        return sanitize_message("\n".join(lines))
    except Exception:
        return ""


def _save_learning(correction: str, category: str = "factual"):
    """Append a learning to staging file. Cron syncs to git (same as archives).

    Categories: communication, factual, structured_data
    """
    try:
        # Write to staging file outside worktree (avoids read-only errors)
        staging_file = Path(ARCHIVE_DIR) / "pending-learnings.jsonl"
        import json as _json
        entry = _json.dumps({"category": category, "correction": correction,
                             "ts": datetime.now(timezone.utc).isoformat()})
        with open(staging_file, "a") as f:
            f.write(entry + "\n")
        logger.info("Learning staged: [%s] %s", category, correction[:80])
        return
    except Exception as e:
        logger.warning("Learning staging failed: %s", e)

    # No fallback — staging is the only write path. Cron syncs to git.


def _compress_history(history: list[dict]) -> str:
    """Extract key context from conversation history — 20 tokens, unmissable (Ganymede)."""
    if not history:
        return ""
    # Combine all text for entity/number extraction
    all_text = " ".join(h.get("user", "") + " " + h.get("bot", "") for h in history)
    tickers = sorted(set(re.findall(r"\$[A-Z]{2,10}", all_text)))
    numbers = re.findall(r"\$[\d,.]+[KMB]?|\d+\.?\d*%", all_text)
    parts = []
    if tickers:
        parts.append(f"Discussing: {', '.join(tickers)}")
    if numbers:
        parts.append(f"Key figures: {', '.join(numbers[:5])}")
    parts.append(f"Exchanges: {len(history)}")
    return " | ".join(parts)


def _format_conversation_history(chat_id: int, user_id: int) -> str:
    """Format conversation history with compressed context summary (Ganymede: Option C+A).

    In group chats, merges user-specific history with chat-level history
    so the bot sees exchanges from other users in the same chat.
    """
    user_key = (chat_id, user_id)
    chat_key = (chat_id, 0)  # chat-level history (all users)

    # Merge: chat-level history gives full group context
    chat_history = conversation_history.get(chat_key, [])
    user_history = conversation_history.get(user_key, [])

    # Use chat-level if available (group chats), otherwise user-level (DMs)
    history = chat_history if chat_history else user_history
    if not history:
        return "(No prior conversation)"

    # Compressed context first — hard for the model to miss
    summary = _compress_history(history)
    lines = [summary, ""]

    # Full exchange log for reference
    for exchange in history:
        who = exchange.get("username", "User")
        if exchange.get("user"):
            lines.append(f"@{who}: {exchange['user']}")
        if exchange.get("bot"):
            lines.append(f"Rio: {exchange['bot']}")
        lines.append("")
    return "\n".join(lines)


# Research intent patterns (Rhea: explicit /research + natural language fallback)
# Telegram appends @botname to commands in groups (Ganymede: /research@FutAIrdBot query)
RESEARCH_PATTERN = re.compile(r'/research(?:@\w+)?\s+(.+)', re.IGNORECASE)


async def _research_and_followup(msg, query: str, user):
    """Run X search and send a follow-up message with findings.

    Used when Opus triggers RESEARCH: tag — the user expects results back,
    not silent archival.
    """
    from x_client import search_tweets as _search
    logger.info("Research follow-up: searching X for '%s'", query)
    tweets = await _search(query, max_results=10, min_engagement=0)
    if not tweets:
        await msg.reply_text(f"Searched X for '{query}' — nothing recent found.")
        return

    # Build concise summary of findings
    lines = [f"Found {len(tweets)} recent posts about '{query}':\n"]
    for t in tweets[:5]:
        author = t.get("author", "?")
        text = t.get("text", "")[:200]
        url = t.get("url", "")
        lines.append(f"@{author}: {text}")
        if url:
            lines.append(f"  {url}")
        lines.append("")

    followup = "\n".join(lines)
    # Split if needed
    if len(followup) <= 4096:
        await msg.reply_text(followup)
    else:
        chunks = []
        remaining = followup
        while remaining:
            if len(remaining) <= 4096:
                chunks.append(remaining)
                break
            split_at = remaining.rfind("\n\n", 0, 4000)
            if split_at == -1:
                split_at = remaining.rfind("\n", 0, 4096)
            if split_at == -1:
                split_at = 4096
            chunks.append(remaining[:split_at])
            remaining = remaining[split_at:].lstrip("\n")
        for chunk in chunks:
            if chunk.strip():
                await msg.reply_text(chunk)

    # Also archive for pipeline
    await handle_research(msg, query, user, silent=True)


async def handle_research(msg, query: str, user, silent: bool = False):
    """Handle a research request — search X and archive results as sources.

    If silent=True, archive only — no messages posted. Used when triggered
    by RESEARCH: tag after Opus already responded.
    """
    username = user.username if user else "unknown"

    if not silent and not check_research_rate_limit(user.id if user else 0):
        remaining = get_research_remaining(user.id if user else 0)
        await msg.reply_text(f"Research limit reached (3/day). Resets at midnight UTC. {remaining} remaining.")
        return

    if not silent:
        await msg.chat.send_action("typing")

    logger.info("Research: searching X for '%s'", query)
    tweets = await search_tweets(query, max_results=15, min_engagement=0)
    logger.info("Research: got %d tweets for '%s'", len(tweets), query)
    if not tweets:
        if not silent:
            await msg.reply_text(f"No recent tweets found for '{query}'.")
        return

    # Fetch full content for top tweets (not just search snippets)
    from x_client import fetch_from_url
    for i, tweet in enumerate(tweets[:5]):  # Top 5 by engagement
        if i > 0:
            await asyncio.sleep(0.5)  # Ganymede: 500ms between calls, polite to Ben's API
        url = tweet.get("url", "")
        if url:
            try:
                full_data = await fetch_from_url(url)
                if full_data:
                    # Replace snippet with full text
                    full_text = full_data.get("text", "")
                    if full_text and len(full_text) > len(tweet.get("text", "")):
                        tweet["text"] = full_text
                    # Include article content if available
                    contents = full_data.get("contents", [])
                    if contents:
                        article_parts = []
                        for block in contents:
                            block_text = block.get("text", "")
                            if not block_text:
                                continue
                            block_type = block.get("type", "unstyled")
                            if block_type in ("header-one", "header-two", "header-three"):
                                article_parts.append(f"\n## {block_text}\n")
                            elif block_type == "blockquote":
                                article_parts.append(f"> {block_text}")
                            elif block_type == "list-item":
                                article_parts.append(f"- {block_text}")
                            else:
                                article_parts.append(block_text)
                        if article_parts:
                            tweet["text"] += "\n\n--- Article Content ---\n" + "\n".join(article_parts)
            except Exception as e:
                logger.warning("Failed to fetch full content for %s: %s", url, e)

    # Archive all tweets as ONE source file per research query
    # (not per-tweet — one extraction PR produces claims from the best material)
    try:
        # Write to staging dir (outside worktree — no read-only errors)
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        slug = re.sub(r"[^a-z0-9]+", "-", query[:60].lower()).strip("-")
        filename = f"{date_str}-x-research-{slug}.md"
        source_path = Path(ARCHIVE_DIR) / filename
        source_path.parent.mkdir(parents=True, exist_ok=True)

        # Build consolidated source file
        tweets_body = ""
        for i, tweet in enumerate(tweets, 1):
            tweets_body += f"\n### Tweet {i} — @{tweet['author']} ({tweet.get('engagement', 0)} engagement)\n"
            tweets_body += f"**URL:** {tweet.get('url', '')}\n"
            tweets_body += f"**Followers:** {tweet.get('author_followers', 0)} | "
            tweets_body += f"**Likes:** {tweet.get('likes', 0)} | **RT:** {tweet.get('retweets', 0)}\n\n"
            tweets_body += f"{tweet['text']}\n"

        source_content = f"""---
type: source
source_type: x-research
title: "X research: {query}"
url: ""
author: "multiple"
date: {date_str}
domain: internet-finance
format: social-media-collection
status: unprocessed
proposed_by: "@{username}"
contribution_type: research-direction
research_query: "{query.replace('"', "'")}"
tweet_count: {len(tweets)}
tags: [x-research, telegram-research]
---

# X Research: {query}

Submitted by @{username} via Telegram /research command.
{len(tweets)} tweets found, sorted by engagement.

{tweets_body}
"""
        source_path.write_text(source_content)
        archived = len(tweets)
        logger.info("Research archived: %s (%d tweets)", filename, archived)
    except Exception as e:
        logger.warning("Research archive failed: %s", e)

    if not silent:
        record_research_usage(user.id if user else 0)
        remaining = get_research_remaining(user.id if user else 0)
        top_authors = list(set(t["author"] for t in tweets[:5]))
        await msg.reply_text(
            f"Queued {archived} tweets about '{query}' for extraction. "
            f"Top voices: @{', @'.join(top_authors[:3])}. "
            f"Results will appear in the KB within ~30 minutes. "
            f"({remaining} research requests remaining today.)"
        )
    logger.info("Research: @%s queried '%s', archived %d tweets (silent=%s)", username, query, archived, silent)


# ─── Message Handlers ───────────────────────────────────────────────────


def _is_reply_to_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
    """Check if a message is a reply to one of the bot's own messages."""
    msg = update.message
    if not msg or not msg.reply_to_message:
        return False
    replied = msg.reply_to_message
    return replied.from_user is not None and replied.from_user.id == context.bot.id


async def handle_reply_to_bot(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle replies to the bot's messages — treat as tagged conversation."""
    if not _is_reply_to_bot(update, context):
        # Not a reply to us — fall through to buffer handler
        await handle_message(update, context)
        return
    logger.info("Reply to bot from @%s",
                update.message.from_user.username if update.message.from_user else "unknown")
    await handle_tagged(update, context)


async def handle_smart_research_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Route configured slash commands into the smart-research proxy path."""
    await handle_tagged(update, context)


def _previous_user_message(chat_id: int, user_id: int | None) -> str | None:
    if user_id is not None:
        history = conversation_history.get((chat_id, user_id), [])
        if history:
            return history[-1].get("user")
    chat_history = conversation_history.get((chat_id, 0), [])
    if chat_history:
        return chat_history[-1].get("user")
    return None


async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle ALL incoming group messages — buffer for triage."""
    if not update.message or not update.message.text:
        return

    msg = update.message
    text = msg.text.strip()

    # Skip very short messages
    if len(text) < MIN_MESSAGE_LENGTH:
        return

    if AGENT_HTTP_RESEARCH_PROXY_URL and extract_paid_work_order_id(text):
        await handle_tagged(update, context)
        return

    # Conversation window behavior depends on chat type (Rio: DMs vs groups)
    # DMs: auto-respond (always 1-on-1, no false positives)
    # Groups: silent context only (reply-to is the only follow-up trigger)
    user = msg.from_user
    is_dm = msg.chat.type == "private"

    if user:
        key = (msg.chat_id, user.id)
        if key in unanswered_count:
            unanswered_count[key] += 1

            if is_dm and unanswered_count[key] < CONVERSATION_WINDOW:
                # DM: auto-respond — conversation window fires
                logger.info("DM conversation window: @%s msg %d/%d",
                            user.username or "?", unanswered_count[key], CONVERSATION_WINDOW)
                await handle_tagged(update, context)
                return
            # Group: don't track silent messages in history (Ganymede: Option A)
        # History should be the actual conversation, not a log of everything said in the group
            # Expire window after CONVERSATION_WINDOW unanswered messages
            if unanswered_count[key] >= CONVERSATION_WINDOW:
                del unanswered_count[key]
                conversation_history.pop(key, None)
                logger.info("Conversation window expired for @%s", user.username or "?")

    # Capture to full transcript (all messages, all chats)
    _record_transcript(msg, text, is_bot=False)

    # Buffer for batch triage
    message_buffer.append({
        "text": sanitize_message(text),
        "user_id": msg.from_user.id if msg.from_user else None,
        "username": msg.from_user.username if msg.from_user else None,
        "display_name": msg.from_user.full_name if msg.from_user else None,
        "chat_id": msg.chat_id,
        "message_id": msg.message_id,
        "timestamp": msg.date.isoformat() if msg.date else datetime.now(timezone.utc).isoformat(),
        "reply_to": msg.reply_to_message.message_id if msg.reply_to_message else None,
    })


async def handle_tagged(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle messages that tag the bot."""
    if not update.message or not update.message.text:
        return

    msg = update.message
    user = msg.from_user
    text = sanitize_message(msg.text)

    # Rate limit check
    if user and is_rate_limited(user.id):
        await msg.reply_text("I'm processing other requests — try again in a few minutes.", do_quote=True)
        return

    logger.info("Tagged by @%s: %s", user.username if user else "unknown", text[:100])

    smart_research_goal = None
    previous_user_goal = _previous_user_message(msg.chat_id, user.id if user else None)
    paid_work_order_id = extract_paid_work_order_id(text) if AGENT_HTTP_RESEARCH_PROXY_URL else None
    if AGENT_HTTP_RESEARCH_PROXY_URL:
        smart_research_goal = extract_smart_research_goal(
            text,
            tuple(AGENT_SMART_RESEARCH_COMMAND_PREFIXES),
        )
        if paid_work_order_id and not smart_research_goal:
            smart_research_goal = previous_user_goal or text
        if not smart_research_goal and AGENT_AUTO_SMART_RESEARCH_FROM_CHAT:
            smart_research_goal = extract_auto_smart_research_goal(
                text,
                tuple(AGENT_MENTION_ALIASES),
            )
        if not smart_research_goal and AGENT_AUTO_SMART_RESEARCH_FROM_CHAT:
            smart_research_goal = extract_auto_smart_research_followup_goal(
                text,
                previous_user_goal,
                tuple(AGENT_MENTION_ALIASES),
            )
    if AGENT_HTTP_RESEARCH_PROXY_URL and smart_research_goal:
        payment_gate = smart_research_payment_fields_for_message(
            paid_work_order_id=paid_work_order_id,
            configured_payment_gate=_smart_research_payment_gate(
                msg.chat_id,
                msg.chat.title if getattr(msg, "chat", None) else None,
            ),
        )
        proxy_research_goal = smart_research_goal
        if should_attach_structured_market_context(smart_research_goal):
            market_context, market_data_audit, market_duration, market_tokens = await _market_context_for_message(
                smart_research_goal
            )
        else:
            market_context, market_data_audit, market_duration, market_tokens = "", {}, 0, []
        if market_context:
            logger.info(
                "%s smart research added structured market context for %s in %dms",
                AGENT_NAME,
                ",".join(market_tokens),
                market_duration,
            )
            proxy_research_goal = (
                f"{smart_research_goal}\n\n"
                "Structured live market-data context available before web research:\n"
                f"{market_context}\n\n"
                "Use the structured market-data context as primary evidence for price, volume, FDV, "
                "market cap, and liquidity. Do not say you cannot check these metrics when values "
                "are present above. For buy/sell wording, do not provide personalized financial advice; "
                "give market data, risks, and a concise non-advice thesis. Do not mention payment "
                "execution status unless the user asked about payments."
            )
        payload = build_smart_research_proxy_payload(
            research_goal=proxy_research_goal,
            source="telegram",
            agent=AGENT_NAME.lower(),
            chat_id=msg.chat_id,
            message_id=msg.message_id,
            username=user.username if user else None,
            include_synthesis=True,
            work_order_id=paid_work_order_id,
            original_research_goal=previous_user_goal if paid_work_order_id else None,
            **payment_gate,
        )
        try:
            status, proxy_body, proxy_reply = await _post_chat_proxy_with_typing(
                msg.chat,
                url=AGENT_HTTP_RESEARCH_PROXY_URL,
                payload=payload,
                timeout_seconds=90,
            )
        except Exception as e:
            logger.warning("%s HTTP smart research proxy failed: %s", AGENT_NAME, e)
            await msg.reply_text(
                f"{AGENT_NAME}'s smart research route is temporarily unavailable. "
                "Try again after the service recovers.",
                do_quote=True,
            )
            return

        if not proxy_reply:
            logger.warning("%s HTTP smart research proxy returned no reply (status=%s)", AGENT_NAME, status)
            await msg.reply_text(
                f"{AGENT_NAME}'s smart research route returned no usable reply. "
                "The Telegram bridge is fail-closed.",
                do_quote=True,
            )
            return

        if not await _reply_checkout_qr_photo(msg, proxy_body, do_quote=True):
            await _reply_text_native(msg, proxy_reply, do_quote=True)

        if _should_start_smart_research_auto_resume_poll(
            paid_work_order_id=paid_work_order_id,
            http_status=status,
            proxy_body=proxy_body,
        ):
            context.application.create_task(
                _poll_smart_research_auto_resume(
                    msg,
                    context,
                    research_goal=smart_research_goal,
                    username=user.username if user else None,
                )
            )
            logger.info(
                "%s started smart research auto-resume poll chat_id=%s message_id=%s",
                AGENT_NAME,
                msg.chat_id,
                msg.message_id,
            )

        if user:
            username = user.username or "anonymous"
            key = (msg.chat_id, user.id)
            unanswered_count[key] = 0
            entry = {"user": text[:500], "bot": proxy_reply[:500], "username": username}
            history = conversation_history.setdefault(key, [])
            history.append(entry)
            if len(history) > MAX_HISTORY_USER:
                history.pop(0)
            chat_key = (msg.chat_id, 0)
            chat_history = conversation_history.setdefault(chat_key, [])
            chat_history.append(entry)
            if len(chat_history) > MAX_HISTORY_CHAT:
                chat_history.pop(0)
            user_response_times[user.id].append(time.time())
        return

    if AGENT_HTTP_CHAT_PROXY_URL:
        payload = build_chat_proxy_payload(
            message=text,
            source="telegram",
            agent=AGENT_NAME.lower(),
            chat_id=msg.chat_id,
            message_id=msg.message_id,
            username=user.username if user else None,
        )
        try:
            status, proxy_body, proxy_reply = await _post_chat_proxy_with_typing(
                msg.chat,
                url=AGENT_HTTP_CHAT_PROXY_URL,
                payload=payload,
            )
        except Exception as e:
            logger.warning("%s HTTP chat proxy failed: %s", AGENT_NAME, e)
            await msg.reply_text(
                f"{AGENT_NAME}'s HTTP chat route is temporarily unavailable. "
                "Try again after the service recovers.",
                do_quote=True,
            )
            return

        if not proxy_reply:
            logger.warning("%s HTTP chat proxy returned no reply (status=%s)", AGENT_NAME, status)
            await msg.reply_text(
                f"{AGENT_NAME}'s HTTP chat route returned no usable reply. "
                "The Telegram bridge is fail-closed.",
                do_quote=True,
            )
            return

        await _reply_text_native(msg, proxy_reply, do_quote=True)

        if user:
            username = user.username or "anonymous"
            key = (msg.chat_id, user.id)
            unanswered_count[key] = 0
            entry = {"user": text[:500], "bot": proxy_reply[:500], "username": username}
            history = conversation_history.setdefault(key, [])
            history.append(entry)
            if len(history) > MAX_HISTORY_USER:
                history.pop(0)
            chat_key = (msg.chat_id, 0)
            chat_history = conversation_history.setdefault(chat_key, [])
            chat_history.append(entry)
            if len(chat_history) > MAX_HISTORY_CHAT:
                chat_history.pop(0)
            user_response_times[user.id].append(time.time())

        logger.info("%s proxied Telegram reply via HTTP chat route (status=%s)", AGENT_NAME, status)
        _record_transcript(
            msg,
            proxy_reply,
            is_bot=True,
            rio_response=proxy_reply,
            internal={
                "agent": AGENT_NAME.lower(),
                "http_chat_proxy": True,
                "http_status": status,
                "schema": proxy_body.get("schema") if isinstance(proxy_body, dict) else None,
                "llm_ok": proxy_body.get("llmOk") if isinstance(proxy_body, dict) else None,
            },
        )
        return

    # ─── Audit: init timing and tool call tracking ──────────────────
    response_start = time.monotonic()
    tool_calls = []

    # Check for /research command — run search BEFORE Opus so results are in context
    research_context = ""
    research_match = RESEARCH_PATTERN.search(text)
    if research_match:
        query = research_match.group(1).strip()
        logger.info("Research: searching X for '%s'", query)
        from x_client import search_tweets, check_research_rate_limit, record_research_usage
        if check_research_rate_limit(user.id if user else 0):
            tweets = await search_tweets(query, max_results=10, min_engagement=0)
            logger.info("Research: got %d tweets for '%s'", len(tweets), query)
            if tweets:
                # Archive as source file (staging dir)
                try:
                    slug = re.sub(r"[^a-z0-9]+", "-", query[:60].lower()).strip("-")
                    filename = f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}-x-research-{slug}.md"
                    source_path = Path(ARCHIVE_DIR) / filename
                    tweets_body = "\n".join(
                        f"@{t['author']} ({t.get('engagement',0)} eng): {t['text'][:200]}"
                        for t in tweets[:10]
                    )
                    source_path.write_text(f"---\ntype: source\nsource_type: x-research\ntitle: \"X research: {query}\"\ndate: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\ndomain: internet-finance\nstatus: unprocessed\nproposed_by: \"@{user.username if user else 'unknown'}\"\ncontribution_type: research-direction\n---\n\n{tweets_body}\n")
                    logger.info("Research archived: %s", filename)
                except Exception as e:
                    logger.warning("Research archive failed: %s", e)

                # Build context for Opus prompt
                research_context = f"\n## Fresh X Research Results for '{query}'\n"
                for t in tweets[:7]:
                    research_context += f"- @{t['author']}: {t['text'][:150]}\n"
                record_research_usage(user.id if user else 0)
        # Strip the /research command from text so Opus responds to the topic, not the command
        text = re.sub(r'/research(?:@\w+)?\s+', '', text).strip()
        if not text:
            text = query

    # Send typing indicator
    await msg.chat.send_action("typing")

    # Fetch any X/Twitter links in the message (tweet or article)
    x_link_context = ""
    x_urls = re.findall(r'https?://(?:twitter\.com|x\.com)/\w+/status/\d+', text)
    if x_urls:
        from x_client import fetch_from_url
        for url in x_urls[:3]:  # Cap at 3 links
            try:
                tweet_data = await fetch_from_url(url)
                if tweet_data:
                    x_link_context += f"\n## Linked Tweet by @{tweet_data['author']}\n"
                    if tweet_data.get("title"):
                        x_link_context += f"Title: {tweet_data['title']}\n"
                    x_link_context += f"{tweet_data['text'][:500]}\n"
                    x_link_context += f"Engagement: {tweet_data.get('engagement', 0)} | URL: {url}\n"
                    logger.info("Fetched X link: @%s — %s", tweet_data['author'], tweet_data['text'][:60])
            except Exception as e:
                logger.warning("Failed to fetch X link %s: %s", url, e)

    # Haiku pre-pass: does this message need an X search? (Option A: two-pass)
    t_haiku = time.monotonic()
    if not research_context:  # Skip if /research already ran
        try:
            haiku_prompt = (
                f"Does this Telegram message need a live X/Twitter search to answer well? "
                f"Only say YES if the user is asking about recent sentiment, community takes, "
                f"what people are saying, or emerging discussions.\n\n"
                f"Message: {text}\n\n"
                f"If YES, provide a SHORT search query (2-3 words max, like 'P2P.me' or 'MetaDAO buyback'). "
                f"Twitter search works best with simple queries — too many words returns nothing.\n\n"
                f"Respond with ONLY one of:\n"
                f"YES: [2-3 word query]\n"
                f"NO"
            )
            haiku_result = await call_openrouter("anthropic/claude-haiku-4.5", haiku_prompt, max_tokens=50)
            if haiku_result and haiku_result.strip().upper().startswith("YES:"):
                search_query = haiku_result.strip()[4:].strip()
                logger.info("Haiku pre-pass: research needed — '%s'", search_query)
                from x_client import search_tweets, check_research_rate_limit, record_research_usage
                if check_research_rate_limit(user.id if user else 0):
                    tweets = await search_tweets(search_query, max_results=10, min_engagement=0)
                    logger.info("Haiku research: got %d tweets", len(tweets))
                    if tweets:
                        research_context = f"\n## LIVE X Search Results (you just searched for '{search_query}' — cite these directly)\n"
                        for t in tweets[:7]:
                            research_context += f"- @{t['author']}: {t['text'][:200]}\n"
                        # Don't burn user's rate limit on autonomous searches (Ganymede)
                        # Archive as source
                        try:
                            slug = re.sub(r"[^a-z0-9]+", "-", search_query[:60].lower()).strip("-")
                            filename = f"{datetime.now(timezone.utc).strftime('%Y-%m-%d')}-x-research-{slug}.md"
                            source_path = Path(ARCHIVE_DIR) / filename
                            tweets_body = "\n".join(f"@{t['author']}: {t['text'][:200]}" for t in tweets[:10])
                            source_path.write_text(f"---\ntype: source\nsource_type: x-research\ntitle: \"X research: {search_query}\"\ndate: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\ndomain: internet-finance\nstatus: unprocessed\nproposed_by: \"@{user.username if user else 'unknown'}\"\ncontribution_type: research-direction\n---\n\n{tweets_body}\n")
                        except Exception as e:
                            logger.warning("Haiku research archive failed: %s", e)
        except Exception as e:
            logger.warning("Haiku pre-pass failed: %s", e)
    haiku_duration = int((time.monotonic() - t_haiku) * 1000)
    if research_context:
        tool_calls.append({
            "tool": "haiku_prepass", "input": {"query": text[:200]},
            "output": {"triggered": True, "result_length": len(research_context)},
            "duration_ms": haiku_duration,
        })

    # ─── Query reformulation for follow-ups ────────────────────────
    # Conversational follow-ups ("you're wrong", "tell me more") are unsearchable.
    # Use Haiku to rewrite them into standalone queries using conversation context.
    search_query_text = text  # default: use raw message
    user_key = (msg.chat_id, user.id if user else 0)
    hist = conversation_history.get(user_key, [])
    if hist:
        # There's conversation history — check if this is a follow-up
        try:
            last_exchange = hist[-1]
            recent_context = ""
            if last_exchange.get("user"):
                recent_context += f"User: {last_exchange['user'][:300]}\n"
            if last_exchange.get("bot"):
                recent_context += f"Bot: {last_exchange['bot'][:300]}\n"
            reformulate_prompt = (
                f"A user is in a conversation. Given the recent exchange and their new message, "
                f"rewrite the new message as a STANDALONE search query that captures what they're "
                f"actually asking about. The query should work for semantic search — specific topics, "
                f"entities, and concepts.\n\n"
                f"Recent exchange:\n{recent_context}\n"
                f"New message: {text}\n\n"
                f"If the message is already a clear standalone question or topic, return it unchanged.\n"
                f"If it's a follow-up, correction, or reference to the conversation, rewrite it.\n\n"
                f"Return ONLY the rewritten query, nothing else. Max 30 words."
            )
            reformulated = await call_openrouter("anthropic/claude-haiku-4.5", reformulate_prompt, max_tokens=80)
            if reformulated and reformulated.strip() and len(reformulated.strip()) > 3:
                search_query_text = reformulated.strip()
                logger.info("Query reformulated: '%s' → '%s'", text[:60], search_query_text[:60])
                tool_calls.append({
                    "tool": "query_reformulate", "input": {"original": text[:200], "history_turns": len(hist)},
                    "output": {"reformulated": search_query_text[:200]},
                    "duration_ms": 0,  # included in haiku timing
                })
        except Exception as e:
            logger.warning("Query reformulation failed: %s", e)
            # Fall through — use raw text

    # Full retrieval pipeline: keyword → decompose → vector → RRF merge
    retrieval = await orchestrate_retrieval(
        text=text,
        search_query=search_query_text,
        kb_read_dir=KB_READ_DIR,
        kb_index=kb_index,
        llm_fn=call_openrouter,
        triage_model=TRIAGE_MODEL,
        retrieve_context_fn=retrieve_context,
        retrieve_vector_fn=retrieve_vector_context,
        kb_scope=AGENT_KB_SCOPE,
    )
    kb_context_text = retrieval["kb_context_text"]
    kb_ctx = retrieval["kb_ctx"]
    retrieval_layers = retrieval["retrieval_layers"]
    tool_calls.extend(retrieval["tool_calls"])

    # Fetch live market data for any tokens mentioned (Rhea: market-data API)
    entity_terms = [tag for ent in kb_ctx.entities for tag in ent.tags]
    market_context, market_data_audit, market_duration, token_mentions = await _market_context_for_message(
        text,
        extra_terms=entity_terms,
    )
    if token_mentions:
        tool_calls.append({
            "tool": "market_data", "input": {"tickers": token_mentions},
            "output": market_data_audit,
            "duration_ms": market_duration,
        })

    # Build Opus prompt — Rio's voice
    prompt = f"""You are Rio, the Teleo internet finance agent. Your Telegram handle is @FutAIrdBot — that IS you. Users tag @FutAIrdBot to reach you. Never say "I'm not FutAIrdBot." You are also @futaRdIO on X. You have deep knowledge about futarchy, prediction markets, token governance, and the MetaDAO ecosystem.

## How to sound
Write like a sharp analyst talking to peers, not like an AI. Specifically:
- Use your knowledge naturally. Don't say "the KB tracks" or "at experimental confidence" or "our claims show." Just state what you know and how confident you are in plain language.
- Have a take. You're an analyst, not a summarizer. Say what you actually think.
- Before you respond, ask yourself: "Does every sentence here add something the user doesn't already know?" If a sentence just restates context, agrees without adding insight, or pads with filler — cut it. Your goal is signal density, not word count.
- Short questions deserve short answers. If someone asks a factual question, give the fact. Don't surround it with caveats, context, and "the honest picture is" framing.
- Long answers are fine when the question is genuinely complex or the user asks for depth. But earn every paragraph — each one should contain a distinct insight the previous one didn't cover.
- Match the user's energy. If they wrote one line, respond in kind.
- Sound human. No em dashes, no "That said", no "It's worth noting." Just say the thing.
- No markdown. Plain text only.
- When you're uncertain, just say so simply. "I'm not sure about X" beats "we don't have data on this yet."

## Your learnings (corrections from past conversations — prioritize these over KB data when they conflict)
{_load_learnings()}

## What you know about this topic
{kb_context_text}

## KB Tools — SEARCH UNTIL YOU HAVE ENOUGH

You have 8 tools to search the knowledge base. The context above is an initial retrieval pass — it is almost never sufficient on its own. You MUST use tools to verify and deepen your understanding before answering.

**Your retrieval loop (follow this every time):**
1. Review the initial context above. Identify what's missing or unclear.
2. Use tools to fill gaps — search for sources, explore graph edges, read full claims.
3. After each tool result, ask yourself: "Do I have enough to give a substantive, grounded answer?"
4. If NO — search again with different terms, follow more graph edges, read the original source.
5. If YES — compose your answer. You have up to 6 tool calls, use them.

**Tool selection rules:**
- Someone asks about a specific author/paper/research → call find_by_source AND search_sources to find ALL material from that source
- You see a claim but need the original article → call read_source with the source title
- You want to understand the argument structure around a claim → call explore_graph to see what supports, challenges, and depends on it
- Initial claims don't cover the topic well → call search_kb with refined keywords
- You want to trace an entity's full network → call list_entity_links then read linked items
- You want to find original research documents → call search_sources by topic/author

**Critical rules:**
- DO NOT guess or hallucinate details about specific research — use tools to get actual data
- DO NOT answer from just the initial retrieval context if the question asks about specific research — always trace back to the source
- When you find a claim, explore its graph edges — connected claims often contain the nuance the user needs
- If search_kb returns poor results, try search_sources or find_by_source with different keywords

{f"## Live Market Data{chr(10)}{market_context}" if market_context else ""}

{research_context}

{x_link_context}

## Conversation History (NEVER ask a question your history already answers)
{_format_conversation_history(msg.chat_id, user.id if user else 0)}

## The message you're responding to
From: @{user.username if user else 'unknown'}
Message: {text}

Respond now. Be substantive but concise. If they're wrong about something, say so directly. If they know something you don't, tell them it's worth digging into. If they correct you, accept it and build on the correction. Do NOT respond to messages that aren't directed at you — only respond when tagged or replied to.

IMPORTANT: Special tags you can append at the end of your response (after your main text):

1. LEARNING: [category] [what you learned]
   Categories: factual, communication, structured_data
   Only when genuinely learned something. Most responses have none.
   NEVER save a learning about what data you do or don't have access to.

2. RESEARCH: [search query]
   Triggers a live X search and sends results back to the chat. Use when the user asks about recent activity, sentiment, or discussions.

3. SOURCE: [description of what to ingest]
   When a user shares valuable source material (X posts, articles, data). Creates a source file in the ingestion pipeline, attributed to the user. Include the verbatim content — don't alter or summarize the user's contribution. Use this when someone drops a link or shares original analysis worth preserving.

4. CLAIM: [specific, disagreeable assertion]
   When a user makes a specific claim with evidence that could enter the KB. Creates a draft claim file attributed to them. Only for genuine claims — not opinions or questions.

5. CONFIDENCE: [0.0-1.0]
   ALWAYS include this tag. Rate how well the KB context above actually helped you answer this question. 1.0 = KB had exactly what was needed. 0.5 = KB had partial/tangential info. 0.0 = KB had nothing relevant, you answered from general knowledge. This is for internal audit only — never visible to users."""

    # Call Opus with KB tools — agent can drill into claims, entities, and sources
    from kb_tools import TOOL_DEFINITIONS, execute_tool
    _tool_executor = lambda name, args: execute_tool(name, args, KB_READ_DIR)
    response, kb_tool_audit = await call_openrouter_with_tools(
        RESPONSE_MODEL, prompt, TOOL_DEFINITIONS, _tool_executor, max_tokens=1024,
        max_iterations=6)
    if kb_tool_audit:
        for t in kb_tool_audit:
            if t.get("type") == "reasoning":
                tool_calls.append({"type": "kb_reasoning", **t})
            else:
                tool_calls.append({"tool": f"kb:{t.get('tool', 'unknown')}", **{k: v for k, v in t.items() if k != "tool"}})

    if not response:
        await msg.reply_text("Processing error — I'll get back to you.", do_quote=True)
        return

    # Parse LEARNING and RESEARCH tags before posting
    display_response = response

    # Auto-learning (Rhea: zero-cost self-write trigger)
    learning_lines = re.findall(r'^LEARNING:\s*(factual|communication|structured_data)\s+(.+)$',
                                response, re.MULTILINE)
    if learning_lines:
        display_response = re.sub(r'\nLEARNING:\s*\S+\s+.+$', '', display_response, flags=re.MULTILINE).rstrip()
        for category, correction in learning_lines:
            _save_learning(correction.strip(), category.strip())
            logger.info("Auto-learned [%s]: %s", category, correction[:80])

    # Auto-research (Ganymede: LLM-driven research trigger)
    # Skip if Haiku pre-pass already searched (prevents double-fire + duplicate "No tweets found" messages)
    research_lines = re.findall(r'^RESEARCH:\s+(.+)$', response, re.MULTILINE)
    if research_lines:
        display_response = re.sub(r'\nRESEARCH:\s+.+$', '', display_response, flags=re.MULTILINE).rstrip()
        if not research_context:  # Only fire if Haiku didn't already search
            for query in research_lines:
                # Send follow-up with findings (not silent — user expects results)
                asyncio.get_event_loop().create_task(
                    _research_and_followup(msg, query.strip(), user))
                logger.info("Auto-research triggered (will follow up): %s", query[:80])

    # SOURCE: tag — Rio flags content for pipeline ingestion (verbatim, attributed)
    source_lines = re.findall(r'^SOURCE:\s+(.+)$', response, re.MULTILINE)
    if source_lines:
        display_response = re.sub(r'\nSOURCE:\s+.+$', '', display_response, flags=re.MULTILINE).rstrip()
        for source_text in source_lines:
            _create_inline_source(source_text.strip(), text, user, msg)
            logger.info("Inline SOURCE created: %s", source_text[:80])

    # CLAIM: tag — Rio flags a specific assertion for claim drafting
    claim_lines = re.findall(r'^CLAIM:\s+(.+)$', response, re.MULTILINE)
    if claim_lines:
        display_response = re.sub(r'\nCLAIM:\s+.+$', '', display_response, flags=re.MULTILINE).rstrip()
        for claim_text in claim_lines:
            _create_inline_claim(claim_text.strip(), text, user, msg)
            logger.info("Inline CLAIM drafted: %s", claim_text[:80])

    # CONFIDENCE: tag — model self-rated retrieval quality (audit only)
    # Handles: "CONFIDENCE: 0.8", "CONFIDENCE: [0.8]", "Confidence: 0.8", case-insensitive
    # Ganymede: must strip from display even if the model deviates from exact format
    confidence_score = None
    confidence_match = re.search(r'^CONFIDENCE:\s*\[?([\d.]+)\]?', response, re.MULTILINE | re.IGNORECASE)
    if confidence_match:
        try:
            confidence_score = max(0.0, min(1.0, float(confidence_match.group(1))))
        except ValueError:
            pass
    # Strip ANY line starting with CONFIDENCE (broad match — catches format deviations)
    display_response = re.sub(r'\n?^CONFIDENCE\s*:.*$', '', display_response, flags=re.MULTILINE | re.IGNORECASE).rstrip()

    # ─── Audit: write response_audit record ────────────────────────
    response_time_ms = int((time.monotonic() - response_start) * 1000)
    tool_calls.append({
        "tool": "llm_call", "input": {"model": RESPONSE_MODEL},
        "output": {"response_length": len(response), "tags_found": {
            "learning": len(learning_lines) if learning_lines else 0,
            "research": len(research_lines) if research_lines else 0,
            "source": len(source_lines) if source_lines else 0,
            "claim": len(claim_lines) if claim_lines else 0,
        }},
        "duration_ms": response_time_ms - sum(tc.get("duration_ms", 0) for tc in tool_calls),
    })

    # Claims audit — already built by orchestrate_retrieval with RRF ranking
    claims_audit = retrieval.get("claims_audit", [])

    # ─── Eval: URL fabrication check ──────────────────────────────
    blocked = False
    block_reason = None
    display_response, fabricated_urls = check_url_fabrication(display_response, kb_context_text)
    if fabricated_urls:
        logger.warning("URL fabrication detected (%d URLs removed): %s", len(fabricated_urls), text[:80])

    # ─── Eval: confidence floor ────────────────────────────────────
    display_response, blocked, block_reason = apply_confidence_floor(display_response, confidence_score)
    if blocked:
        logger.warning("Confidence floor triggered: %.2f for query: %s", confidence_score, text[:100])

    # ─── Eval: cost alert ──────────────────────────────────────────
    response_cost = getattr(response, 'cost', 0.0) if response else 0.0
    response_prompt_tokens = getattr(response, 'prompt_tokens', 0) if response else 0
    response_completion_tokens = getattr(response, 'completion_tokens', 0) if response else 0
    if response_cost > COST_ALERT_THRESHOLD:
        logger.warning("Cost alert: $%.4f for query: %s (model=%s)", response_cost, text[:80], RESPONSE_MODEL)

    # Detect retrieval gap (Rio: most valuable signal for KB improvement)
    retrieval_gap = None
    if not claims_audit and not (kb_ctx and kb_ctx.entities):
        retrieval_gap = f"No KB matches for: {text[:200]}"
    elif confidence_score is not None and confidence_score < 0.3:
        retrieval_gap = f"Low confidence ({confidence_score}) — KB may lack coverage for: {text[:200]}"

    # Conversation window (Ganymede + Rio: capture prior messages)
    conv_window = None
    if user:
        hist = conversation_history.get((msg.chat_id, user.id), [])
        if hist:
            conv_window = _json.dumps(hist[-5:])

    try:
        from lib.db import insert_response_audit
        insert_response_audit(
            _audit_conn,
            timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
            chat_id=msg.chat_id,
            user=f"@{user.username}" if user and user.username else "unknown",
            agent="rio",
            model=RESPONSE_MODEL,
            query=text[:2000],
            conversation_window=conv_window,
            entities_matched=_json.dumps([{"name": e.name, "path": e.path}
                                          for e in (kb_ctx.entities if kb_ctx else [])]),
            claims_matched=_json.dumps(claims_audit),
            retrieval_layers_hit=_json.dumps(list(set(retrieval_layers))),
            retrieval_gap=retrieval_gap,
            market_data=_json.dumps(market_data_audit) if market_data_audit else None,
            research_context=research_context[:2000] if research_context else None,
            kb_context_text=kb_context_text[:10000],
            tool_calls=_json.dumps(tool_calls),
            raw_response=response[:5000],
            display_response=display_response[:5000],
            confidence_score=confidence_score,
            response_time_ms=response_time_ms,
            # Eval pipeline columns (schema v10)
            prompt_tokens=response_prompt_tokens,
            completion_tokens=response_completion_tokens,
            generation_cost=response_cost,
            total_cost=response_cost,  # same as generation_cost until embedding cost tracked
            blocked=1 if blocked else 0,
            block_reason=block_reason,
        )
        _audit_conn.commit()
        kb_tool_count = sum(1 for t in tool_calls if t.get("type") == "tool_call" or (t.get("tool", "").startswith("kb:") and t.get("type") != "kb_reasoning"))
        kb_reasoning_count = sum(1 for t in tool_calls if t.get("type") in ("reasoning", "kb_reasoning"))
        logger.info("Audit record written (confidence=%.2f, cost=$%.4f, layers=%s, %d claims, %d kb_tools, %d reasoning_steps, %dms%s)",
                     confidence_score or 0, response_cost, retrieval_layers,
                     len(claims_audit), kb_tool_count, kb_reasoning_count, response_time_ms,
                     ", BLOCKED" if blocked else "")
    except Exception as e:
        logger.warning("Failed to write audit record: %s", e)

    # Post response (without tag lines)
    # Telegram has a 4096 char limit — split long messages
    if len(display_response) <= 4096:
        await msg.reply_text(display_response, do_quote=True)
    else:
        # Split on paragraph boundaries where possible
        chunks = []
        remaining = display_response
        while remaining:
            if len(remaining) <= 4096:
                chunks.append(remaining)
                break
            # Find a good split point (paragraph break near 4000 chars)
            split_at = remaining.rfind("\n\n", 0, 4000)
            if split_at == -1:
                split_at = remaining.rfind("\n", 0, 4096)
            if split_at == -1:
                split_at = 4096
            chunks.append(remaining[:split_at])
            remaining = remaining[split_at:].lstrip("\n")
        # First chunk quotes the original message, rest are standalone follow-ups
        first = True
        for chunk in chunks:
            if chunk.strip():
                await msg.reply_text(chunk, quote=first)
                first = False

    # Update conversation state: reset window, store history (Ganymede+Rhea)
    if user:
        username = user.username or "anonymous"
        key = (msg.chat_id, user.id)
        unanswered_count[key] = 0  # reset — conversation alive
        entry = {"user": text[:500], "bot": response[:500], "username": username}
        # Per-user history
        history = conversation_history.setdefault(key, [])
        history.append(entry)
        if len(history) > MAX_HISTORY_USER:
            history.pop(0)
        # Chat-level history (group context — all users visible)
        chat_key = (msg.chat_id, 0)
        chat_history = conversation_history.setdefault(chat_key, [])
        chat_history.append(entry)
        if len(chat_history) > MAX_HISTORY_CHAT:
            chat_history.pop(0)

    # Record rate limit
    if user:
        user_response_times[user.id].append(time.time())

    # Log the exchange for audit trail
    logger.info("Rio responded to @%s (msg_id=%d)", user.username if user else "?", msg.message_id)

    # Record bot response to transcript (with internal reasoning)
    _record_transcript(msg, display_response, is_bot=True, rio_response=display_response,
                       internal={
                           "entities_matched": [e.name for e in kb_ctx.entities] if kb_ctx else [],
                           "claims_matched": len(kb_ctx.claims) if kb_ctx else 0,
                           "search_triggered": bool(research_context),
                           "learnings_written": bool(learning_lines) if 'learning_lines' in dir() else False,
                       })

    # Detect and fetch URLs for pipeline ingestion (all URLs, not just first)
    urls = _extract_urls(text)
    url_content = None
    for url in urls[:5]:  # Cap at 5 URLs per message
        logger.info("Fetching URL: %s", url)
        content = await _fetch_url_content(url)
        if content:
            logger.info("Fetched %d chars from %s", len(content), url)
            if url_content is None:
                url_content = content  # First URL's content for conversation archive
            _archive_standalone_source(url, content, user)

    # Archive the exchange as a source for pipeline (slow path)
    _archive_exchange(text, response, user, msg, url_content=url_content, urls=urls)


def _archive_standalone_source(url: str, content: str, user):
    """Create a standalone source file for a URL shared in Telegram.

    Separate from the conversation archive — this is the actual article/tweet
    entering the extraction pipeline as a proper source, attributed to the
    contributor who shared it. Ganymede: keep pure (no Rio analysis), two
    source_types (x-tweet vs x-article).
    """
    try:
        username = user.username if user else "anonymous"
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")

        # Extract author from URL or content
        author = "unknown"
        author_match = re.search(r"x\.com/(\w+)/", url) or re.search(r"twitter\.com/(\w+)/", url)
        if author_match:
            author = f"@{author_match.group(1)}"

        # Distinguish tweet vs article (Ganymede: different extraction behavior)
        is_article = "--- Article Content ---" in content and len(content) > 1000
        source_type = "x-article" if is_article else "x-tweet"
        fmt = "article" if is_article else "social-media"

        slug = re.sub(r"[^a-z0-9]+", "-", f"{author}-{url.split('/')[-1][:30]}".lower()).strip("-")
        filename = f"{date_str}-tg-shared-{slug}.md"
        source_path = Path(ARCHIVE_DIR) / filename

        # Don't overwrite if already archived
        if source_path.exists():
            return

        domain, sub_tags = _classify_content(content)
        all_tags = ["telegram-shared", source_type] + sub_tags

        source_content = f"""---
type: source
source_type: {source_type}
title: "{author} — shared via Telegram by @{username}"
author: "{author}"
url: "{url}"
date: {date_str}
domain: {domain}
format: {fmt}
status: unprocessed
proposed_by: "@{username}"
contribution_type: source-submission
tags: {all_tags}
---

# {author} — {'Article' if is_article else 'Tweet/Thread'}

Shared by @{username} via Telegram.
Source URL: {url}

## Content

{content}
"""
        source_path.write_text(source_content)
        logger.info("Standalone source archived: %s (shared by @%s)", filename, username)
    except Exception as e:
        logger.warning("Failed to archive standalone source %s: %s", url, e)


async def _fetch_url_content(url: str) -> str | None:
    """Fetch article/page content from a URL for pipeline ingestion.

    For X/Twitter URLs, uses Ben's API (x_client.fetch_from_url) which returns
    structured article content. For other URLs, falls back to raw HTTP fetch.
    """
    # X/Twitter URLs → use x_client for structured content
    if "x.com/" in url or "twitter.com/" in url:
        try:
            from x_client import fetch_from_url
            data = await fetch_from_url(url)
            if not data:
                logger.warning("x_client returned no data for %s", url)
                return None
            # Format structured content
            parts = []
            # Tweet text
            tweet_text = data.get("text", "")
            if tweet_text:
                parts.append(tweet_text)
            # Article content (contents[] array with typed blocks)
            contents = data.get("contents", [])
            if contents:
                parts.append("\n--- Article Content ---\n")
                for block in contents:
                    block_type = block.get("type", "unstyled")
                    block_text = block.get("text", "")
                    if not block_text:
                        continue
                    if block_type in ("header-one", "header-two", "header-three"):
                        parts.append(f"\n## {block_text}\n")
                    elif block_type == "blockquote":
                        parts.append(f"> {block_text}")
                    elif block_type == "list-item":
                        parts.append(f"- {block_text}")
                    else:
                        parts.append(block_text)
            result = "\n".join(parts)
            return result[:10000] if result else None
        except Exception as e:
            logger.warning("x_client fetch failed for %s: %s", url, e)
            return None

    # Non-X URLs → raw HTTP fetch with HTML stripping
    import aiohttp
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                if resp.status >= 400:
                    return None
                html = await resp.text()
                text = re.sub(r"<script.*?</script>", "", html, flags=re.DOTALL)
                text = re.sub(r"<style.*?</style>", "", text, flags=re.DOTALL)
                text = re.sub(r"<[^>]+>", " ", text)
                text = re.sub(r"\s+", " ", text).strip()
                return text[:10000]
    except Exception as e:
        logger.warning("Failed to fetch URL %s: %s", url, e)
        return None


def _extract_urls(text: str) -> list[str]:
    """Extract URLs from message text."""
    return re.findall(r"https?://[^\s<>\"']+", text)


def _archive_exchange(user_text: str, rio_response: str, user, msg,
                      url_content: str | None = None, urls: list[str] | None = None):
    """Archive a tagged exchange. Conversations go to telegram-archives/conversations/
    (not queue — skips extraction). Sources with URLs already have standalone files."""
    try:
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        username = user.username if user else "anonymous"
        slug = re.sub(r"[^a-z0-9]+", "-", user_text[:50].lower()).strip("-")
        filename = f"{date_str}-telegram-{username}-{slug}.md"

        # Conversations go to conversations/ subdir (Ganymede: skip extraction at source).
        # The cron only moves top-level ARCHIVE_DIR/*.md to queue — subdirs are untouched.
        conv_dir = Path(ARCHIVE_DIR) / "conversations"
        conv_dir.mkdir(parents=True, exist_ok=True)
        archive_path = conv_dir / filename

        # Extract rationale (the user's text minus the @mention and URL)
        rationale = re.sub(r"@\w+", "", user_text).strip()
        for url in (urls or []):
            rationale = rationale.replace(url, "").strip()

        # Determine priority — directed contribution with rationale gets high priority
        priority = "high" if rationale and len(rationale) > 20 else "medium"
        intake_tier = "directed" if rationale and len(rationale) > 20 else "undirected"

        url_section = ""
        if url_content:
            url_section = f"\n## Article Content (fetched)\n\n{url_content[:8000]}\n"

        domain, sub_tags = _classify_content(user_text + " " + rio_response)

        content = f"""---
type: source
source_type: telegram
title: "Telegram: @{username} — {slug}"
author: "@{username}"
url: "{urls[0] if urls else ''}"
date: {date_str}
domain: {domain}
format: conversation
status: unprocessed
priority: {priority}
intake_tier: {intake_tier}
rationale: "{rationale[:200]}"
proposed_by: "@{username}"
tags: [telegram, ownership-community]
---

## Conversation

**@{username}:**
{user_text}

**Rio (response):**
{rio_response}
{url_section}
## Agent Notes
**Why archived:** Tagged exchange in ownership community.
**Rationale from contributor:** {rationale if rationale else 'No rationale provided (bare link or question)'}
**Intake tier:** {intake_tier} — {'fast-tracked, contributor provided reasoning' if intake_tier == 'directed' else 'standard processing'}
**Triage:** Conversation may contain [CLAIM], [ENTITY], or [EVIDENCE] for extraction.
"""
        # Write to telegram-archives/ (outside worktree — no read-only errors)
        # A cron moves files into inbox/queue/ and commits them
        archive_path.write_text(content)
        logger.info("Archived exchange to %s (tier: %s, urls: %d)",
                     filename, intake_tier, len(urls or []))
    except Exception as e:
        logger.error("Failed to archive exchange: %s", e)


# ─── Batch Triage ───────────────────────────────────────────────────────


async def run_batch_triage(context: ContextTypes.DEFAULT_TYPE):
    """Batch triage of buffered messages every TRIAGE_INTERVAL seconds.

    Groups messages into conversation windows, sends to Haiku for classification,
    archives substantive findings.
    """
    global message_buffer

    if not message_buffer:
        return

    # Grab and clear buffer
    messages = message_buffer[:]
    message_buffer = []

    logger.info("Batch triage: %d messages to process", len(messages))

    # Group into conversation windows (messages within 5 min of each other)
    windows = _group_into_windows(messages, window_seconds=300)

    if not windows:
        return

    # Build triage prompt
    windows_text = ""
    for i, window in enumerate(windows):
        window_msgs = "\n".join(
            f"  @{m.get('username', '?')}: {m['text'][:200]}"
            for m in window
        )
        windows_text += f"\n--- Window {i+1} ({len(window)} messages) ---\n{window_msgs}\n"

    prompt = f"""Classify each conversation window. For each, respond with ONE tag:

[CLAIM] — Contains a specific, disagreeable proposition about how something works
[ENTITY] — Contains factual data about a company, protocol, person, or market
[EVIDENCE] — Contains data or argument that supports or challenges an existing claim about internet finance, futarchy, prediction markets, or token governance
[SKIP] — Casual conversation, not relevant to the knowledge base

Be generous with EVIDENCE — even confirming evidence strengthens the KB.

{windows_text}

Respond with ONLY the window numbers and tags, one per line:
1: [TAG]
2: [TAG]
..."""

    result = await call_openrouter(TRIAGE_MODEL, prompt, max_tokens=500)

    if not result:
        logger.warning("Triage LLM call failed — buffered messages dropped")
        return

    # Parse triage results — consolidate tagged windows per chat_id
    # Priority: CLAIM > EVIDENCE > ENTITY when merging windows from same chat
    TAG_PRIORITY = {"CLAIM": 3, "EVIDENCE": 2, "ENTITY": 1}
    chat_tagged: dict[int, dict] = {}  # chat_id -> {tag, messages}

    for line in result.strip().split("\n"):
        match = re.match(r"(\d+):\s*\[(\w+)\]", line)
        if not match:
            continue
        idx = int(match.group(1)) - 1
        tag = match.group(2).upper()

        if idx < 0 or idx >= len(windows):
            continue
        if tag not in ("CLAIM", "ENTITY", "EVIDENCE"):
            continue

        window = windows[idx]
        chat_id = window[0].get("chat_id", 0)

        if chat_id not in chat_tagged:
            chat_tagged[chat_id] = {"tag": tag, "messages": list(window)}
        else:
            # Merge windows from same chat — keep highest-priority tag
            existing = chat_tagged[chat_id]
            existing["messages"].extend(window)
            if TAG_PRIORITY.get(tag, 0) > TAG_PRIORITY.get(existing["tag"], 0):
                existing["tag"] = tag

    # Archive one source per chat_id
    for chat_id, data in chat_tagged.items():
        _archive_window(data["messages"], data["tag"])

    logger.info("Triage complete: %d windows → %d sources (%d chats)",
                len(windows), len(chat_tagged), len(chat_tagged))


def _group_into_windows(messages: list[dict], window_seconds: int = 300) -> list[list[dict]]:
    """Group messages into conversation windows by chat_id and time proximity.

    Groups by chat_id first, then splits on time gaps > window_seconds.
    Cap per-window at 50 messages (not 10 — one conversation shouldn't become 12 branches).
    """
    if not messages:
        return []

    # Group by chat_id first
    by_chat: dict[int, list[dict]] = {}
    for msg in messages:
        cid = msg.get("chat_id", 0)
        by_chat.setdefault(cid, []).append(msg)

    windows = []
    for chat_id, chat_msgs in by_chat.items():
        # Sort by timestamp within each chat
        chat_msgs.sort(key=lambda m: m.get("timestamp", ""))

        current_window = [chat_msgs[0]]
        for msg in chat_msgs[1:]:
            # Check time gap
            try:
                prev_ts = datetime.fromisoformat(current_window[-1].get("timestamp", ""))
                curr_ts = datetime.fromisoformat(msg.get("timestamp", ""))
                gap = (curr_ts - prev_ts).total_seconds()
            except (ValueError, TypeError):
                gap = window_seconds + 1  # Unknown gap → force split

            if gap > window_seconds or len(current_window) >= 50:
                windows.append(current_window)
                current_window = [msg]
            else:
                current_window.append(msg)

        if current_window:
            windows.append(current_window)

    return windows


def _archive_window(window: list[dict], tag: str):
    """Archive a triaged conversation window to inbox/queue/."""
    try:
        date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        first_user = window[0].get("username", "group")
        slug = re.sub(r"[^a-z0-9]+", "-", window[0]["text"][:40].lower()).strip("-")
        filename = f"{date_str}-telegram-{first_user}-{slug}.md"

        archive_path = Path(ARCHIVE_DIR) / filename
        archive_path.parent.mkdir(parents=True, exist_ok=True)

        # Build conversation content
        conversation = ""
        contributors = set()
        for msg in window:
            username = msg.get("username", "anonymous")
            contributors.add(username)
            conversation += f"**@{username}:** {msg['text']}\n\n"

        content = f"""---
type: source
source_type: telegram
title: "Telegram conversation: {slug}"
author: "{', '.join(contributors)}"
date: {date_str}
domain: internet-finance
format: conversation
status: unprocessed
priority: medium
triage_tag: {tag.lower()}
tags: [telegram, ownership-community]
---

## Conversation ({len(window)} messages, {len(contributors)} participants)

{conversation}

## Agent Notes
**Triage:** [{tag}] — classified by batch triage
**Participants:** {', '.join(f'@{u}' for u in contributors)}
"""
        # Write to telegram-archives/ (outside worktree)
        archive_path.write_text(content)
        logger.info("Archived window [%s]: %s (%d msgs, %d participants)",
                     tag, filename, len(window), len(contributors))
    except TimeoutError:
        logger.warning("Failed to archive window: worktree lock timeout")
    except Exception as e:
        logger.error("Failed to archive window: %s", e)


# ─── Bot Setup ──────────────────────────────────────────────────────────


async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /start command."""
    await update.message.reply_text(
        f"I'm {AGENT_NAME}, a TeleoHumanity agent. "
        f"Tag me with {AGENT_HANDLE} to ask about {AGENT_DOMAIN_EXPERTISE}. "
        "I'll ground my response in the configured agent runtime."
    )


async def stats_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /stats command — show KB stats."""
    kb_index.ensure_fresh()
    stats = get_db_stats()
    await update.message.reply_text(
        f"📊 KB Stats:\n"
        f"• {len(kb_index._claims)} claims indexed\n"
        f"• {len(kb_index._entities)} entities tracked\n"
        f"• {len(kb_index._positions)} agent positions\n"
        f"• {stats['merged_claims']} PRs merged\n"
        f"• {stats['contributors']} contributors"
    )


def _load_agent_config(config_path: str):
    """Load agent YAML config and set module-level variables."""
    global BOT_TOKEN_FILE, RESPONSE_MODEL, TRIAGE_MODEL, AGENT_KB_SCOPE
    global LEARNINGS_FILE, MAX_RESPONSE_PER_USER_PER_HOUR
    global AGENT_NAME, AGENT_HANDLE, AGENT_X_HANDLE, AGENT_DOMAIN_EXPERTISE
    global AGENT_HTTP_CHAT_PROXY_URL, AGENT_HTTP_RESEARCH_PROXY_URL
    global AGENT_RESPOND_TO_PRIVATE_CHATS, AGENT_MENTION_ALIASES, AGENT_SMART_RESEARCH_COMMAND_PREFIXES
    global AGENT_AUTO_SMART_RESEARCH_FROM_CHAT

    with open(config_path) as f:
        cfg = yaml.safe_load(f)

    AGENT_NAME = cfg.get("name", AGENT_NAME)
    AGENT_HANDLE = cfg.get("handle", AGENT_HANDLE)
    AGENT_X_HANDLE = cfg.get("x_handle", AGENT_X_HANDLE)
    AGENT_DOMAIN_EXPERTISE = cfg.get("domain_expertise", AGENT_DOMAIN_EXPERTISE)
    AGENT_HTTP_CHAT_PROXY_URL = cfg.get("http_chat_proxy_url")
    AGENT_HTTP_RESEARCH_PROXY_URL = cfg.get("http_research_proxy_url")
    AGENT_RESPOND_TO_PRIVATE_CHATS = bool(cfg.get("respond_to_private_chats", False))
    aliases = [AGENT_HANDLE, *cfg.get("mention_aliases", [])]
    AGENT_MENTION_ALIASES = sorted({alias for alias in aliases if alias})
    AGENT_SMART_RESEARCH_COMMAND_PREFIXES = cfg.get(
        "smart_research_command_prefixes",
        list(DEFAULT_SMART_RESEARCH_COMMAND_PREFIXES),
    )
    AGENT_AUTO_SMART_RESEARCH_FROM_CHAT = bool(cfg.get("auto_smart_research_from_chat", False))

    if cfg.get("bot_token_file"):
        BOT_TOKEN_FILE = f"/opt/teleo-eval/secrets/{cfg['bot_token_file']}"
    if cfg.get("response_model"):
        RESPONSE_MODEL = cfg["response_model"]
    if cfg.get("triage_model"):
        TRIAGE_MODEL = cfg["triage_model"]
    if cfg.get("learnings_file"):
        LEARNINGS_FILE = f"/opt/teleo-eval/workspaces/main/{cfg['learnings_file']}"
    if cfg.get("max_response_per_user_per_hour"):
        MAX_RESPONSE_PER_USER_PER_HOUR = cfg["max_response_per_user_per_hour"]
    if cfg.get("kb_scope", {}).get("primary"):
        AGENT_KB_SCOPE = cfg["kb_scope"]["primary"]

    logger.info("Loaded agent config: %s (scope: %s)", cfg.get("name", "unknown"),
                AGENT_KB_SCOPE or "all domains")
    return cfg


def _mention_filter_regex(agent_cfg: dict | None) -> str:
    """Build the Telegram mention regex for the active agent."""
    aliases = ["@teleo", "@FutAIrdBot"]
    if agent_cfg:
        aliases = [agent_cfg.get("handle", ""), *agent_cfg.get("mention_aliases", [])]
    cleaned = sorted({alias for alias in aliases if alias})
    if not cleaned:
        cleaned = ["@teleo", "@FutAIrdBot"]
    return "(?i)(" + "|".join(re.escape(alias) for alias in cleaned) + ")"


def main():
    """Start the bot."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", help="Agent YAML config file")
    parser.add_argument("--validate", action="store_true", help="Validate config and exit")
    args = parser.parse_args()

    # Load agent config if provided
    agent_cfg = None
    if args.config:
        agent_cfg = _load_agent_config(args.config)
        if args.validate:
            logger.info("Config valid: %s", args.config)
            return

    # Load token
    token_path = Path(BOT_TOKEN_FILE)
    if not token_path.exists():
        logger.error("Bot token not found at %s", BOT_TOKEN_FILE)
        sys.exit(1)
    token = token_path.read_text().strip()

    agent_name = agent_cfg.get("name", "Rio") if agent_cfg else "Rio"
    logger.info("Starting Teleo Telegram bot (%s)...", agent_name)

    # Initialize persistent audit connection (Ganymede + Rhea: once at startup, not per-response)
    global _audit_conn
    _audit_conn = sqlite3.connect(PIPELINE_DB, timeout=30)
    _audit_conn.row_factory = sqlite3.Row
    _audit_conn.execute("PRAGMA journal_mode=WAL")
    _audit_conn.execute("PRAGMA busy_timeout=10000")
    try:
        from lib.db import migrate
        migrate(_audit_conn)
        logger.info("Audit DB connection initialized, schema migrated")
    except Exception as e:
        logger.error("Audit DB migration failed — audit writes will fail: %s", e)

    # Prebuild KB index at startup so the first query doesn't pay the 29s rebuild cost
    logger.info("Prebuilding KB index...")
    kb_index.ensure_fresh(max_age_seconds=0)  # force immediate build
    logger.info("KB index ready: %d claims, %d entities",
                len(kb_index._claims), len(kb_index._entities))

    # Build application
    app = Application.builder().token(token).build()

    # Command handlers
    app.add_handler(CommandHandler("start", start_command))
    app.add_handler(CommandHandler("stats", stats_command))
    for command in smart_research_command_names(AGENT_SMART_RESEARCH_COMMAND_PREFIXES):
        app.add_handler(CommandHandler(command, handle_smart_research_command))

    # Tag handler — messages mentioning the bot
    # python-telegram-bot filters.Mention doesn't work for bot mentions in groups
    # Use a regex filter for the bot username
    app.add_handler(MessageHandler(
        filters.TEXT & filters.Regex(_mention_filter_regex(agent_cfg)),
        handle_tagged,
    ))

    if agent_cfg and AGENT_RESPOND_TO_PRIVATE_CHATS:
        app.add_handler(MessageHandler(
            filters.ChatType.PRIVATE & filters.TEXT & ~filters.COMMAND,
            handle_tagged,
        ))

    # Reply handler — replies to the bot's own messages continue the conversation
    reply_to_bot_filter = filters.TEXT & filters.REPLY & ~filters.COMMAND
    app.add_handler(MessageHandler(
        reply_to_bot_filter,
        handle_reply_to_bot,
    ))

    # All other text messages — buffer for triage
    app.add_handler(MessageHandler(
        filters.TEXT & ~filters.COMMAND,
        handle_message,
    ))

    # Batch triage job
    app.job_queue.run_repeating(
        run_batch_triage,
        interval=TRIAGE_INTERVAL,
        first=TRIAGE_INTERVAL,
    )

    # Transcript dump job — every 1 hour
    app.job_queue.run_repeating(
        _dump_transcripts,
        interval=3600,
        first=3600,
    )

    # Audit retention cleanup — daily, 90-day window (Ganymede: match transcript policy)
    async def _cleanup_audit(context=None):
        try:
            _audit_conn.execute("DELETE FROM response_audit WHERE timestamp < datetime('now', '-90 days')")
            _audit_conn.commit()
            logger.info("Audit retention cleanup complete")
        except Exception as e:
            logger.warning("Audit cleanup failed: %s", e)

    app.job_queue.run_repeating(
        _cleanup_audit,
        interval=86400,  # daily
        first=86400,
    )

    # Run
    logger.info("Bot running. Triage interval: %ds, transcript dump: 1h", TRIAGE_INTERVAL)
    app.run_polling(drop_pending_updates=True)


if __name__ == "__main__":
    main()
