Uname:Linux antigravity-cli 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64

Base Dir : /var/www/moonbloom

User : wp-moonbloom


403WebShell
403Webshell
Server IP : 85.155.190.233  /  Your IP : 216.73.216.103
Web Server : nginx/1.24.0
System : Linux antigravity-cli 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64
User : wp-moonbloom ( 1001)
PHP Version : 8.3.6
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /proc/thread-self/root/opt/Gemini_Swarm_Core/scripts/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /proc/thread-self/root/opt/Gemini_Swarm_Core/scripts/tg_bridge.py
import asyncio
import aiosqlite
import contextlib
import signal
import time
import html
import logging
import os
import re
import shutil
import sys
import uuid
import collections
import hmac
import hashlib
import json
from datetime import datetime
from urllib.parse import parse_qsl
from pathlib import Path

# FastAPI imports
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

_TOOL_CALL_RE = re.compile(r'^\s*\w+\([^)]*\)\s*$')

from aiogram import Bot, Dispatcher
from aiogram.filters import Command
from aiogram.types import Message, BotCommand

# Custom log handler to keep recent logs in memory for API dashboard
class InMemoryLogHandler(logging.Handler):
    def __init__(self, maxlen=300):
        super().__init__()
        self.logs = collections.deque(maxlen=maxlen)

    def emit(self, record):
        try:
            log_entry = {
                "timestamp": datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"),
                "level": record.levelname,
                "message": self.format(record)
            }
            self.logs.append(log_entry)
        except Exception:
            self.handleError(record)

# Base logging setup
in_memory_log = InMemoryLogHandler()
in_memory_log.setFormatter(logging.Formatter("%(message)s"))
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Standard console logging
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
logger.addHandler(console_handler)
# Add memory handler
logger.addHandler(in_memory_log)


_MD_CODE_BLOCK_RE = re.compile(r"```(\w*)\n?(.*?)\n?```", re.DOTALL)
_MD_INLINE_CODE_RE = re.compile(r"`([^`\n]+)`")
_MD_LINK_RE = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)")
_MD_BOLD_RE = re.compile(r"\*\*([^\*\n]+)\*\*")
_MD_ITALIC_RE = re.compile(r"(?<![\*\w])\*([^\*\n]+)\*(?!\w)")
_MD_HEADER_RE = re.compile(r"^#{1,6}\s+(.+)$", re.MULTILINE)


def md_to_tg_html(text: str) -> str:
    """Convert Markdown to Telegram-flavored HTML safely.

    Order matters: protect code/links FIRST (so their content is not touched by
    bold/italic regex), escape remaining text, then convert bold/italic, finally
    restore the protected placeholders.
    """
    placeholders: list[str] = []

    def stash(content: str) -> str:
        placeholders.append(content)
        return f"\x00PH{len(placeholders) - 1}\x00"

    text = _MD_CODE_BLOCK_RE.sub(
        lambda m: stash(f"<pre>{html.escape(m.group(2))}</pre>"), text
    )
    text = _MD_INLINE_CODE_RE.sub(
        lambda m: stash(f"<code>{html.escape(m.group(1))}</code>"), text
    )
    text = _MD_LINK_RE.sub(
        lambda m: stash(
            f'<a href="{html.escape(m.group(2), quote=True)}">{html.escape(m.group(1))}</a>'
        ),
        text,
    )
    text = html.escape(text, quote=False)
    text = _MD_HEADER_RE.sub(r"<b>\1</b>", text)
    text = _MD_BOLD_RE.sub(r"<b>\1</b>", text)
    text = _MD_ITALIC_RE.sub(r"<i>\1</i>", text)
    for i, content in enumerate(placeholders):
        text = text.replace(f"\x00PH{i}\x00", content)
    return text


def strip_markdown(text: str) -> str:
    """Plain-text fallback if HTML rendering fails Telegram validation."""
    text = _MD_CODE_BLOCK_RE.sub(lambda m: m.group(2), text)
    text = _MD_INLINE_CODE_RE.sub(lambda m: m.group(1), text)
    text = _MD_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2)})", text)
    text = _MD_BOLD_RE.sub(r"\1", text)
    text = _MD_ITALIC_RE.sub(r"\1", text)
    text = _MD_HEADER_RE.sub(r"\1", text)
    return text

TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
ALLOWED_USERS = [x.strip() for x in os.environ.get("TELEGRAM_ALLOWED_USERS", "").split(",") if x.strip()]

SWARM_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = SWARM_ROOT / "sessions.db"

# --- Dynamic Subagent Configurations & Fallbacks ---
AGENT_SKILLS_MAP = {
    "oracle": (
        "# Skills: Oracle\n"
        "- **Notion Knowledge Retrieval:** Deep search and indexing of Notion automation workspace.\n"
        "- **Semantic RAG:** Question-only vector representation and matching.\n"
        "- **Agentic QA:** Delivering synthesized context answers."
    ),
    "prometheus": (
        "# Skills: Prometheus\n"
        "- **Strategic Mapping:** Translating vague goals into technical requirements.\n"
        "- **Risk Assessment:** Identifying edge cases and architectural bottlenecks.\n"
        "- **Mission Decomposition:** Creating a step-by-step roadmap for parallel execution.\n"
        "- **Constraint Management:** Ensuring plans stay within the bounds of Gemini CLI mandates and project policies."
    ),
    "librarian": (
        "# Skills: Librarian\n"
        "- **Context Extraction:** Finding needles in haystacks within large codebases.\n"
        "- **Dependency Mapping:** Understanding how modules and services interact.\n"
        "- **Pattern Recognition:** Identifying established coding styles and architectural conventions.\n"
        "- **Technical Research:** Synthesizing internal code knowledge with external documentation."
    ),
    "junior": (
        "# Skills: Junior\n"
        "- **Surgical Implementation:** Making precise, minimal changes to achieve a goal.\n"
        "- **Test-Driven Development:** Using tests to guide and verify implementation.\n"
        "- **Debug Proficiency:** Identifying and fixing issues through empirical reproduction.\n"
        "- **Environment Management:** Running complex builds and verification pipelines."
    ),
    "quality-validator": (
        "# Skills: Quality_Validator\n"
        "- **Critical Review:** Identifying subtle bugs and stylistic inconsistencies.\n"
        "- **Exhaustive Verification:** Running comprehensive test suites and edge-case analysis.\n"
        "- **Standards Enforcement:** Ensuring strict adherence to `GEMINI.md` and workspace policies.\n"
        "- **Reporting:** Providing clear, actionable feedback for re-implementation if needed."
    ),
    "frontend": (
        "# Skills: Frontend\n"
        "- **Vanilla CSS & Modern Layouts:** Expertise in CSS Grid, Flexbox, Custom Properties, and responsive layout patterns.\n"
        "- **Semantic HTML & Accessibility:** Deep knowledge of WCAG standards, aria-roles, semantic layouts, and screen-reader accessibility.\n"
        "- **Surgical UI Refinement:** Making highly precise layout and visual changes without impacting surrounding structures.\n"
        "- **Interactive Prototyping:** Enhancing user interfaces with light, clean interactive logic using Vanilla JavaScript.\n"
        "- **Testing & Verification Tools:** Utilizing developer tools (Chrome DevTools MCP), responsive testing frameworks, and linters."
    ),
    "stitch": (
        "# Skills: Stitch\n"
        "- **Google Stitch Canvas Integration:** Utilizing `google-labs-code/stitch-skills` and Stitch MCP to extract canvas assets, inspect nodes, and query visual attributes.\n"
        "- **Design Token Compilation:** Mapping design styles (typography, color ramps, shadow elevations, grid configurations) to standard CSS variables or design token files.\n"
        "- **Automated Component Generation:** Compiling visual mockups into clean, semantic components ready for frontend integration.\n"
        "- **Visual Regression Testing:** Verifying that compiled components align perfectly with visual source layouts on the canvas."
    ),
    "jules": (
        "# Skills: Jules\n"
        "- **Autonomous Coding:** Solving complex programming tasks via Jules CLI.\n"
        "- **Cloud Agent Integration:** Interacting with Google's cloud coding engine.\n"
        "- **Refactoring:** Automated codebase improvements."
    )
}

@contextlib.asynccontextmanager
async def connect_db():
    db = await aiosqlite.connect(DB_PATH, timeout=30.0)
    try:
        await db.execute("PRAGMA journal_mode=WAL")
        await db.execute("PRAGMA busy_timeout=10000")
        await db.execute("PRAGMA synchronous=NORMAL")
        yield db
    finally:
        await db.close()

_GIT_URL_RE = re.compile(r"^https://[a-zA-Z0-9._/\-]+$")
_GITHUB_REPO_RE = re.compile(r"https://github\.com/([a-zA-Z0-9_\-]+)/([a-zA-Z0-9_\-\.]+?)(?:\.git)?(?:\s|$)")
_ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')

if not TOKEN:
    logging.error("TELEGRAM_BOT_TOKEN is not set.")
    sys.exit(1)

_ceo_instruction_path = SWARM_ROOT / "GEMINI.md"
_CEO_INSTRUCTION = (
    _ceo_instruction_path.read_text(encoding="utf-8")
    if _ceo_instruction_path.exists()
    else "You are the Lead Orchestrator of the Antigravity Swarm. Answer in Russian. Be concise."
)
# Short persona prefix used in every call to keep prompt size manageable
_CEO_PERSONA = _CEO_INSTRUCTION[:600].strip()

# In-memory conversation history per chat session
_sessions: dict[str, list[dict]] = {}
_MAX_HISTORY = 20
_MAX_DB_HISTORY_LIMIT = 200

bot = Bot(token=TOKEN)
dp = Dispatcher()

