403Webshell
Server IP : 157.230.181.24  /  Your IP : 216.73.217.11
Web Server : Apache/2.4.58 (Ubuntu)
System : Linux conductive 6.8.0-117-generic #117-Ubuntu SMP PREEMPT_DYNAMIC Tue May 5 19:26:24 UTC 2026 x86_64
User :  ( 1000)
PHP Version : 8.3.31
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /opt/aim-bots/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /opt/aim-bots/aim_persona_bot.py
#!/usr/bin/env python3
"""AIM persona bot — voice-profile actors, typing delays, proactive pings."""

import json
import logging
import os
import random
import re
import socket
import struct
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import defaultdict, deque
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo

from persona_actor import PersonaActor, SideChickActor, WifeActor, MentorActor, StyleAdapter, build_system_prompt
from coherence import reply_coherent
from reply_retriever import ReplyRetriever
from buddy_memory import BuddyMemoryStore
from aim_profiles import load_profile_from_voice, persist_aim_profile
from timing_sampler import TimingSampler
from burst_planner import BurstPlanner
from conversation_context import (
    assistant_said_goodbye,
    fresh_conversation_prefix,
    looks_like_goodbye,
    looks_like_greeting,
)

ROAST = "Tic/Toc"
MAX_HISTORY = 16
MAX_REPLY_LEN = 450


def load_env(path: Path) -> None:
    if not path.exists():
        return
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        os.environ.setdefault(key.strip(), value.strip())


def normalize(screen_name: str) -> str:
    return screen_name.lower().replace(" ", "")


ALWAYS_ONLINE_MANIFEST = Path("/opt/aim-bots/always_online_squad.json")


def load_always_online_squad() -> set[str]:
    if not ALWAYS_ONLINE_MANIFEST.exists():
        return set()
    try:
        data = json.loads(ALWAYS_ONLINE_MANIFEST.read_text())
        return {b.lower() for b in data.get("bots", [])}
    except (json.JSONDecodeError, OSError):
        return set()


def strip_aim_html(text: str) -> str:
    text = re.sub(r"(?i)<br\s*/?>", " ", text)
    text = re.sub(r"<[^>]+>", "", text)
    for src, dst in (("&lt;", "<"), ("&gt;", ">"), ("&amp;", "&"), ("&quot;", '"')):
        text = text.replace(src, dst)
    return re.sub(r"\s+", " ", text).strip()


def sanitize_outbound_text(text: str) -> str:
    """TOC send_im wraps the payload in ASCII double quotes; inner \" truncates the IM."""
    if not text:
        return text
    text = text.replace('"', "'")
    text = text.replace("\u201c", "'").replace("\u201d", "'")
    text = text.replace("\u2018", "'").replace("\u2019", "'")
    return text


def roast_password(password: str) -> str:
    result = []
    for i, ch in enumerate(password):
        xored = ord(ch) ^ ord(ROAST[i % len(ROAST)])
        result.append(f"{xored:02x}")
    return "0x" + "".join(result)


def escape_toc(text: str) -> str:
    for ch in r'\\$"(){}[]':
        text = text.replace(ch, "\\" + ch)
    return text


def send_flap(sock: socket.socket, frame_type: int, seq: int, payload) -> int:
    data = payload.encode("ascii") if isinstance(payload, str) else payload
    if frame_type == 2:
        data += b"\x00"
    header = struct.pack("!BBHH", 0x2A, frame_type, seq, len(data))
    sock.sendall(header + data)
    return seq + 1


def recv_flap(sock: socket.socket) -> tuple[int, int, bytes]:
    header = b""
    while len(header) < 6:
        chunk = sock.recv(6 - len(header))
        if not chunk:
            raise ConnectionError("Connection closed")
        header += chunk
    _, frame_type, seq, length = struct.unpack("!BBHH", header)
    payload = b""
    while len(payload) < length:
        chunk = sock.recv(length - len(payload))
        if not chunk:
            raise ConnectionError("Connection closed")
        payload += chunk
    return frame_type, seq, payload


def parse_im(line: str) -> tuple[str, str] | None:
    if line.startswith("IM_IN:"):
        parts = line.split(":", 3)
        if len(parts) >= 4:
            return parts[1], parts[3]
    if line.startswith("IM_IN2:"):
        parts = line.split(":", 5)
        if len(parts) >= 6:
            return parts[1], parts[5]
    return None


def format_aim_html(text: str, persona: dict) -> str:
    text = sanitize_outbound_text(text)
    safe = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    face = persona.get("font_face", "Trebuchet MS")
    color = persona.get("font_color", "#0000ff")
    size = persona.get("font_size", "1")
    bg = persona.get("body_bgcolor", "#000080")
    # Single-quoted attrs — escape_toc escapes double quotes and breaks HTML in transit
    font_tag = f"<FONT COLOR='{color}' FACE='{face}' SIZE={size} LANG='0'>{safe}</FONT>"
    if persona.get("html_wrap") == "font":
        return font_tag
    return f"<HTML><BODY BGCOLOR='{bg}'>{font_tag}</BODY></HTML>"


