| 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 : |
#!/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 (("<", "<"), (">", ">"), ("&", "&"), (""", '"')):
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("&", "&").replace("<", "<").replace(">", ">")
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())