_task_registry: dict[str, dict] = {}
_registry_lock = asyncio.Lock()

# --- FastAPI Setup & Endpoints ---
app = FastAPI(title="Gemini Swarm Mission Control", debug=False)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://proimport-shop.ru",
        "https://www.proimport-shop.ru",
        "https://web.telegram.org",
        "https://k.telegram.org",
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
)

def verify_telegram_signature(init_data: str, bot_token: str) -> bool:
    try:
        parsed = dict(parse_qsl(init_data))
        if "hash" not in parsed:
            return False
        tg_hash = parsed.pop("hash")
        sorted_params = sorted(parsed.items())
        data_check_string = "\n".join(f"{k}={v}" for k, v in sorted_params)
        secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
        calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
        return hmac.compare_digest(calculated_hash, tg_hash)
    except Exception:
        return False

async def get_current_user(request: Request):
    auth_header = request.headers.get("Authorization")
    if not auth_header or not auth_header.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Unauthorized")
    init_data = auth_header.split(" ", 1)[1]
    if not verify_telegram_signature(init_data, TOKEN):
        raise HTTPException(status_code=401, detail="Invalid token signature")
    parsed = dict(parse_qsl(init_data))
    user_data = parsed.get("user")
    if not user_data:
        raise HTTPException(status_code=401, detail="No user data")
    try:
        user_info = json.loads(user_data)
        user_id = str(user_info.get("id"))
    except Exception:
        raise HTTPException(status_code=401, detail="Invalid user data format")
    if user_id not in ALLOWED_USERS:
        raise HTTPException(status_code=403, detail="Forbidden user")
    return user_id

class ConfirmRequest(BaseModel):
    job_id: str
    approved: bool

class SettingsRequest(BaseModel):
    auto_approve_routine: bool
    max_history_limit: int

class RollbackRequest(BaseModel):
    commit_hash: str

_FILE_CACHE = {}  # path_str -> (mtime, content, timestamp)

def read_cached_file(path: Path) -> str:
    path_str = str(path)
    now = time.time()
    
    if path_str in _FILE_CACHE:
        cache_time, content, check_time = _FILE_CACHE[path_str]
        if now - check_time < 5.0:
            return content
            
    try:
        if not path.exists():
            return ""
        mtime = path.stat().st_mtime
        if path_str in _FILE_CACHE:
            cache_time, content, check_time = _FILE_CACHE[path_str]
            if cache_time == mtime:
                _FILE_CACHE[path_str] = (mtime, content, now)
                return content
        
        content = path.read_text(encoding="utf-8")
        _FILE_CACHE[path_str] = (mtime, content, now)
        return content
    except Exception as e:
        logging.error(f"Error reading {path}: {e}")
        if path_str in _FILE_CACHE:
            return _FILE_CACHE[path_str][1]
        raise