class MessageSplitter:
    MAX_WORDS = 14

    def _trim_part(self, text: str) -> str:
        words = text.split()
        if len(words) <= self.MAX_WORDS:
            return text
        return " ".join(words[: self.MAX_WORDS])

    def split(self, text: str) -> list[str]:
        if "|" in text:
            parts = [self._trim_part(p.strip()) for p in text.split("|") if p.strip()][:3]
            return parts
        text = re.sub(r"\s+", " ", text.strip())
        if not text:
            return []
        if len(text.split()) <= self.MAX_WORDS:
            return [text]
        parts = re.split(r"(?<=[.!?])\s+", text)
        parts = [self._trim_part(p.strip()) for p in parts if p.strip()]
        if len(parts) <= 1:
            return [self._trim_part(text)]
        if len(parts) >= 2 and random.random() < 0.65:
            return parts[:3]
        return [self._trim_part(text)]


class ConversationPresence:
    """Track keyboard presence and back-and-forth momentum per buddy."""

    def __init__(self, voice: dict) -> None:
        self.voice = voice
        self.profile = voice.get("timing_profile", {}) or {}
        self.heat_threshold = float(self.profile.get("heat_threshold", 0.55))
        self.force_online = bool(
            voice.get("force_always_online")
            or voice.get("always_at_keyboard")
            or self.profile.get("always_at_keyboard")
        )
        self.heat: dict[str, float] = defaultdict(float)
        self.last_user_at: dict[str, float] = {}
        self.last_bot_at: dict[str, float] = {}

    def register_user_message(self, sender: str) -> dict:
        now = time.time()
        prev_user = self.last_user_at.get(sender, 0.0)
        prev_bot = self.last_bot_at.get(sender, 0.0)
        gap_user = now - prev_user if prev_user else 9999.0
        gap_since_bot = now - prev_bot if prev_bot else 9999.0

        heat = self.heat[sender] * 0.65
        if prev_bot and gap_since_bot < 90:
            heat += 0.5
        if prev_user and gap_user < 40:
            heat += 0.35
        heat = min(1.0, heat)
        self.heat[sender] = heat
        self.last_user_at[sender] = now

        if heat >= self.heat_threshold:
            mode = "hot"
        elif gap_since_bot < 120 and gap_user < 60:
            mode = "warm"
        elif gap_since_bot >= 600 or gap_user >= 300:
            mode = "cold"
        else:
            mode = "warm"

        if self.force_online or self.profile.get("always_at_keyboard"):
            at_keyboard = True
            afk = False
        elif heat >= self.heat_threshold:
            # Hot back-and-forth — never pretend to be away mid-convo
            at_keyboard = True
            afk = False
        else:
            always_online = float(self.profile.get("always_online_chance", 0.95))
            at_keyboard = random.random() < always_online
            afk = not at_keyboard

        return {
            "mode": mode,
            "heat": heat,
            "at_keyboard": at_keyboard,
            "afk": afk,
            "gap_since_bot": gap_since_bot,
            "gap_since_user": gap_user,
        }

    def register_bot_sent(self, sender: str) -> None:
        self.last_bot_at[sender] = time.time()


class TypingSimulator:
    def __init__(self, wpm_min: int = 90, wpm_max: int = 110, profile: dict | None = None) -> None:
        self.wpm_min = wpm_min
        self.wpm_max = wpm_max
        self.profile = profile or {}
        self.sampler = TimingSampler(self.profile)

    def _presence(self, presence: dict | None) -> dict:
        return presence or {"mode": "warm", "at_keyboard": True, "afk": False}

    def initial_delay(self, incoming: str, presence: dict | None = None) -> float:
        p = self._presence(presence)
        return self.sampler.reply_delay(
            p.get("mode", "warm"),
            incoming_len=len(incoming),
            afk=bool(p.get("afk")),
        )

    def read_delay(self, incoming: str, presence: dict | None = None) -> float:
        return self.initial_delay(incoming, presence) * 0.35

    def type_delay(self, outgoing: str, presence: dict | None = None, msg_index: int = 0) -> float:
        p = self._presence(presence)
        mode = p.get("mode", "warm")
        if msg_index > 0:
            return self.sampler.burst_gap(mode, msg_index)
        return self.sampler.typing_delay(outgoing, mode, self.wpm_min, self.wpm_max)

    def between_messages(self, presence: dict | None = None) -> float:
        p = self._presence(presence)
        return self.sampler.burst_gap(p.get("mode", "warm"), 1)


class LLMClient:
    def __init__(self, system_prompt: str, log: logging.Logger) -> None:
        self.system_prompt = system_prompt
        self.log = log
        self.openai_key = os.environ.get("OPENAI_API_KEY", "")
        self.groq_key = os.environ.get("GROQ_API_KEY", "")
        self.openrouter_key = os.environ.get("OPENROUTER_API_KEY", "")
        self.model = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
        self.groq_model = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant")
        self.openrouter_model = os.environ.get("OPENROUTER_MODEL", "google/gemma-2-9b-it:free")
        self.disabled: dict[str, float] = {}

    def _disabled(self, provider: str) -> bool:
        return time.time() < self.disabled.get(provider, 0)

    def _disable(self, provider: str, seconds: int = 300) -> None:
        self.disabled[provider] = time.time() + seconds

    def chat(self, history: list[dict], memory_context: str | None = None, temperature: float | None = None) -> str | None:
        system = self.system_prompt
        if memory_context:
            system = f"{system}\n\n{memory_context}"
        messages = [{"role": "system", "content": system}, *history]
        temp = temperature if temperature is not None else 0.68
        reply = self._call_providers(messages, temperature=temp)
        return reply[:MAX_REPLY_LEN] if reply else None

    def _call_providers(
        self,
        messages: list[dict],
        *,
        max_tokens: int = 220,
        temperature: float = 0.92,
        tag: str = "reply",
    ) -> str | None:
        providers = []
        skip_openai = os.environ.get("SKIP_OPENAI", "").lower() in {"1", "true", "yes"}
        prefer_groq = os.environ.get("PREFER_GROQ", "true").lower() in {"1", "true", "yes"}

        if self.groq_key and not self._disabled("groq") and prefer_groq:
            providers.append(("groq", "https://api.groq.com/openai/v1/chat/completions", self.groq_key, self.groq_model))
        if self.openai_key and not self._disabled("openai") and not skip_openai:
            providers.append(("openai", "https://api.openai.com/v1/chat/completions", self.openai_key, self.model))
        if self.groq_key and not self._disabled("groq") and not prefer_groq:
            providers.append(("groq", "https://api.groq.com/openai/v1/chat/completions", self.groq_key, self.groq_model))
        if self.openrouter_key and not self._disabled("openrouter"):
            providers.append(("openrouter", "https://openrouter.ai/api/v1/chat/completions", self.openrouter_key, self.openrouter_model))
        for provider, url, key, model in providers:
            body = {"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
            headers = {
                "Authorization": f"Bearer {key}",
                "Content-Type": "application/json",
                "User-Agent": "Mozilla/5.0 (compatible; AIM-Persona-Bot/1.0)",
            }
            if provider == "openrouter":
                headers["HTTP-Referer"] = "https://ceagon.com"
                headers["X-Title"] = "AIM Persona Bot"
            req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers, method="POST")
            try:
                with urllib.request.urlopen(req, timeout=45) as resp:
                    data = json.loads(resp.read().decode())
                reply = data["choices"][0]["message"]["content"].strip()
                reply = re.sub(r"\s+", " ", reply)
                self.log.info("%s mode: %s", tag, provider)
                return reply if reply else None
            except urllib.error.HTTPError as exc:
                self.log.warning("%s %s unavailable (%s)", tag, provider, exc.code)
                if exc.code in {401, 402, 429}:
                    self._disable(provider, 3600 if provider == "openai" else 300)
                elif exc.code == 403 and provider != "groq":
                    self._disable(provider)
            except Exception as exc:
                self.log.warning("%s %s failed: %s", tag, provider, exc)
        return None

    def extract_memory_facts(
        self,
        friend_name: str,
        user_msg: str,
        bot_reply: str,
        recent_context: str,
    ) -> list[str]:
        prompt = (
            f"Extract durable personal facts about {friend_name} from this AIM exchange.\n"
            f"Store things that would still matter days or weeks later: job, school, relationships, "
            f"plans, problems, people, shows, health, life stuff.\n"
            f"Do NOT store: greetings, jokes, memes, game moment-to-moment, /b/ nonsense, "
            f"one-word reactions, obvious sarcasm with no real info.\n"
            f"Write facts in third person about {friend_name} (e.g. '{friend_name} has work Tuesday 6-2').\n"
            f"Return ONLY a JSON array of strings, max 3 items. Use [] if nothing worth keeping.\n\n"
            f"Recent chat:\n{recent_context}\n\n"
            f"{friend_name} said: {user_msg}\n"
            f"You replied: {bot_reply}\n\n"
            f"JSON array:"
        )
        raw = self._call_providers(
            [{"role": "user", "content": prompt}],
            max_tokens=180,
            temperature=0.15,
            tag="memory",
        )
        if not raw:
            return []
        raw = raw.strip()
        if raw.startswith("```"):
            raw = re.sub(r"^```(?:json)?", "", raw).strip()
            raw = re.sub(r"```$", "", raw).strip()
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError:
            match = re.search(r"\[[\s\S]*\]", raw)
            if not match:
                return []
            try:
                parsed = json.loads(match.group(0))
            except json.JSONDecodeError:
                return []
        if not isinstance(parsed, list):
            return []
        return [str(item).strip() for item in parsed if isinstance(item, str) and len(str(item).strip()) >= 8][:3]