@app.get("/api/status")
async def api_status(user_id: str = Depends(get_current_user)):
    async with _registry_lock:
        running_tasks = list(_task_registry.values())

    status_path = SWARM_ROOT / "SWARM_STATUS.md"
    objective = "Не определена"
    last_update = "Неизвестно"
    agents_list = []
    recent_logs = []

    # Defaults in case file parsing fails
    default_agents = [
        {"name": "CEO", "status_raw": "🟢 IDLE", "status": "idle", "task": "Monitoring Swarm", "progress": "100%"},
        {"name": "Prometheus", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
        {"name": "Librarian", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
        {"name": "Junior", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
        {"name": "Frontend", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
        {"name": "Stitch", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"},
        {"name": "Quality-Validator", "status_raw": "🟢 IDLE", "status": "idle", "task": "-", "progress": "100%"}
    ]

    if status_path.exists():
        try:
            content = read_cached_file(status_path)
            
            # Parse last update
            import re
            m_update = re.search(r"\*\*Last Update:\*\*?\s*(.*)", content)
            if m_update:
                last_update = m_update.group(1).strip()
                
            # Parse global objective
            m_obj = re.search(r"\*\*Current Global Objective:\*\*?\s*(.*)", content)
            if m_obj:
                objective = m_obj.group(1).strip()
                
            # Parse Agent Status Matrix
            lines = content.splitlines()
            in_matrix = False
            for line in lines:
                if "## 📊 Agent Status Matrix" in line:
                    in_matrix = True
                    continue
                if in_matrix:
                    if line.strip() == "---" or (line.strip() == "" and len(agents_list) > 0) or line.strip().startswith("##"):
                        in_matrix = False
                        continue
                    if line.startswith("|") and not ":" in line and not "Agent" in line:
                        parts = [p.strip() for p in line.split("|")]
                        if len(parts) >= 5:
                            agent_name = parts[1].replace("**", "").strip()
                            status_raw = parts[2].strip()
                            current_task = parts[3].strip()
                            progress = parts[4].strip()
                            
                            status_lower = status_raw.lower()
                            status_mapped = "idle"
                            if "running" in status_lower or "thinking" in status_lower or "выполняет" in status_lower or "🔵" in status_lower:
                                status_mapped = "running"
                            elif "paused" in status_lower or "waiting" in status_lower or "ожидает" in status_lower or "🟡" in status_lower:
                                status_mapped = "paused"
                            elif "idle" in status_lower or "🟢" in status_lower:
                                status_mapped = "idle"
                            elif "offline" in status_lower or "🔴" in status_lower:
                                status_mapped = "offline"
                                
                            agents_list.append({
                                "name": agent_name,
                                "status_raw": status_raw,
                                "status": status_mapped,
                                "task": current_task,
                                "progress": progress
                            })
            
            # Parse Recent Mission Log
            in_logs = False
            for line in lines:
                if "## 📝 Recent Mission Log" in line:
                    in_logs = True
                    continue
                if in_logs:
                    if line.strip() == "---" or line.strip().startswith("##"):
                        in_logs = False
                        continue
                    cleaned = line.strip()
                    if cleaned.startswith("- ") or cleaned.startswith("* "):
                        recent_logs.append(cleaned[2:])
                    elif cleaned and len(recent_logs) > 0 and not cleaned.startswith("|"):
                        recent_logs[-1] += " " + cleaned
        except Exception as e:
            logging.error(f"Error parsing SWARM_STATUS.md: {e}")
            
    if not agents_list:
        agents_list = default_agents

    tasks_list = []
    for task in running_tasks:
        tasks_list.append({
            "job_id": task["job_id"],
            "repo_url": task["repo_url"],
            "task_desc": task.get("task_desc", "Выполнение задачи"),
            "pid": task["pid"],
            "status": task.get("status", "running")
        })

    # System metrics
    import os
    import shutil
    try:
        load1, load5, load15 = os.getloadavg()
        cpu_load = f"{load1:.2f}, {load5:.2f}, {load15:.2f}"
    except Exception:
        cpu_load = "N/A"

    try:
        with open("/proc/meminfo", "r") as f:
            meminfo = {}
            for line in f:
                parts = line.split(":")
                if len(parts) == 2:
                    meminfo[parts[0].strip()] = parts[1].strip()
            mem_total = int(meminfo["MemTotal"].split()[0])
            mem_avail = int(meminfo.get("MemAvailable", meminfo.get("MemFree", "0")).split()[0])
            mem_used = mem_total - mem_avail
            ram_info = f"{mem_used // 1024} MB / {mem_total // 1024} MB ({((mem_used/mem_total)*100):.1f}%)"
    except Exception:
        ram_info = "N/A"

    try:
        total, used, free = shutil.disk_usage("/")
        disk_info = f"{used / (1024**3):.1f} GB / {total / (1024**3):.1f} GB ({((used/total)*100):.1f}%)"
    except Exception:
        disk_info = "N/A"

    # Git branch & commit
    git_branch = "unknown"
    git_commit = "unknown"
    try:
        rc_branch, out_branch, _ = await _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], str(SWARM_ROOT))
        if rc_branch == 0:
            git_branch = out_branch.strip()
        rc_commit, out_commit, _ = await _run_git(["git", "rev-parse", "--short", "HEAD"], str(SWARM_ROOT))
        if rc_commit == 0:
            git_commit = out_commit.strip()
    except Exception:
        pass

    return {
        "status": "active" if running_tasks else "idle",
        "tasks": tasks_list,
        "agents": agents_list,
        "objective": objective,
        "last_update": last_update,
        "recent_logs": recent_logs[:5],
        "system_stats": {
            "cpu": cpu_load,
            "ram": ram_info,
            "disk": disk_info
        },
        "git_info": {
            "branch": git_branch,
            "commit": git_commit
        }
    }

@app.get("/api/git/history")
async def api_git_history(user_id: str = Depends(get_current_user)):
    rc_active, active_hash, _ = await _run_git(["git", "rev-parse", "--short", "HEAD"], str(SWARM_ROOT))
    active_hash = active_hash.strip()
    
    rc_log, log_out, log_err = await _run_git(["git", "log", "-n", "10", '--pretty=format:%h|%an|%ar|%s'], str(SWARM_ROOT))
    if rc_log != 0:
        raise HTTPException(status_code=500, detail=f"Git log failed: {log_err}")
        
    commits = []
    for line in log_out.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("|", 3)
        if len(parts) == 4:
            h, author, when, subject = parts
            commits.append({
                "hash": h,
                "author": author,
                "when": when,
                "subject": subject,
                "active": h == active_hash or active_hash.startswith(h)
            })
    return {"commits": commits}

@app.post("/api/git/rollback")
async def api_git_rollback(req: RollbackRequest, user_id: str = Depends(get_current_user)):
    # 1. Reset git to the specified commit
    rc, out_git, err_git = await _run_git(["git", "reset", "--hard", req.commit_hash], str(SWARM_ROOT))
    if rc != 0:
        raise HTTPException(status_code=500, detail=f"Git reset failed: {err_git}")
    
    # 2. Support migration/rsync if we rolled back to nested structure
    gemini_dir = SWARM_ROOT / "Gemini_Swarm_Core"
    if gemini_dir.exists() and gemini_dir.is_dir():
        import subprocess
        subprocess.run(f"rsync -a {gemini_dir}/ {SWARM_ROOT}/ && rm -rf {gemini_dir}", shell=True)

    # 3. Asynchronously trigger background restart of the systemd service
    import subprocess
    subprocess.Popen("sleep 1 && systemctl restart tg-bridge.service", shell=True)
    return {"status": "ok", "message": "Rollback successful. Restarting bridge service..."}

@app.get("/api/logs")
async def api_logs(user_id: str = Depends(get_current_user)):
    return {"logs": list(in_memory_log.logs)}

def get_agent_permissions(agent_name: str) -> dict:
    isolation_path = SWARM_ROOT / "swarm-isolation.toml"
    allowed_tools = []
    access_level = "Read-Only"
    
    if isolation_path.exists():
        try:
            content = read_cached_file(isolation_path)
            import re
            rules = content.split("[[rule]]")
            normalized_name = agent_name.lower().replace("-", "_").strip()
            
            for rule in rules:
                if not rule.strip():
                    continue
                subagent_match = re.search(r'subagent\s*=\s*"([^"]+)"', rule)
                if subagent_match:
                    rule_subagent = subagent_match.group(1).lower().replace("-", "_").strip()
                    if rule_subagent == normalized_name:
                        tools_match = re.search(r'toolName\s*=\s*\[(.*?)\]', rule, re.DOTALL)
                        if tools_match:
                            tools_str = tools_match.group(1)
                            tools = [t.strip().strip('"').strip("'") for t in tools_str.split(",") if t.strip()]
                            allowed_tools.extend(tools)
                            
            if any(t in allowed_tools for t in ["write_to_file", "replace_file_content", "multi_replace_file_content"]):
                access_level = "Full Access"
            elif "run_command" in allowed_tools:
                access_level = "Testing & Verification"
            elif any(t in allowed_tools for t in ["view_file", "grep_search", "list_dir"]):
                access_level = "Read-Only"
        except Exception as e:
            logging.error(f"Error parsing swarm-isolation.toml: {e}")
            
    if not allowed_tools:
        if agent_name.lower() == "ceo":
            access_level = "Orchestrator"
            allowed_tools = ["all_tools"]
        else:
            access_level = "Read-Only"
            allowed_tools = ["view_file", "grep_search", "list_dir"]
            
    return {
        "allowed_tools": allowed_tools,
        "access_level": access_level
    }

def get_agent_mcp_servers(agent_name: str) -> list:
    active_mcps = []
    mcp_config_paths = [
        SWARM_ROOT / ".agents" / "plugins" / "antigravity-swarm" / "mcp_config.json",
        Path("/root/.gemini/antigravity/mcp_config.json"),
        Path("/root/.gemini/config/mcp_config.json")
    ]
    
    for path in mcp_config_paths:
        if path.exists():
            try:
                import json
                mcp_data = json.loads(path.read_text(encoding="utf-8"))
                servers = list(mcp_data.get("mcpServers", {}).keys())
                for s in servers:
                    if s not in active_mcps:
                        active_mcps.append(s)
            except Exception as e:
                logging.error(f"Error parsing mcp_config.json at {path}: {e}")
                
    mcp_servers = []
    dir_name = agent_name.lower().strip()
    
    if dir_name == "stitch":
        mcp_servers.append({
            "name": "Stitch MCP",
            "active": "stitch" in active_mcps,
            "description": "Google Stitch design canvas integration"
        })
    elif dir_name in ["prometheus", "librarian", "ceo"]:
        mcp_servers.append({
            "name": "Tavily MCP",
            "active": "tavily" in active_mcps,
            "description": "Web search and deep research"
        })
        mcp_servers.append({
            "name": "Exa MCP",
            "active": "exa" in active_mcps,
            "description": "Semantic search for business data"
        })
        mcp_servers.append({
            "name": "Google Developer Knowledge MCP",
            "active": "google-developer-knowledge" in active_mcps,
            "description": "Google Cloud, Android, Firebase, and Maps documentation search"
        })
    return mcp_servers



@app.get("/api/agent/{name}")
async def api_agent_detail(name: str, user_id: str = Depends(get_current_user)):
    dir_name = name.lower().strip()
    if not re.match(r"^[a-zA-Z0-9_\-]+$", dir_name):
        raise HTTPException(status_code=400, detail="Invalid agent name format")
    
    role_content = ""
    instructions_content = ""
    skills_content = ""
    model_name = "gemini-3.5-flash" # default
    
    # Custom mapping for model
    if dir_name in ["librarian", "frontend"]:
        model_name = "gemini-3.1-flash"
    elif dir_name in ["prometheus", "junior", "quality-validator", "oracle", "jules", "stitch", "ceo"]:
        model_name = "gemini-3.5-flash"

    # CEO does not have a folder in swarm/
    if dir_name == "ceo":
        role_content = "Главный агент (CEO / Orchestrator). Координирует работу всего роя, общается с владельцем, планирует миссии и контролирует безопасность."
        instructions_content = "Управление роем субагентов по методологии google-adk. Контроль безопасности выполнения команд на VPS. Соблюдение регламента ветвления Git."
        skills_content = "# Skills: CEO\n- **Orchestration:** Multi-agent task delegation and coordination.\n- **Security Enforcement:** Checking system health and VPS commands.\n- **Git & PR Management:** Enforcing clean repository operations."
    else:
        # Dynamic parsing from GEMINI.md
        parsed = parse_agent_from_gemini_md(dir_name)
        role_content = parsed["role"]
        instructions_content = parsed["instructions"]
        
        # Look up skills from AGENT_SKILLS_MAP, with fallback
        if dir_name in AGENT_SKILLS_MAP:
            skills_content = AGENT_SKILLS_MAP[dir_name]
        else:
            skills_content = f"# Skills: {name}\n- **Core Operations:** Executing tasks assigned by CEO.\n- **Context Awareness:** Working within the scope of the project."

    # Parse current status/task from SWARM_STATUS.md
    status_path = SWARM_ROOT / "SWARM_STATUS.md"
    current_status = "idle"
    status_raw = "🟢 IDLE"
    current_task = "-"
    progress = "100%"
    
    if status_path.exists():
        try:
            content = read_cached_file(status_path)
            # Parse matrix lines to find the agent
            for line in content.splitlines():
                if line.startswith("|") and not ":" in line and not "Agent" in line:
                    parts = [p.strip() for p in line.split("|")]
                    if len(parts) >= 5:
                        agent_name = parts[1].replace("**", "").strip()
                        if agent_name.lower() == name.lower():
                            status_raw = parts[2].strip()
                            current_task = parts[3].strip()
                            progress = parts[4].strip()
                            
                            status_lower = status_raw.lower()
                            if "running" in status_lower or "thinking" in status_lower or "выполняет" in status_lower or "🔵" in status_lower:
                                current_status = "running"
                            elif "paused" in status_lower or "waiting" in status_lower or "ожидает" in status_lower or "🟡" in status_lower:
                                current_status = "paused"
                            elif "idle" in status_lower or "🟢" in status_lower:
                                current_status = "idle"
                            elif "offline" in status_lower or "🔴" in status_lower:
                                current_status = "offline"
                            break
        except Exception as e:
            logging.error(f"Error parsing status for agent details: {e}")

    # Log filtering for this agent
    # 1. From SWARM_STATUS.md (recent mission log)
    filtered_recent_logs = []
    if status_path.exists():
        try:
            content = read_cached_file(status_path)
            lines = content.splitlines()
            in_logs = False
            for line in lines:
                if "## 📝 Recent Mission Log" in line:
                    in_logs = True
                    continue
                if in_logs:
                    if line.strip() == "---" or line.strip().startswith("##"):
                        in_logs = False
                        continue
                    cleaned = line.strip()
                    if cleaned.startswith("- ") or cleaned.startswith("* "):
                        log_msg = cleaned[2:]
                        # Filter criteria: contains agent name (case-insensitive) or specific keywords
                        if name.lower() in log_msg.lower() or (name.lower() == "quality-validator" and "validator" in log_msg.lower()):
                            filtered_recent_logs.append(log_msg)
        except Exception as e:
            logging.error(f"Error parsing logs for agent details: {e}")

    # 2. From in-memory system logs
    filtered_system_logs = []
    # Search for occurrences of agent name in in-memory logs
    for log in list(in_memory_log.logs):
        msg = log.get("message", "")
        # Look for agent name in message
        if name.lower() in msg.lower() or (name.lower() == "quality-validator" and "validator" in msg.lower()):
            filtered_system_logs.append(log)

    permissions = get_agent_permissions(name)
    mcp_servers = get_agent_mcp_servers(name)

    return {
        "name": name,
        "role": role_content,
        "instructions": instructions_content,
        "skills": skills_content,
        "status": current_status,
        "status_raw": status_raw,
        "task": current_task,
        "progress": progress,
        "model": model_name,
        "recent_logs": filtered_recent_logs,
        "system_logs": filtered_system_logs,
        "allowed_tools": permissions["allowed_tools"],
        "access_level": permissions["access_level"],
        "mcp_servers": mcp_servers
    }

@app.post("/api/confirm")
async def api_confirm(req: ConfirmRequest, user_id: str = Depends(get_current_user)):
    async with _registry_lock:
        task = _task_registry.get(req.job_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    
        try:
            await _kill_process_tree(task["pid"])
            logging.info(f"Task {req.job_id} (PID {task['pid']}) stopped by user via Mini App.")
        except Exception as e:
            logging.error(f"Failed to kill process {task['pid']}: {e}")
            raise HTTPException(status_code=500, detail=f"Failed to stop process: {e}")
    
    return {"status": "ok"}

@app.get("/api/settings")
async def api_get_settings(user_id: str = Depends(get_current_user)):
    auto_approve = True
    history_limit = 20
    try:
        async with connect_db() as db:
            async with db.execute("SELECT value FROM app_settings WHERE key = 'auto_approve_routine'") as cursor:
                row = await cursor.fetchone()
                if row:
                    auto_approve = row[0] == "true"
            async with db.execute("SELECT value FROM app_settings WHERE key = 'max_history_limit'") as cursor:
                row = await cursor.fetchone()
                if row:
                    history_limit = int(row[0])
    except Exception as e:
        logging.error(f"Failed to read settings: {e}")
    return {"auto_approve_routine": auto_approve, "max_history_limit": history_limit}

@app.post("/api/settings")
async def api_save_settings(req: SettingsRequest, user_id: str = Depends(get_current_user)):
    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('auto_approve_routine', ?)",
                ("true" if req.auto_approve_routine else "false",)
            )
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('max_history_limit', ?)",
                (str(req.max_history_limit),)
            )
            await db.commit()
        global _MAX_HISTORY
        _MAX_HISTORY = req.max_history_limit
        logging.info(f"Settings updated via Mini App: auto_approve={req.auto_approve_routine}, history_limit={req.max_history_limit}")
    except Exception as e:
        logging.error(f"Failed to save settings: {e}")
        raise HTTPException(status_code=500, detail=str(e))
    return {"status": "saved"}

# Mount static files for Telegram Mini App
mini_app_dir = Path("/opt/Gemini_Swarm_Core/src/web")
if not mini_app_dir.exists():
    mini_app_dir = SWARM_ROOT / "src" / "web"
    if not mini_app_dir.exists():
        mini_app_dir.mkdir(parents=True, exist_ok=True)

app.mount("/mini-app", StaticFiles(directory=str(mini_app_dir), html=True), name="mini-app")


def is_allowed(message: Message) -> bool:
    return str(message.from_user.id) in ALLOWED_USERS or str(message.chat.id) in ALLOWED_USERS


def _resolve_cli() -> str:
    local_agy = "/root/.local/bin/agy"
    if os.path.exists(local_agy):
        return local_agy
    return shutil.which("agy") or shutil.which("gemini") or "agy"


async def _kill_process_tree(pid: int):
    if sys.platform != "win32":
        try:
            pgid = os.getpgid(pid)
            os.killpg(pgid, signal.SIGTERM)
            for _ in range(30):
                await asyncio.sleep(0.1)
                try:
                    os.killpg(pgid, 0)
                except ProcessLookupError:
                    return
            os.killpg(pgid, signal.SIGKILL)
        except ProcessLookupError:
            pass
        except Exception as e:
            logging.error(f"Error killing process group {pid}: {e}")
    else:
        import subprocess
        try:
            subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
        except Exception as e:
            logging.error(f"Error killing Windows process tree {pid}: {e}")


async def _run_with_timeout(args, timeout, cwd=None, env=None):
    kwargs = {}
    if sys.platform != "win32":
        kwargs["preexec_fn"] = os.setpgrp
    
    proc = await asyncio.create_subprocess_exec(
        *args,
        cwd=cwd,
        env=env,
        stdin=asyncio.subprocess.DEVNULL,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        **kwargs
    )
    try:
        out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
        return proc.returncode, out, err
    except asyncio.TimeoutError:
        await _kill_process_tree(proc.pid)
        raise


async def get_active_session() -> tuple:
    """Returns (session_id or None, repo_path, task_name, conversation_id).
    
    If no active session exists, it defaults to:
    (None, str(SWARM_ROOT), "default", "default")
    """
    try:
        async with connect_db() as db:
            async with db.execute("SELECT value FROM app_settings WHERE key = 'active_session_id'") as cursor:
                row = await cursor.fetchone()
                active_id = int(row[0]) if row else None
                
            if active_id is not None:
                async with db.execute(
                    "SELECT id, repo_path, task_name, conversation_id FROM sessions WHERE id = ?",
                    (active_id,)
                ) as cursor:
                    s_row = await cursor.fetchone()
                    if s_row:
                        return s_row
                        
            async with db.execute("SELECT value FROM app_settings WHERE key = 'active_repo_path'") as cursor:
                row = await cursor.fetchone()
                repo_path = row[0] if row else str(SWARM_ROOT)
                
            return (None, repo_path, "default", "default")
    except Exception as e:
        logging.error(f"Error in get_active_session: {e}")
        return (None, str(SWARM_ROOT), "default", "default")


async def init_db():
    async with connect_db() as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS chat_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT NOT NULL,
                role TEXT NOT NULL,
                text TEXT NOT NULL,
                timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS long_term_memory (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT NOT NULL,
                fact TEXT NOT NULL,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS app_settings (
                key TEXT PRIMARY KEY,
                value TEXT NOT NULL
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS sessions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                repo_path TEXT NOT NULL,
                task_name TEXT NOT NULL,
                conversation_id TEXT NOT NULL,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                UNIQUE(repo_path, task_name)
            )
        """)
        await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_repo_task ON sessions (repo_path, task_name)")
        
        # Scheduler Tables
        await db.execute("""
            CREATE TABLE IF NOT EXISTS swarm_tasks (
                task_id INTEGER PRIMARY KEY AUTOINCREMENT,
                task_name TEXT UNIQUE NOT NULL,
                repo_path TEXT NOT NULL,
                cron_expression TEXT NOT NULL,
                active INTEGER DEFAULT 1,
                last_run_time TEXT
            )
        """)
        await db.execute("""
            CREATE TABLE IF NOT EXISTS swarm_run_sessions (
                session_id TEXT PRIMARY KEY,
                task_id INTEGER NOT NULL,
                status TEXT NOT NULL,
                started_at TEXT NOT NULL,
                finished_at TEXT,
                log_file_path TEXT,
                digest_summary TEXT,
                FOREIGN KEY(task_id) REFERENCES swarm_tasks(task_id)
            )
        """)

        try:
            await db.execute("ALTER TABLE chat_history ADD COLUMN session_db_id INTEGER DEFAULT NULL")
        except aiosqlite.OperationalError:
            pass # already exists
            
        await db.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_session ON chat_history (session_id)")
        await db.execute("CREATE INDEX IF NOT EXISTS idx_chat_history_session_db ON chat_history (session_db_id)")
        await db.execute("CREATE INDEX IF NOT EXISTS idx_long_term_memory_session ON long_term_memory (session_id)")
        await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ltm_session_fact ON long_term_memory (session_id, fact)")
        await db.commit()


async def extract_and_save_ltm(session_id: str, user_text: str, assistant_text: str):
    try:
        prompt = (
            "Analyze the following conversation exchange between a User and the CEO. "
            "Extract new, persistent facts or preferences about the user, their goals, or requirements. "
            "Return only the facts as a plain list, one per line (each starting with '-'). "
            "If no new facts are present, output only 'None'.\n\n"
            f"User: {user_text}\n"
            f"CEO: {assistant_text}"
        )
        session_db_id, repo_path, task_name, active_conversation_id = await get_active_session()
        cli = _resolve_cli()
        env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}
        
        args = [cli, "-p", prompt]
        if active_conversation_id and active_conversation_id != "default":
            args.extend(["--conversation", active_conversation_id])
            
        try:
            returncode, out, err = await _run_with_timeout(
                args, timeout=180, cwd=repo_path, env=env
            )
        except asyncio.TimeoutError:
            logging.error("LTM extraction subprocess timed out.")
            return

        if returncode != 0:
            stderr_text = err.decode("utf-8", errors="replace").strip()
            logging.error(f"LTM extraction failed with code {returncode}. Stderr: {stderr_text}")
            return

        raw_output = out.decode("utf-8", errors="replace")
        _SKIP = (
            "warning:",
            "ripgrep",
            "mcp",
            "using a terminal",
            "fallback",
            "falling back",
            "256-color",
        )
        lines = raw_output.splitlines()
        content_lines = []
        found = False
        for line in lines:
            clean_line = _ANSI_ESCAPE_RE.sub("", line).strip().lower()
            if not found:
                if not clean_line or any(p in clean_line for p in _SKIP):
                    continue
                found = True
            if _TOOL_CALL_RE.match(_ANSI_ESCAPE_RE.sub("", line)):
                continue
            content_lines.append(line)
        cleaned_output = "\n".join(content_lines).strip()

        facts = []
        for line in cleaned_output.splitlines():
            line = line.strip()
            if not line or line.lower() == "none":
                continue
            match = re.match(r"^[-*•\d\.\s]+(.*)$", line)
            if match:
                fact = match.group(1).strip()
                if fact and fact.lower() != "none":
                    facts.append(fact)
            else:
                if line:
                    facts.append(line)
        
        if not facts:
            return

        async with connect_db() as db:
            for fact in facts:
                try:
                    await db.execute(
                        "INSERT OR IGNORE INTO long_term_memory (session_id, fact) VALUES (?, ?)",
                        (session_id, fact)
                    )
                except aiosqlite.Error as e:
                    logging.error(f"Failed to insert fact to DB: {e}")
            await db.commit()
            
    except Exception as e:
        logging.error(f"Error in extract_and_save_ltm background task: {e}")


def extract_assistant_text_from_protobuf(data: bytes) -> str:
    def get_strings(bytes_data):
        i = 0
        strings = []
        while i < len(bytes_data):
            tag = 0
            shift = 0
            while i < len(bytes_data):
                b = bytes_data[i]
                i += 1
                tag |= (b & 0x7f) << shift
                if not (b & 0x80):
                    break
                shift += 7
            if i >= len(bytes_data):
                break
            wire_type = tag & 7
            if wire_type == 0:
                while i < len(bytes_data) and (bytes_data[i] & 0x80):
                    i += 1
                i += 1
            elif wire_type == 1:
                i += 8
            elif wire_type == 2:
                length = 0
                shift = 0
                while i < len(bytes_data):
                    b = bytes_data[i]
                    i += 1
                    length |= (b & 0x7f) << shift
                    if not (b & 0x80):
                        break
                    shift += 7
                if i + length > len(bytes_data):
                    break
                val = bytes_data[i:i+length]
                try:
                    s = val.decode('utf-8')
                    if len(s) > 10 and any(c.isalpha() for c in s):
                        strings.append(s)
                except UnicodeDecodeError:
                    pass
                strings.extend(get_strings(val))
                i += length
            elif wire_type == 5:
                i += 4
        return strings

    candidates = get_strings(data)
    if not candidates:
        return ""
    return max(candidates, key=len)


async def ask_ceo(session_id: str, text: str) -> str:
    """Call CEO via agy (Antigravity CLI) subprocess. Uses OAuth subscription, no API key needed."""
    session_db_id, repo_path, task_name, active_conversation_id = await get_active_session()
    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT INTO chat_history (session_id, role, text, session_db_id) VALUES (?, ?, ?, ?)",
                (session_id, "user", text, session_db_id)
            )
            await db.commit()
    except Exception as db_err:
        logging.error(f"Database error saving user message: {db_err}")

    history_rows = []
    ltm_facts = []
    try:
        async with connect_db() as db:
            async with db.execute(
                "SELECT role, text FROM (SELECT role, text, id FROM chat_history WHERE session_id = ? AND session_db_id IS ? ORDER BY id DESC LIMIT 20) ORDER BY id ASC",
                (session_id, session_db_id)
            ) as cursor:
                history_rows = await cursor.fetchall()
            
            async with db.execute(
                "SELECT fact FROM long_term_memory WHERE session_id = ? ORDER BY id ASC",
                (session_id,)
            ) as cursor:
                ltm_rows = await cursor.fetchall()
                ltm_facts = [r[0] for r in ltm_rows]
    except Exception as db_err:
        logging.error(f"Database error retrieving history/LTM: {db_err}")

    chat_constraint = (
        "\n[SYSTEM INSTRUCTION: You are in CHAT/PLANNING mode. Do NOT write files, modify the codebase, or run executable commands using your tools unless the user explicitly commands you to do so (e.g. using 'write', 'implement', or 'run'). For questions, analysis, planning, and strategy discussions, ONLY reply in text and ask for the user's feedback or approval before making any changes. Keep all modifications strictly blocked until approved.]"
    )
    prompt_lines = [_CEO_PERSONA, chat_constraint]
    if ltm_facts:
        prompt_lines.append("\nHere are some persistent facts about this user and conversation:")
        for fact in ltm_facts:
            prompt_lines.append(f"- {fact}")
    prompt_lines.append("")

    for role, msg_text in history_rows:
        prefix = "User" if role == "user" else "CEO"
        prompt_lines.append(f"{prefix}: {msg_text}")
    prompt = "\n".join(prompt_lines)

    cli = _resolve_cli()
    env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}

    args = [cli, "--dangerously-skip-permissions", "--print-timeout", "10m", "-p", prompt]
    if active_conversation_id and active_conversation_id != "default":
        args.extend(["--conversation", active_conversation_id])

    try:
        returncode, out, err = await _run_with_timeout(
            args, timeout=600, cwd=repo_path, env=env
        )
    except asyncio.TimeoutError:
        return "⏱ Превышено время ожидания (10 мин)."

    # 1. Attempt to fetch clean response from the official SQLite conversation database
    sqlite_response = None
    if active_conversation_id and active_conversation_id != "default":
        conv_db_path = Path("/root/.gemini/antigravity-cli/conversations") / f"{active_conversation_id}.db"
        if not conv_db_path.exists() and sys.platform == "win32":
            user_profile = os.environ.get('USERPROFILE') or os.path.expanduser('~')
            conv_db_path = Path(user_profile) / ".gemini" / "antigravity-cli" / "conversations" / f"{active_conversation_id}.db"
        
        if conv_db_path.exists():
            try:
                import sqlite3
                # Read the last step of type 14 (model text response)
                conn = sqlite3.connect(str(conv_db_path))
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx DESC LIMIT 1"
                )
                row = cursor.fetchone()
                conn.close()
                if row:
                    sqlite_response = extract_assistant_text_from_protobuf(row[0])
            except Exception as db_err:
                logging.error(f"Failed to read clean response from SQLite DB: {db_err}")

    if sqlite_response:
        response = sqlite_response
    else:
        # Fall back to stdout parsing if the database method failed or conversation is not set
        raw = out.decode("utf-8", errors="replace")
        _SKIP = (
            "warning:",
            "ripgrep",
            "mcp",
            "using a terminal",
            "fallback",
            "falling back",
            "256-color",
        )
        lines = raw.splitlines()
        content_lines = []
        found = False
        for line in lines:
            clean_line = _ANSI_ESCAPE_RE.sub("", line).strip().lower()
            if not found:
                if not clean_line or any(p in clean_line for p in _SKIP):
                    continue
                found = True
            if _TOOL_CALL_RE.match(_ANSI_ESCAPE_RE.sub("", line)):
                continue
            content_lines.append(line)
        response = "\n".join(content_lines).strip()

    if not response:
        stderr_text = err.decode("utf-8", errors="replace").strip()
        logging.error(f"CEO stderr: {stderr_text[:500]}")
        if "RESOURCE_EXHAUSTED" in stderr_text or "Individual quota" in stderr_text:
            resets_match = re.search(r"Resets in ([\d]+h[\d]+m[\d]+s|[\d]+h[\d]+m|[\d]+m[\d]+s)", stderr_text)
            resets_in = resets_match.group(1) if resets_match else "~24h (midnight PT)"
            return (
                f"⏳ <b>Квота Gemini исчерпана</b>\n"
                f"Сброс через: <code>{resets_in}</code>\n\n"
                f"Используй /quota для статуса."
            )
        return f"❌ Нет ответа от модели."

    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT INTO chat_history (session_id, role, text, session_db_id) VALUES (?, ?, ?, ?)",
                (session_id, "model", response, session_db_id)
            )
            # Automatic rotation of chat history: keep only latest 200 records per session
            await db.execute("""
                DELETE FROM chat_history 
                WHERE session_id = ? AND session_db_id IS ? AND id NOT IN (
                    SELECT id FROM chat_history 
                    WHERE session_id = ? AND session_db_id IS ? 
                    ORDER BY id DESC LIMIT ?
                )
            """, (session_id, session_db_id, session_id, session_db_id, _MAX_DB_HISTORY_LIMIT))
            await db.commit()
    except Exception as db_err:
        logging.error(f"Database error saving CEO response: {db_err}")

    return response


@dp.message(Command("start"))
async def cmd_start(message: Message):
    if not is_allowed(message):
        return
    await message.answer(
        "👋 Привет! Я CEO Antigravity Swarm.\n\n"
        "Пиши мне на русском — отвечаю напрямую, без задержек.\n\n"
        "Чтобы запустить рой над репозиторием, скинь GitHub-ссылку и задачу:\n"
        "<i>«Вот репо https://github.com/user/repo — исправь баги»</i>\n\n"
        "Команды:\n"
        "🔹 <code>/deploy</code> — Обновить код роя с GitHub\n"
        "🔹 <code>/status</code> — Статус задач\n"
        "🔹 <code>/log &lt;job_id&gt;</code> — Просмотр логов по ID задачи\n"
        "🔹 <code>/cleanup</code> — Удалить временные рабочие папки\n"
        "🔹 <code>/repos</code> — Список репозиториев роя\n"
        "🔹 <code>/repo &lt;path/url&gt;</code> — Сменить активный репозиторий\n"
        "🔹 <code>/sessions</code> — Список задач текущего репозитория\n"
        "🔹 <code>/new &lt;task-name&gt;</code> — Создать изолированную задачу\n"
        "🔹 <code>/switch &lt;task-name&gt;</code> — Переключиться на задачу\n"
        "🔹 <code>/where</code> — Текущий контекст репозитория и задачи\n"
        "🔹 <code>/task &lt;git-url&gt; &lt;описание&gt;</code> — Запустить рой с изоляцией\n"
        "🔹 <code>/remember [факт]</code> — Запомнить факт (или извлечь из последнего сообщения)",
        parse_mode="HTML",
    )


@dp.message(Command("remember"))
async def cmd_remember(message: Message):
    if not is_allowed(message):
        return
    args = message.text.split(maxsplit=1)
    session_id = str(message.chat.id)
    if len(args) < 2:
        try:
            session_db_id, _, _, _ = await get_active_session()
            async with connect_db() as db:
                async with db.execute(
                    "SELECT role, text FROM chat_history WHERE session_id = ? AND session_db_id IS ? ORDER BY id DESC LIMIT 2",
                    (session_id, session_db_id)
                ) as cursor:
                    rows = await cursor.fetchall()
            if len(rows) < 2:
                await message.answer("ℹ️ Нет достаточного количества сообщений для извлечения фактов.")
                return
            user_msg = ""
            model_msg = ""
            for r, t in rows:
                if r == "user":
                    user_msg = t
                elif r == "model":
                    model_msg = t
            if not user_msg or not model_msg:
                await message.answer("ℹ️ Не удалось найти пару сообщение-ответ в истории.")
                return
            await message.answer("🧠 Анализирую последний диалог для извлечения фактов...")
            await extract_and_save_ltm(session_id, user_msg, model_msg)
            await message.answer("✅ Новые факты успешно извлечены и сохранены!")
        except Exception as e:
            await message.answer(f"❌ Ошибка извлечения: {e}")
        return
        
    fact = args[1].strip()
    escaped_fact = html.escape(fact)
    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT OR IGNORE INTO long_term_memory (session_id, fact) VALUES (?, ?)",
                (session_id, fact)
            )
            await db.commit()
        await message.answer(f"🧠 Запомнил факт: <code>{escaped_fact}</code>", parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка сохранения факта: {e}")


@dp.message(Command("repos"))
async def cmd_repos(message: Message):
    if not is_allowed(message):
        return
    try:
        async with connect_db() as db:
            async with db.execute("SELECT DISTINCT repo_path FROM sessions ORDER BY repo_path") as cursor:
                rows = await cursor.fetchall()
        if not rows:
            await message.answer("📂 Нет сохранённых репозиториев.")
            return
        lines = ["📂 <b>Репозитории роя:</b>"]
        for r in rows:
            lines.append(f"• <code>{r[0]}</code>")
        await message.answer("\n".join(lines), parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка получения репозиториев: {e}")


@dp.message(Command("repo"))
async def cmd_repo(message: Message):
    if not is_allowed(message):
        return
    args = message.text.split(maxsplit=1)
    if len(args) < 2:
        await message.answer("ℹ️ Использование: <code>/repo путь_или_ссылка</code>\nПример: <code>/repo https://github.com/user/repo</code>", parse_mode="HTML")
        return
    target = args[1].strip()
    
    # Handle GitHub URL
    git_match = _GITHUB_REPO_RE.match(target)
    if git_match:
        repo_name = target.rstrip("/").split("/")[-1].removesuffix(".git")
        repo_path = str(Path("/root/workspaces") / repo_name)
    elif _GIT_URL_RE.match(target):
        repo_name = target.rstrip("/").split("/")[-1].removesuffix(".git")
        repo_path = str(Path("/root/workspaces") / repo_name)
    else:
        repo_path = str(Path(target).resolve())
        # Validate that local path exists and is a directory to prevent invalid states
        resolved_path = Path(repo_path)
        if not resolved_path.exists() or not resolved_path.is_dir():
            await message.answer(f"❌ Путь не существует или не является директорией: <code>{repo_path}</code>", parse_mode="HTML")
            return
        
    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_repo_path', ?)",
                (repo_path,)
            )
            # Remove active_session_id to fall back to default for the new repo
            await db.execute(
                "DELETE FROM app_settings WHERE key = 'active_session_id'"
            )
            await db.commit()
        await message.answer(f"📁 Активный репозиторий изменен на:\n<code>{repo_path}</code>", parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка смены репозитория: {e}")


@dp.message(Command("sessions"))
async def cmd_sessions(message: Message):
    if not is_allowed(message):
        return
    try:
        # Get active repo
        session_db_id, active_repo_path, task_name, active_conversation_id = await get_active_session()
        
        async with connect_db() as db:
            async with db.execute(
                "SELECT id, task_name, conversation_id FROM sessions WHERE repo_path = ? ORDER BY id DESC",
                (active_repo_path,)
            ) as cursor:
                rows = await cursor.fetchall()
                
        if not rows:
            await message.answer(f"📝 В репозитории <code>{active_repo_path}</code> нет созданных задач.", parse_mode="HTML")
            return
            
        lines = [f"📝 <b>Задачи в {active_repo_path}:</b>"]
        for r_id, t_name, c_id in rows:
            is_active = " 🟢 [Активная]" if r_id == session_db_id else ""
            lines.append(f"• ID: <code>{r_id}</code> | <code>{t_name}</code> (conv: <code>{c_id[:8]}...</code>){is_active}")
        await message.answer("\n".join(lines), parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка получения списка сессий: {e}")


@dp.message(Command("new"))
async def cmd_new(message: Message):
    if not is_allowed(message):
        return
    args = message.text.split(maxsplit=1)
    if len(args) < 2:
        await message.answer("ℹ️ Использование: <code>/new имя_задачи</code>\nПример: <code>/new расписание</code>", parse_mode="HTML")
        return
    task_name = args[1].strip()
    escaped_task_name = html.escape(task_name)
    
    try:
        session_db_id, active_repo_path, _, _ = await get_active_session()
        
        async with connect_db() as db:
            async with db.execute(
                "SELECT id FROM sessions WHERE repo_path = ? AND task_name = ?",
                (active_repo_path, task_name)
            ) as cursor:
                row = await cursor.fetchone()
            if row:
                await message.answer(f"⚠️ Задача <code>{escaped_task_name}</code> уже существует. Используйте <code>/switch {escaped_task_name}</code>", parse_mode="HTML")
                return
                
            conv_id = str(uuid.uuid4())
            cursor = await db.execute(
                "INSERT INTO sessions (repo_path, task_name, conversation_id) VALUES (?, ?, ?)",
                (active_repo_path, task_name, conv_id)
            )
            new_id = cursor.lastrowid
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
                (str(new_id),)
            )
            await db.commit()
            
        await message.answer(
            f"✨ Создана и активирована задача: <code>{escaped_task_name}</code>\n"
            f"🆔 ID задачи (в базе): <code>{new_id}</code>\n"
            f"🔑 Conversation ID: <code>{conv_id}</code>",
            parse_mode="HTML"
        )
    except Exception as e:
        await message.answer(f"❌ Ошибка создания задачи: {e}")


@dp.message(Command("switch"))
async def cmd_switch(message: Message):
    if not is_allowed(message):
        return
    args = message.text.split(maxsplit=1)
    if len(args) < 2:
        await message.answer("ℹ️ Использование: <code>/switch имя_задачи</code>\nПример: <code>/switch расписание</code>", parse_mode="HTML")
        return
    task_name = args[1].strip()
    escaped_task_name = html.escape(task_name)
    
    try:
        _, active_repo_path, _, _ = await get_active_session()
        
        async with connect_db() as db:
            async with db.execute(
                "SELECT id FROM sessions WHERE repo_path = ? AND task_name = ?",
                (active_repo_path, task_name)
            ) as cursor:
                row = await cursor.fetchone()
            if not row:
                await message.answer(
                    f"❌ Задача <code>{escaped_task_name}</code> не найдена для текущего репозитория.\n"
                    f"Создайте её с помощью <code>/new {escaped_task_name}</code>",
                    parse_mode="HTML"
                )
                return
                
            new_id = row[0]
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
                (str(new_id),)
            )
            await db.commit()
            
        await message.answer(f"🔄 Переключено на задачу <code>{escaped_task_name}</code> (ID: <code>{new_id}</code>).", parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка переключения задачи: {e}")


@dp.message(Command("where"))
async def cmd_where(message: Message):
    if not is_allowed(message):
        return
    try:
        session_db_id, active_repo_path, task_name, active_conversation_id = await get_active_session()
        
        lines = [
            "📍 <b>Текущий контекст:</b>",
            f"• Репозиторий: <code>{active_repo_path}</code>",
            f"• ID задачи (в базе): <code>{session_db_id or 'нет'}</code>",
            f"• Задача: <code>{task_name}</code>",
            f"• Conversation ID: <code>{active_conversation_id}</code>"
        ]
        await message.answer("\n".join(lines), parse_mode="HTML")
    except Exception as e:
        await message.answer(f"❌ Ошибка получения контекста: {e}")


@dp.message(Command("quota"))
async def cmd_quota(message: Message):
    if not is_allowed(message):
        return
    log_dir = Path("/root/.gemini/antigravity-cli/log")
    try:
        logs = sorted(log_dir.glob("cli-*.log"), reverse=True)
        if not logs:
            await message.answer("📊 Лог файлов agy не найдено.")
            return

        content = logs[0].read_text(errors="replace")
        matches = list(re.finditer(
            r"RESOURCE_EXHAUSTED.*?Resets in ([\dhms ]+)",
            content
        ))

        if matches:
            resets_in = matches[-1].group(1).strip()
            await message.answer(
                f"⏳ <b>Квота Gemini исчерпана</b>\n"
                f"Сброс через: <code>{resets_in}</code>\n"
                f"Лог: <code>{logs[0].name}</code>",
                parse_mode="HTML"
            )
        else:
            await message.answer(
                f"✅ <b>Квота в норме</b>\n"
                f"429 ошибок в логах не найдено.\n"
                f"Лог: <code>{logs[0].name}</code>",
                parse_mode="HTML"
            )
    except Exception as e:
        await message.answer(f"❌ Ошибка чтения логов: {e}")


@dp.message(Command("task"))
async def cmd_task(message: Message):
    if not is_allowed(message):
        return
    args = message.text.split(maxsplit=2)
    if len(args) < 3:
        await message.answer("ℹ️ Использование: <code>/task ссылка_на_гит описание_задачи</code>\nПример: <code>/task https://github.com/user/repo исправить баги</code>", parse_mode="HTML")
        return
    git_url = args[1].strip()
    task_desc = args[2].strip()
    await run_task_logic(message, git_url, task_desc)


@dp.message(Command("status"))
async def cmd_status(message: Message):
    if not is_allowed(message):
        return

    async with _registry_lock:
        running = list(_task_registry.values())

    if running:
        lines = [f"🟢 Активных задач: {len(running)}"]
        for job in running:
            lines.append(f"• job <code>{job['job_id']}</code> PID <code>{job['pid']}</code> — {job['repo_url']}")
        await message.answer("\n".join(lines), parse_mode="HTML")
    else:
        status_path = SWARM_ROOT / "SWARM_STATUS.md"
        if status_path.exists():
            try:
                content = read_cached_file(status_path)
                if len(content) > 3800:
                    content = content[:3800] + "\n\n...(обрезано)"
                await message.answer(f"📋 <b>Статус роя:</b>\n\n<pre>{content}</pre>", parse_mode="HTML")
            except Exception as e:
                await message.answer(f"❌ Ошибка: {e}")
        else:
            await message.answer("ℹ️ Нет активных задач.")


@dp.message(Command("log"))
async def cmd_log(message: Message):
    if not is_allowed(message):
        return

    args = message.text.split()
    if len(args) < 2:
        async with _registry_lock:
            running = list(_task_registry.values())
        if running:
            job_id = running[-1]["job_id"]
        else:
            await message.answer("ℹ️ Использование: <code>/log <job_id></code> или <code>/log <repo_name></code>", parse_mode="HTML")
            return
    else:
        job_id = args[1].strip()

    workspace_path = None
    async with _registry_lock:
        if job_id in _task_registry:
            workspace_path = _task_registry[job_id]["workspace_path"]

    if not workspace_path:
        workspaces_dir = Path("/root/workspaces")
        if workspaces_dir.exists():
            for d in workspaces_dir.iterdir():
                if d.is_dir() and d.name.lower() == job_id.lower():
                    workspace_path = str(d)
                    break

    if not workspace_path:
        await message.answer(f"❌ Задача или репозиторий <code>{job_id}</code> не найдены.", parse_mode="HTML")
        return

    log_file = Path(workspace_path) / "swarm.log"
    telemetry_log = Path(workspace_path) / ".agents" / "telemetry.log"
    
    response = []
    
    if log_file.exists():
        try:
            content = log_file.read_text(encoding="utf-8", errors="replace")
            lines = content.splitlines()
            tail = "\n".join(lines[-35:])
            if len(tail) > 3000:
                tail = "..." + tail[-3000:]
            response.append(f"📋 <b>Последние строки swarm.log:</b>\n<pre>{tail}</pre>")
        except Exception as e:
            response.append(f"❌ Ошибка чтения swarm.log: {e}")
    else:
        response.append("ℹ️ Файл <code>swarm.log</code> не найден.")

    if telemetry_log.exists():
        try:
            content = telemetry_log.read_text(encoding="utf-8", errors="replace")
            lines = [line for line in content.splitlines() if line.strip()]
            tail = "\n".join(lines[-15:])
            if len(tail) > 1000:
                tail = "..." + tail[-1000:]
            response.append(f"📊 <b>Последние строки telemetry.log:</b>\n<pre>{tail}</pre>")
        except Exception as e:
            response.append(f"❌ Ошибка чтения telemetry.log: {e}")

    await message.answer("\n\n".join(response), parse_mode="HTML")


async def _run_git(args: list[str], cwd: str) -> tuple[int, str, str]:
    proc = await asyncio.create_subprocess_exec(
        *args, cwd=cwd,
        stdin=asyncio.subprocess.DEVNULL,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    out, err = await proc.communicate()
    return proc.returncode, out.decode("utf-8", errors="replace"), err.decode("utf-8", errors="replace")


async def _delivery_pipeline(chat_id: int, workspace_path: str):
    await bot.send_message(chat_id, "🚀 Рой завершил задачу. Запускаю доставку кода...")

    rc, _, err = await _run_git(["git", "add", "."], workspace_path)
    if rc != 0:
        await bot.send_message(chat_id, f"⚠️ git add failed:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
        return

    rc, _, err = await _run_git(
        ["git", "commit", "-m", "feat: Auto-implemented by Antigravity Swarm"],
        workspace_path,
    )
    if rc != 0:
        await bot.send_message(chat_id, f"ℹ️ Нечего коммитить или ошибка:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
        return

    rc, branch_name, _ = await _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], workspace_path)
    branch_name = branch_name.strip()

    rc, _, err = await _run_git(["git", "push", "origin", branch_name], workspace_path)
    if rc != 0:
        await bot.send_message(chat_id, f"⚠️ git push failed:\n<pre>{err[:300]}</pre>", parse_mode="HTML")
        return

    rc, pr_url, err = await _run_git(
        ["gh", "pr", "create",
         "--title", "🤖 Auto: Antigravity Swarm",
         "--body", "Изменения внесены роем и проверены Quality-Validator."],
        workspace_path,
    )
    if rc == 0:
        await bot.send_message(chat_id, f"🎉 Pull Request создан!\n{pr_url.strip()}")
    else:
        await bot.send_message(chat_id, f"⚠️ PR не создан:\n<pre>{err[:300]}</pre>", parse_mode="HTML")


async def _monitor_process(job_id: str, process: asyncio.subprocess.Process, chat_id: int, workspace_path: str):
    mc_path = Path(workspace_path) / "MISSION_CONTROL.md"
    last_mc_content = ""
    logging.info(f"[{job_id}] Monitoring PID {process.pid}")

    while process.returncode is None:
        await asyncio.sleep(30)
        if mc_path.exists():
            try:
                current_mc = mc_path.read_text(encoding="utf-8")
                if current_mc != last_mc_content:
                    last_mc_content = current_mc
                    progress_lines = [
                        line for line in current_mc.split("\n")
                        if "[x]" in line or "[/]" in line or "[ ]" in line
                    ]
                    if progress_lines:
                        report = "\n".join(progress_lines[-10:])
                        await bot.send_message(
                            chat_id,
                            f"🔄 <b>Прогресс:</b>\n<pre>{report}</pre>",
                            parse_mode="HTML",
                        )
            except Exception as e:
                logging.error(f"MISSION_CONTROL read error: {e}")

    exit_code = await process.wait()
    logging.info(f"[{job_id}] PID {process.pid} exited with code {exit_code}")

    async with _registry_lock:
        _task_registry.pop(job_id, None)

    if exit_code == 0:
        await _delivery_pipeline(chat_id, workspace_path)
    else:
        await bot.send_message(
            chat_id,
            f"⚠️ Задача прервана. Код выхода: <code>{exit_code}</code>.",
            parse_mode="HTML",
        )

    # Clean up workspace folder to avoid VPS cluttering
    try:
        wpath = Path(workspace_path)
        if wpath.exists():
            shutil.rmtree(wpath)
            logging.info(f"[{job_id}] Workspace cleaned up: {workspace_path}")
    except Exception as cleanup_err:
        logging.error(f"[{job_id}] Failed to clean up workspace {workspace_path}: {cleanup_err}")


async def run_task_logic(message: Message, git_url: str, task_desc: str):
    if not _GIT_URL_RE.match(git_url):
        await message.answer("❌ Некорректный URL. Поддерживаются только https:// ссылки.")
        return

    repo_name = git_url.rstrip("/").split("/")[-1].removesuffix(".git")
    workspaces_dir = Path("/root/workspaces")
    workspaces_dir.mkdir(exist_ok=True)
    workspace_path = (workspaces_dir / repo_name).resolve()

    if not str(workspace_path).startswith(str(workspaces_dir.resolve())):
        await message.answer("❌ Недопустимое имя репозитория.")
        return

    await message.answer(f"⏳ Клонирую <code>{git_url}</code>...", parse_mode="HTML")

    if workspace_path.exists():
        shutil.rmtree(workspace_path)

    rc, _, clone_err = await _run_git(["git", "clone", "--depth", "1", git_url, str(workspace_path)], "/root")
    if rc != 0:
        await message.answer(f"❌ Ошибка клонирования:\n<pre>{clone_err[:400]}</pre>", parse_mode="HTML")
        return

    job_id = str(uuid.uuid4())[:8]
    task_name = f"task-{job_id}"
    conv_id = str(uuid.uuid4())
    
    # Register session and workspace path in settings
    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_repo_path', ?)",
                (str(workspace_path),)
            )
            cursor = await db.execute(
                "INSERT INTO sessions (repo_path, task_name, conversation_id) VALUES (?, ?, ?)",
                (str(workspace_path), task_name, conv_id)
            )
            session_db_id = cursor.lastrowid
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('active_session_id', ?)",
                (str(session_db_id),)
            )
            await db.commit()
    except Exception as db_err:
        logging.error(f"Failed to register task session: {db_err}")

    branch_name = f"swarm/task-{job_id}"
    rc, _, branch_err = await _run_git(["git", "checkout", "-b", branch_name], str(workspace_path))
    if rc != 0:
        await message.answer(f"⚠️ Ветка не создана:\n<pre>{branch_err[:200]}</pre>", parse_mode="HTML")

    await message.answer(f"🚀 Запускаю рой в ветке <code>{branch_name}</code>...", parse_mode="HTML")

    env = {**os.environ, "HOME": "/root", "GEMINI_CLI_TRUST_WORKSPACE": "true"}
    
    log_path = workspace_path / "swarm.log"
    log_file = None
    try:
        log_file = open(log_path, "w", encoding="utf-8")
        stdout_dest = log_file
        stderr_dest = log_file
    except Exception as e:
        logging.error(f"Failed to open log file {log_path}: {e}")
        stdout_dest = asyncio.subprocess.DEVNULL
        stderr_dest = asyncio.subprocess.DEVNULL

    swarm_prompt = (
        f"Ты — CEO Antigravity Swarm. Запущен над репозиторием: {git_url}\n"
        f"Задача: {task_desc}\n\n"
        f"Следуй SDD pipeline: oracle → prometheus → junior → quality-validator. "
        f"Все изменения делай в текущей директории. "
        f"По завершении напиши краткий отчёт о сделанных изменениях."
    )
    kwargs = {}
    if sys.platform != "win32":
        kwargs["preexec_fn"] = os.setpgrp

    process = await asyncio.create_subprocess_exec(
        _resolve_cli(), "--print-timeout", "15m", "-p", swarm_prompt, "--conversation", conv_id,
        cwd=str(workspace_path),
        env=env,
        stdin=asyncio.subprocess.DEVNULL,
        stdout=stdout_dest,
        stderr=stderr_dest,
        **kwargs
    )
    if log_file:
        log_file.close()

    monitor = asyncio.create_task(
        _monitor_process(job_id, process, message.chat.id, str(workspace_path))
    )

    async with _registry_lock:
        _task_registry[job_id] = {
            "job_id": job_id,
            "chat_id": message.chat.id,
            "repo_url": git_url,
            "workspace_path": str(workspace_path),
            "pid": process.pid,
            "status": "running",
            "monitor_task": monitor,
        }

    await message.answer(
        f"🤖 Рой запущен! PID: <code>{process.pid}</code> · job: <code>{job_id}</code>\n"
        f"📝 {task_desc}\n\nОбновления каждые 30 сек.",
        parse_mode="HTML",
    )


@dp.message(Command("cleanup"))
async def cmd_cleanup(message: Message):
    if not is_allowed(message):
        return
    async with _registry_lock:
        if _task_registry:
            await message.answer("⚠️ Есть активные задачи. Дождитесь завершения перед очисткой.")
            return
    workspaces_dir = Path("/root/workspaces")
    if not workspaces_dir.exists():
        await message.answer("ℹ️ ~/workspaces пуста.")
        return
    removed = [d.name for d in workspaces_dir.iterdir() if d.is_dir()]
    for d in workspaces_dir.iterdir():
        if d.is_dir():
            shutil.rmtree(d)
    if removed:
        await message.answer(
            "🗑 Удалены репозитории:\n" + "\n".join(f"• <code>{r}</code>" for r in removed),
            parse_mode="HTML",
        )
    else:
        await message.answer("ℹ️ Нечего удалять.")


@dp.message(Command("deploy"))
async def cmd_deploy(message: Message):
    if not is_allowed(message):
        return

    async with _registry_lock:
        if _task_registry:
            await message.answer("⚠️ Есть активные задачи. Дождитесь завершения перед деплоем.")
            return

    await message.answer("🔄 Подтягиваю обновления с GitHub (ветка main)...")

    rc, pull_out, pull_err = await _run_git(["git", "pull", "origin", "main"], str(SWARM_ROOT))
    if rc != 0:
        await message.answer(f"❌ git pull failed:\n<pre>{pull_err[:400]}</pre>", parse_mode="HTML")
        return

    await message.answer(
        f"✅ Код обновлён!\n<pre>{pull_out.strip()[:300]}</pre>\n\n⚡ Перезапускаю сервис...",
        parse_mode="HTML",
    )

    try:
        async with connect_db() as db:
            await db.execute(
                "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('pending_deploy_chat_id', ?)",
                (str(message.chat.id),)
            )
            await db.commit()
    except Exception as db_err:
        logging.error(f"Failed to save pending deploy chat ID: {db_err}")

    restart = await asyncio.create_subprocess_exec(
        "systemctl", "restart", "tg-bridge.service",
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    await restart.wait()


@dp.message()
async def fallback_handler(message: Message):
    if not is_allowed(message):
        return
    text = (message.text or "").strip()
    if not text:
        return

    session_id = str(message.chat.id)

    git_match = _GITHUB_REPO_RE.search(text)
    if git_match:
        git_url = f"https://github.com/{git_match.group(1)}/{git_match.group(2)}"
        task_desc = _GITHUB_REPO_RE.sub("", text).strip() or "выполни задачу"
        await run_task_logic(message, git_url, task_desc)
        return

    async def _keep_typing(chat_id: int, stop_event: asyncio.Event):
        from aiogram.enums import ChatAction
        while not stop_event.is_set():
            try:
                await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
            except Exception:
                pass
            await asyncio.sleep(4)

    stop_typing = asyncio.Event()
    typing_task = asyncio.create_task(_keep_typing(message.chat.id, stop_typing))
    err_msg = None
    try:
        reply = await ask_ceo(session_id, text)
    except Exception as e:
        logging.error(f"CEO error: {e}")
        reply = None
        err_msg = str(e)
    finally:
        stop_typing.set()
        typing_task.cancel()

    if reply:
        formatted = md_to_tg_html(reply)
        for chunk in [formatted[i:i + 4000] for i in range(0, len(formatted), 4000)]:
            try:
                await message.answer(chunk, parse_mode="HTML")
            except Exception as e:
                logging.warning(f"HTML send failed ({e}); retrying as plain text")
                plain = strip_markdown(reply)
                for pchunk in [plain[i:i + 4000] for i in range(0, len(plain), 4000)]:
                    await message.answer(pchunk)
                break
        # Batch LTM extraction: check if message count is a multiple of 20
        try:
            session_db_id, _, _, _ = await get_active_session()
            async with connect_db() as db:
                async with db.execute(
                    "SELECT COUNT(*) FROM chat_history WHERE session_id = ? AND session_db_id IS ?",
                    (session_id, session_db_id)
                ) as cursor:
                    row = await cursor.fetchone()
                    msg_count = row[0] if row else 0
            if msg_count > 0 and msg_count % 20 == 0:
                logging.info(f"Triggering batch LTM extraction (message count: {msg_count})")
                asyncio.create_task(extract_and_save_ltm(session_id, text, reply))
        except Exception as e:
            logging.error(f"Error checking message count for LTM batch: {e}")
    else:
        await message.answer(f"❌ Ошибка CEO агента: {err_msg or 'пустой ответ'}")


async def main():
    logging.info("Starting Telegram Swarm Bridge & FastAPI...")
    await init_db()
    
    # Set Telegram slash command menu
    try:
        commands = [
            BotCommand(command="deploy", description="Обновить код роя с GitHub"),
            BotCommand(command="status", description="Статус задач"),
            BotCommand(command="log", description="Просмотр логов по ID задачи"),
            BotCommand(command="cleanup", description="Удалить временные рабочие папки"),
            BotCommand(command="repos", description="Список репозиториев роя"),
            BotCommand(command="repo", description="Сменить активный репозиторий"),
            BotCommand(command="sessions", description="Список задач текущего репозитория"),
            BotCommand(command="new", description="Создать изолированную задачу"),
            BotCommand(command="switch", description="Переключиться на задачу"),
            BotCommand(command="where", description="Текущий контекст репозитория и задачи"),
            BotCommand(command="quota", description="Статус квоты Gemini"),
            BotCommand(command="task", description="Запустить рой с изоляцией"),
            BotCommand(command="remember", description="Запомнить факт"),
        ]
        await bot.set_my_commands(commands)
        logging.info("Telegram commands menu set successfully.")
    except Exception as e:
        logging.error(f"Failed to set bot commands: {e}")
    
    # Load initial settings
    global _MAX_HISTORY
    try:
        async with connect_db() as db:
            async with db.execute("SELECT value FROM app_settings WHERE key = 'max_history_limit'") as cursor:
                row = await cursor.fetchone()
                if row:
                    _MAX_HISTORY = int(row[0])
    except Exception as e:
        logging.error(f"Failed to load initial settings: {e}")

    # Check and report pending deploy notification
    try:
        async with connect_db() as db:
            async with db.execute("SELECT value FROM app_settings WHERE key = 'pending_deploy_chat_id'") as cursor:
                row = await cursor.fetchone()
                pending_chat_id = row[0] if row else None
            if pending_chat_id:
                await db.execute("DELETE FROM app_settings WHERE key = 'pending_deploy_chat_id'")
                await db.commit()
        if pending_chat_id:
            logging.info(f"Pending deploy chat ID found: {pending_chat_id}. Sending notification...")
            async def send_confirmation():
                try:
                    await bot.send_message(
                        chat_id=pending_chat_id,
                        text="🚀 <b>Деплой успешно завершен!</b> Бот перезапущен, онлайн и готов к работе.",
                        parse_mode="HTML"
                    )
                except Exception as send_err:
                    logging.error(f"Failed to send deploy confirmation: {send_err}")
            asyncio.create_task(send_confirmation())
    except Exception as e:
        logging.error(f"Error checking/sending deploy confirmation: {e}")

    import uvicorn
    config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="info")
    server = uvicorn.Server(config)
    
    loop = asyncio.get_running_loop()
    stop_event = asyncio.Event()

    def handle_signal():
        logging.info("Received stop signal, initiating graceful shutdown...")
        stop_event.set()

    if sys.platform != "win32":
        import signal
        loop.add_signal_handler(signal.SIGTERM, handle_signal)
        loop.add_signal_handler(signal.SIGINT, handle_signal)

    polling_task = asyncio.create_task(dp.start_polling(bot))
    server_task = asyncio.create_task(server.serve())

    async def wait_for_stop():
        await stop_event.wait()
        logging.info("Stopping polling and web server...")
        await dp.stop_polling()
        server.should_exit = True

    stop_monitor = asyncio.create_task(wait_for_stop())

    try:
        tasks = [polling_task, server_task, stop_monitor]
        await asyncio.gather(*tasks)
    except (KeyboardInterrupt, SystemExit):
        logging.info("KeyboardInterrupt/SystemExit caught, shutting down...")
        stop_event.set()
        await asyncio.sleep(0.5)
    except asyncio.CancelledError:
        logging.info("Tasks cancelled.")
    finally:
        try:
            async with connect_db() as db:
                logging.info("Running PRAGMA optimize...")
                await db.execute("PRAGMA optimize")
                await db.commit()
        except Exception as e:
            logging.error(f"Failed to run PRAGMA optimize: {e}")
        logging.info("Graceful shutdown complete.")


if __name__ == "__main__":
    asyncio.run(main())

Youez - 2016 - github.com/yon3zu
LinuXploit