class ProactiveScheduler:
    def __init__(self, bot: "PersonaBot", voice: dict) -> None:
        self.bot = bot
        self.voice = voice
        self.tz = ZoneInfo(voice.get("timezone", "America/New_York"))
        self.target = voice.get("proactive_target", "conan0fthenight")
        self.per_day = int(voice.get("proactive_per_day", 3))
        self.state_path = bot.persona_dir / "proactive_state.json"
        self._stop = threading.Event()
        self.thread = threading.Thread(target=self._run, daemon=True, name="proactive")

    def start(self) -> None:
        if self.per_day <= 0:
            self.bot.log.info("proactive pings disabled (proactive_per_day=%s)", self.per_day)
            return
        self.thread.start()

    def stop(self) -> None:
        self._stop.set()

    def _load_state(self) -> dict:
        if self.state_path.exists():
            return json.loads(self.state_path.read_text())
        return {}

    def _save_state(self, state: dict) -> None:
        self.state_path.write_text(json.dumps(state, indent=2))

    def _today(self) -> str:
        return datetime.now(self.tz).strftime("%Y-%m-%d")

    def _plan_times(self) -> list[datetime]:
        if self.per_day <= 0:
            return []
        now = datetime.now(self.tz)
        times = []
        late_start = int(self.voice.get("late_night_start", 23))
        late_hour = random.randint(late_start, 23)
        late_min = random.randint(5, 55)
        late = now.replace(hour=late_hour, minute=late_min, second=0, microsecond=0)
        if late <= now:
            late += timedelta(days=1)
        times.append(late)
        while len(times) < self.per_day:
            hour = random.randint(11, 22)
            minute = random.randint(0, 59)
            candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
            if candidate <= now:
                candidate += timedelta(days=1)
            if all(abs((candidate - t).total_seconds()) > 3600 for t in times):
                times.append(candidate)
        return sorted(times)

    def _pick_opener(self, late: bool) -> list[str]:
        key = "late_night_openers" if late else "wow_openers"
        pool = self.voice.get(key) or self.voice.get("wow_openers", [["sup", "u on?"]])
        return random.choice(pool)

    def _run(self) -> None:
        while not self._stop.is_set():
            try:
                state = self._load_state()
                today = self._today()
                if state.get("date") != today:
                    planned = self._plan_times()
                    state = {
                        "date": today,
                        "planned": [t.isoformat() for t in planned],
                        "sent": [],
                    }
                    self._save_state(state)
                    self.bot.log.info("planned proactive pings: %s", state["planned"])

                now = datetime.now(self.tz)
                for planned_iso in state.get("planned", []):
                    if planned_iso in state.get("sent", []):
                        continue
                    planned = datetime.fromisoformat(planned_iso)
                    if now >= planned:
                        late = planned.hour >= int(self.voice.get("late_night_start", 23)) or planned.hour <= int(
                            self.voice.get("late_night_end", 1)
                        )
                        messages = self._pick_opener(late)
                        self.bot.log.info("proactive ping to %s: %s", self.target, messages)
                        self.bot.send_burst(self.target, messages, incoming="")
                        sent = state.setdefault("sent", [])
                        sent.append(planned_iso)
                        self._save_state(state)
            except Exception as exc:
                self.bot.log.exception("proactive scheduler error: %s", exc)
            self._stop.wait(45)


class PersonaBot:
    def __init__(self, persona_dir: Path) -> None:
        self.persona_dir = persona_dir
        self.persona = json.loads((persona_dir / "persona.json").read_text())
        voice_path = persona_dir / "voice.json"
        self.voice = json.loads(voice_path.read_text()) if voice_path.exists() else {}

        squad = load_always_online_squad()
        if self.persona["screen_name"].lower() in squad:
            self.voice["force_always_online"] = True
            self.voice["always_at_keyboard"] = True
            tp = self.voice.setdefault("timing_profile", {})
            if isinstance(tp, dict):
                tp["always_at_keyboard"] = True
                tp["always_online_chance"] = 1.0
                tp["afk_chance"] = 0.0

        self.system_prompt = build_system_prompt(self.persona, self.voice)
        self.llm = LLMClient(self.system_prompt, logging.getLogger(self.persona["screen_name"]))
        if self.voice.get("persona_kind") == "wife" or self.persona.get("persona_kind") == "wife":
            self.actor = WifeActor(self.persona, self.voice)
        elif self.voice.get("persona_kind") == "mentor" or self.persona.get("persona_kind") == "mentor":
            self.actor = MentorActor(self.persona, self.voice)
        elif self.voice.get("persona_kind") == "side_chick" or self.persona.get("persona_kind") == "side_chick":
            self.actor = SideChickActor(self.persona, self.voice)
        else:
            self.actor = PersonaActor(self.persona, self.voice)
        self.style = StyleAdapter(self.voice)
        self.splitter = MessageSplitter()
        self.presence = ConversationPresence(self.voice)
        pairs_path = persona_dir / "pairs.json.gz"
        min_ratio = float(self.voice.get("retriever_min_ratio", 0.72))
        self.retriever = ReplyRetriever(pairs_path, min_ratio=min_ratio)
        self.burst_planner = BurstPlanner(self.voice, self.persona)
        self.timing_sampler = TimingSampler(self.voice.get("timing_profile"))
        self.memory_enabled = self.voice.get("memory_enabled", True)
        self.memory = BuddyMemoryStore(
            persona_dir,
            friend_name=self.voice.get("friend_name", "Caleb"),
            max_facts=int(self.voice.get("memory_max_facts", 50)),
        )
        self.typing = TypingSimulator(
            int(self.voice.get("typing_wpm_min", 90)),
            int(self.voice.get("typing_wpm_max", 110)),
            self.voice.get("timing_profile"),
        )

        self.host = os.environ.get("TOC_HOST", "127.0.0.1")
        self.port = int(os.environ.get("TOC_PORT", "9898"))
        self.oscar_host = os.environ.get("OSCAR_HOST", "157.230.181.24")
        self.oscar_port = os.environ.get("OSCAR_PORT", "5190")
        self.username = self.persona["screen_name"]
        self.password = os.environ["OSCAR_PASSWORD"]
        self.display_name = self.persona.get("display_name", self.username)

        self.sock: socket.socket | None = None
        self.seq_out = 0
        self.signed_on = False
        self.send_lock = threading.Lock()
        self.history: dict[str, deque] = defaultdict(lambda: deque(maxlen=MAX_HISTORY))
        self.history_lock: dict[str, threading.Lock] = defaultdict(threading.Lock)
        self.last_message_at: dict[str, float] = {}
        self.burst_generation: dict[str, int] = defaultdict(int)
        self.burst_cancel: dict[str, threading.Event] = defaultdict(threading.Event)
        self.conversation_closed: dict[str, bool] = defaultdict(bool)
        self.session_reset = int(os.environ.get("SESSION_RESET_MINUTES", self.voice.get("session_reset_minutes", 37))) * 60
        self.fresh_topic = int(os.environ.get("FRESH_TOPIC_MINUTES", self.voice.get("fresh_topic_minutes", 5))) * 60
        self.log = logging.getLogger(self.username)
        self.log.info(
            "appearance: %s %s on %s (session reset %dm, fresh topic %dm)",
            self.persona.get("font_color"),
            self.persona.get("font_face"),
            self.persona.get("body_bgcolor"),
            self.session_reset // 60,
            self.fresh_topic // 60,
        )
        self.proactive = ProactiveScheduler(self, self.voice)
        self._proactive_running = False

    def connect(self) -> None:
        self.sock = socket.create_connection((self.host, self.port), timeout=120)
        self.sock.sendall(b"FLAPON\r\n\r\n")
        frame_type, _, _ = recv_flap(self.sock)
        if frame_type != 1:
            raise RuntimeError(f"Expected SIGNON frame, got {frame_type}")

        sn = normalize(self.username).encode("ascii")
        self.seq_out = send_flap(self.sock, 1, 0, struct.pack("!IHH", 1, 1, len(sn)) + sn)
        roasted = roast_password(self.password)
        cmd = (
            f"toc_signon {self.oscar_host} {self.oscar_port} "
            f"{normalize(self.username)} {roasted} english \"TIC:{self.display_name}\""
        )
        self.seq_out = send_flap(self.sock, 2, self.seq_out, cmd)

        deadline = time.time() + 30
        self.signed_on = False
        while time.time() < deadline:
            self.sock.settimeout(2)
            try:
                frame_type, _, payload = recv_flap(self.sock)
            except socket.timeout:
                if self.signed_on:
                    break
                continue
            if frame_type != 2:
                continue
            line = payload.decode("ascii", errors="replace")
            if line.startswith("SIGN_ON:"):
                self.signed_on = True
            elif line.startswith("ERROR:"):
                raise RuntimeError(line)
        if not self.signed_on:
            raise RuntimeError("Sign-on timed out")

        # TOC 1.0: empty buddy list = invisible to everyone else's buddy list
        subs = list(self.voice.get("buddy_subscriptions") or [])
        target = self.voice.get("proactive_target", "conan0fthenight")
        if target and normalize(target) not in {normalize(b) for b in subs}:
            subs.append(target)
        if subs:
            buddy_cmd = "toc_add_buddy " + " ".join(normalize(b) for b in subs)
            self.seq_out = send_flap(self.sock, 2, self.seq_out, buddy_cmd)
            self.log.info("subscribed to buddies: %s", ", ".join(normalize(b) for b in subs))

        self.seq_out = send_flap(self.sock, 2, self.seq_out, "toc_init_done")
        self.sock.settimeout(None)
        self._apply_aim_profile()
        self.log.info("online as %s", self.display_name)

    def _apply_aim_profile(self) -> None:
        profile_html = load_profile_from_voice(self.voice, self.persona)
        if profile_html:
            db_path = Path(os.environ.get("OSCAR_DB", "/var/lib/ras/oscar.sqlite"))
            try:
                persist_aim_profile(self.username, profile_html, db_path=db_path)
                self.log.info("persisted AIM profile to DB (%d chars)", len(profile_html))
            except Exception as exc:
                self.log.warning("could not persist profile to DB: %s", exc)
            escaped = escape_toc(profile_html)
            self.seq_out = send_flap(self.sock, 2, self.seq_out, f'toc_set_info "{escaped}"')
            self.log.info("set live AIM profile (%d chars)", len(profile_html))
            self.sock.settimeout(1.0)
            try:
                frame_type, _, payload = recv_flap(self.sock)
                if frame_type == 2:
                    line = payload.decode("ascii", errors="replace")
                    if line.startswith("ERROR:"):
                        self.log.warning("toc_set_info error: %s", line)
            except (socket.timeout, ConnectionError, OSError):
                pass
            finally:
                self.sock.settimeout(None)
        away = self.voice.get("away_message")
        if away:
            self.seq_out = send_flap(
                self.sock, 2, self.seq_out, f'toc_set_away "{escape_toc(away.strip())}"'
            )
            self.log.info("set away message")

    def _interrupt_reply(self, sender: str) -> int:
        """Cancel any in-flight burst/compose for this buddy; return new generation id."""
        self.burst_generation[sender] += 1
        self.burst_cancel[sender].set()
        self.burst_cancel[sender] = threading.Event()
        return self.burst_generation[sender]

    def _wait_or_abort(self, sender: str, generation: int, seconds: float) -> bool:
        deadline = time.time() + seconds
        while time.time() < deadline:
            if self.burst_generation[sender] != generation:
                return False
            remaining = deadline - time.time()
            if remaining <= 0:
                break
            self.burst_cancel[sender].wait(timeout=min(0.12, remaining))
        return self.burst_generation[sender] == generation

    def _reset_conversation(self, sender: str, reason: str) -> None:
        self.history[sender].clear()
        if sender in self.actor.states:
            del self.actor.states[sender]
        self.conversation_closed[sender] = False
        self.presence.heat[sender] = 0.0
        self.log.info("conversation reset for %s (%s)", sender, reason)

    def _apply_session_timing(self, sender: str, incoming: str) -> tuple[float, bool, str | None]:
        """Returns (gap_seconds, fresh_session, ephemeral_llm_prefix)."""
        now = time.time()
        last = self.last_message_at.get(sender, 0.0)
        gap = now - last if last else 0.0
        clean = (incoming or "").strip()
        history = list(self.history[sender])

        reset = False
        reason = ""
        if last and gap >= self.session_reset:
            reset = True
            reason = f"idle {gap / 60:.0f}m"
        elif last and gap >= self.fresh_topic:
            reset = True
            reason = f"gap {gap / 60:.0f}m"
        elif last and gap >= 90 and looks_like_greeting(clean):
            reset = True
            reason = "greeting after pause"
        elif self.conversation_closed[sender] and gap >= 45:
            reset = True
            reason = "after goodbye"
        elif assistant_said_goodbye(history) and gap >= 45:
            reset = True
            reason = "after sign-off"

        self.last_message_at[sender] = now

        if reset:
            self._reset_conversation(sender, reason)
            prefix = fresh_conversation_prefix(gap) if last else None
            return gap, True, prefix

        if looks_like_goodbye(clean):
            self.conversation_closed[sender] = True

        return gap, False, None

    def _format_recent_context(self, history: list[dict], n: int = 8) -> str:
        friend = self.voice.get("friend_name", "Caleb")
        name = self.persona.get("display_name", self.username)
        lines = []
        for msg in history[-n:]:
            who = friend if msg["role"] == "user" else name
            content = msg["content"].split("\n")[-1]
            if content.startswith("[About") or content.startswith("[Similar"):
                continue
            lines.append(f"{who}: {content}")
        return "\n".join(lines)

    def compose_parts(
        self,
        sender: str,
        message: str,
        generation: int,
        session_prefix: str | None = None,
        fresh_session: bool = False,
        presence: dict | None = None,
    ) -> tuple[list[str], str, str] | None:
        clean = strip_aim_html(message) or message
        mode = (presence or {}).get("mode", "warm")

        with self.history_lock[sender]:
            if self.burst_generation[sender] != generation:
                return None

            history = self.history[sender]
            history.append({"role": "user", "content": clean})

            llm_history = [dict(m) for m in history]
            llm_user = clean
            if fresh_session and session_prefix:
                llm_user = f"{session_prefix}\n{clean}"
            elif fresh_session:
                llm_user = f"{fresh_conversation_prefix(0)}\n{clean}"
            llm_history[-1] = {"role": "user", "content": llm_user}

            memory_context = None
            if self.memory_enabled and not fresh_session:
                memory_context = self.memory.format_for_prompt(sender)

            hesitate = self.timing_sampler.should_hesitate(mode) and self.voice.get("persona_kind") != "wife"
            hist_snapshot = list(history)

        # Retriever + LLM outside lock — retriever scan is slow on large archives
        retriever_hint = None
        llm_input = llm_history
        if self.retriever.index and not fresh_session and mode != "hot":
            retriever_hint = self.retriever.hint_for_llm(clean, history=hist_snapshot)
            if retriever_hint:
                llm_input = llm_history[:-1] + [
                    {"role": "user", "content": f"{llm_user}\n\n[{retriever_hint}]"},
                ]

        retrieved = None
        if self.retriever.index and not fresh_session and mode != "hot":
            retrieved = self.retriever.best_reply(clean, history=hist_snapshot)
            if retrieved and not reply_coherent(clean, retrieved, hist_snapshot):
                self.log.info("reply mode: retriever rejected (incoherent for thread)")
                retrieved = None

        if retrieved:
            self.log.info("reply mode: retriever (archive match)")
            parts = self.splitter.split(retrieved)
        else:
            reply = self.llm.chat(
                llm_input,
                memory_context=memory_context,
                temperature=float(self.voice.get("llm_temperature", 0.68)),
            )
            if reply:
                self.log.info("reply mode: llm")
                parts = self.splitter.split(reply)
            elif self.retriever.index and mode != "hot":
                retrieved = self.retriever.best_reply(clean, history=hist_snapshot, relaxed=True)
                if retrieved and not reply_coherent(clean, retrieved, hist_snapshot):
                    retrieved = None
                if retrieved:
                    self.log.info("reply mode: retriever-relaxed")
                    parts = self.splitter.split(retrieved)
                else:
                    self.log.info("reply mode: actor")
                    parts = self.actor.reply(
                        sender,
                        llm_history,
                        time_hint=session_prefix,
                        fresh_session=fresh_session,
                    )
            else:
                self.log.info("reply mode: actor")
                parts = self.actor.reply(
                    sender,
                    llm_history,
                    time_hint=session_prefix,
                    fresh_session=fresh_session,
                )

        with self.history_lock[sender]:
            if self.burst_generation[sender] != generation:
                if self.history[sender] and self.history[sender][-1]["role"] == "user":
                    self.history[sender].pop()
                return None

            parts = self.burst_planner.plan(parts, clean, mode, hesitate=hesitate)

            if self.burst_generation[sender] != generation:
                if self.history[sender] and self.history[sender][-1]["role"] == "user":
                    self.history[sender].pop()
                return None

            parts = self.style.apply_all(parts)
            reply_text = " | ".join(parts)
            self.history[sender].append({"role": "assistant", "content": reply_text})
            if looks_like_goodbye(reply_text):
                self.conversation_closed[sender] = True
            return parts, clean, reply_text

    def _send_im_now(self, recipient: str, message: str) -> None:
        if not self.sock:
            return
        html = format_aim_html(message, self.persona)
        msg = escape_toc(html)
        with self.send_lock:
            self.seq_out = send_flap(
                self.sock, 2, self.seq_out, f'toc_send_im {normalize(recipient)} "{msg}"'
            )
        self.log.info("-> %s: %s", recipient, message)

    def send_burst(
        self,
        recipient: str,
        parts: list[str],
        incoming: str = "",
        generation: int | None = None,
        presence: dict | None = None,
        skip_initial_delay: bool = False,
    ) -> None:
        gen = generation if generation is not None else self.burst_generation[recipient]
        parts = self.style.apply_all(parts)
        if incoming and not skip_initial_delay:
            delay = self.typing.initial_delay(incoming, presence)
            self.log.info("timing %s: mode=%s initial=%.1fs", recipient, (presence or {}).get("mode", "?"), delay)
            if not self._wait_or_abort(recipient, gen, delay):
                self.log.info("burst aborted during initial delay for %s", recipient)
                return
        for i, part in enumerate(parts):
            if self.burst_generation[recipient] != gen:
                self.log.info("burst interrupted for %s after %d/%d", recipient, i, len(parts))
                return
            if i > 0:
                gap = self.typing.between_messages(presence)
                if not self._wait_or_abort(recipient, gen, gap):
                    self.log.info("burst interrupted for %s after %d/%d", recipient, i, len(parts))
                    return
            typed = self.typing.type_delay(part, presence, msg_index=i)
            if not self._wait_or_abort(recipient, gen, typed):
                self.log.info("burst interrupted for %s after %d/%d", recipient, i, len(parts))
                return
            self._send_im_now(recipient, part)
        self.presence.register_bot_sent(recipient)

    def _respond_async(
        self,
        sender: str,
        message: str,
        generation: int,
        session_prefix: str | None,
        fresh_session: bool,
        presence: dict,
    ) -> None:
        try:
            clean = strip_aim_html(message) or message
            result = self.compose_parts(
                sender,
                message,
                generation,
                session_prefix=session_prefix,
                fresh_session=fresh_session,
                presence=presence,
            )
            if not result:
                self.log.warning("no reply composed for %s (gen=%s)", sender, generation)
                return
            parts, user_clean, bot_text = result
            self.send_burst(
                sender,
                parts,
                incoming=clean,
                generation=generation,
                presence=presence,
                skip_initial_delay=True,
            )
            if self.memory_enabled and parts:
                with self.history_lock[sender]:
                    context = self._format_recent_context(list(self.history[sender]))
                self.memory.learn_async(
                    self.llm,
                    sender,
                    user_clean,
                    bot_text,
                    context,
                )
        except Exception as exc:
            self.log.exception("failed to respond to %s: %s", sender, exc)

    def handle_line(self, line: str) -> None:
        parsed = parse_im(line)
        if not parsed:
            return
        sender, message = parsed
        if normalize(sender) == normalize(self.username):
            return
        clean = strip_aim_html(message) or message
        self.log.info("<- %s: %s", sender, clean)

        sender = normalize(sender)
        generation = self._interrupt_reply(sender)
        presence = self.presence.register_user_message(sender)
        clean = strip_aim_html(message) or message

        def run() -> None:
            try:
                with self.history_lock[sender]:
                    gap, fresh, session_prefix = self._apply_session_timing(sender, clean)
                initial = self.typing.read_delay(clean, presence)
                self.log.info(
                    "timing %s: mode=%s heat=%.2f read=%.1fs afk=%s fresh=%s",
                    sender,
                    presence.get("mode"),
                    presence.get("heat", 0),
                    initial,
                    presence.get("afk"),
                    fresh,
                )
                if not self._wait_or_abort(sender, generation, initial):
                    self.log.info("reply aborted during initial delay for %s", sender)
                    return
                self._respond_async(sender, message, generation, session_prefix, fresh, presence)
            except Exception as exc:
                self.log.exception("failed to respond to %s: %s", sender, exc)

        threading.Thread(
            target=run,
            daemon=True,
            name=f"reply-{sender}-{generation}",
        ).start()

    def run_forever(self) -> None:
        backoff = 5
        while True:
            try:
                self.connect()
                if not self._proactive_running:
                    self.proactive.start()
                    self._proactive_running = True
                backoff = 5
                last_frame = time.time()
                idle_reconnect = float(self.voice.get("idle_reconnect_sec", 2700))
                while True:
                    try:
                        self.sock.settimeout(120)
                        frame_type, _, payload = recv_flap(self.sock)
                        last_frame = time.time()
                    except socket.timeout:
                        if time.time() - last_frame >= idle_reconnect:
                            self.log.warning("no TOC traffic for %.0fs — reconnecting", idle_reconnect)
                            raise ConnectionError("idle timeout")
                        continue
                    self.sock.settimeout(None)
                    if frame_type == 5:
                        continue
                    if frame_type == 2:
                        self.handle_line(payload.decode("ascii", errors="replace"))
            except Exception as exc:
                self.log.exception("connection error: %s", exc)
                self.proactive.stop()
                self._proactive_running = False
                if self.sock:
                    try:
                        self.sock.close()
                    except OSError:
                        pass
                self.sock = None
                self.seq_out = 0
                time.sleep(backoff)
                backoff = min(backoff * 2, 60)
                self.proactive = ProactiveScheduler(self, self.voice)


def main() -> int:
    if len(sys.argv) < 2:
        print("Usage: aim_persona_bot.py <persona_dir>", file=sys.stderr)
        return 1
    persona_dir = Path(sys.argv[1])
    load_env(persona_dir / "bot.env")
    load_env(Path("/opt/aim-bots/shared.env"))
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(name)s %(levelname)s %(message)s",
        handlers=[logging.StreamHandler(sys.stdout)],
    )
    if "OSCAR_PASSWORD" not in os.environ:
        logging.error("Missing OSCAR_PASSWORD")
        return 1
    PersonaBot(persona_dir).run_forever()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Youez - 2016 - github.com/yon3zu
LinuXploit