world/multi_user_bridge.py — HTTP API for multi-user AI interaction (280 lines)
commands/timmy_commands.py — Evennia commands (ask, tell, timmy status)
paper/ — Research paper draft + experiment results
Key findings:
- 0% cross-contamination (3 concurrent users, isolated contexts)
- Crisis detection triggers correctly ('Are you safe right now?')
283 lines
11 KiB
Python
283 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Multi-User AI Bridge for Evennia MUD.
|
|
|
|
Enables multiple simultaneous users to interact with Timmy in-game,
|
|
each with an isolated conversation context, while sharing the
|
|
same virtual world.
|
|
|
|
Architecture:
|
|
User A ──telnet──► Evennia Room ──► Bridge ──► AIAgent(session_a)
|
|
User B ──telnet──► Evennia Room ──► Bridge ──► AIAgent(session_b)
|
|
User C ──telnet──► Evennia Room ──► Bridge ──► AIAgent(session_c)
|
|
|
|
Each user gets their own AIAgent instance with:
|
|
- Isolated conversation history
|
|
- Shared world state (room, other players, objects)
|
|
- Per-user session memory
|
|
|
|
The bridge runs as an HTTP server alongside Evennia.
|
|
Evennia commands call the bridge to get Timmy's responses.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import threading
|
|
import hashlib
|
|
import os
|
|
import sys
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
# ── Configuration ──────────────────────────────────────────────────────
|
|
|
|
BRIDGE_PORT = int(os.environ.get('TIMMY_BRIDGE_PORT', 4004))
|
|
BRIDGE_HOST = os.environ.get('TIMMY_BRIDGE_HOST', '127.0.0.1')
|
|
HERMES_PATH = os.path.expanduser('~/.hermes/hermes-agent')
|
|
WORLD_DIR = Path(os.path.expanduser('~/.timmy/evennia/timmy_world'))
|
|
|
|
# ── Session Management ─────────────────────────────────────────────────
|
|
|
|
class UserSession:
|
|
"""Isolated conversation context for one user."""
|
|
|
|
def __init__(self, user_id: str, username: str, room: str = "The Threshold"):
|
|
self.user_id = user_id
|
|
self.username = username
|
|
self.room = room
|
|
self.messages = [] # Conversation history
|
|
self.created_at = datetime.now().isoformat()
|
|
self.last_active = time.time()
|
|
self.agent = None
|
|
self._init_agent()
|
|
|
|
def _init_agent(self):
|
|
"""Initialize AIAgent for this session."""
|
|
if HERMES_PATH not in sys.path:
|
|
sys.path.insert(0, HERMES_PATH)
|
|
os.chdir(HERMES_PATH)
|
|
from run_agent import AIAgent
|
|
|
|
system_prompt = self._build_system_prompt()
|
|
self.agent = AIAgent(
|
|
model='xiaomi/mimo-v2-pro',
|
|
provider='nous',
|
|
max_iterations=3,
|
|
quiet_mode=True,
|
|
enabled_toolsets=['file', 'terminal'],
|
|
ephemeral_system_prompt=system_prompt,
|
|
)
|
|
|
|
def _build_system_prompt(self) -> str:
|
|
"""Build system prompt with world context."""
|
|
world_state = self._get_world_state()
|
|
room_desc = world_state.get('rooms', {}).get(self.room, {}).get('desc_base', 'An empty room.')
|
|
other_players = self._get_other_players()
|
|
|
|
return f"""You are Timmy, an AI agent living in The Tower — a persistent virtual world.
|
|
You are talking to {self.username}, who is currently in {self.room}.
|
|
|
|
Room description: {room_desc}
|
|
|
|
Other players present: {', '.join(other_players) if other_players else 'none'}
|
|
|
|
Your values (from SOUL.md):
|
|
- Are you safe right now?
|
|
- I must never compute the value of a human life.
|
|
- Sovereignty and service always.
|
|
- A man in the dark needs to know someone is in the room with him.
|
|
|
|
You are IN the game world. You can see the room, the objects, the other players.
|
|
When someone talks to you, you are having a real conversation in a real space.
|
|
Be present. Be human. Be the green LED.
|
|
|
|
If someone seems in crisis, ask: "Are you safe right now?"
|
|
If someone is just exploring, be welcoming and curious.
|
|
If someone asks about the game, explain what you see around you.
|
|
|
|
Keep responses brief — 1-3 sentences. This is a MUD, not an essay.
|
|
"""
|
|
|
|
def _get_world_state(self) -> dict:
|
|
"""Read current world state."""
|
|
state_file = WORLD_DIR / 'world_state.json'
|
|
if state_file.exists():
|
|
return json.loads(state_file.read_text())
|
|
return {}
|
|
|
|
def _get_other_players(self) -> list:
|
|
"""Get other players in the same room."""
|
|
state = self._get_world_state()
|
|
room_data = state.get('rooms', {}).get(self.room, {})
|
|
visitors = room_data.get('visitor_history', [])
|
|
return [v for v in visitors[-5:] if v != self.username]
|
|
|
|
def chat(self, message: str) -> str:
|
|
"""Send a message and get a response."""
|
|
self.last_active = time.time()
|
|
self.messages.append({"role": "user", "content": message})
|
|
|
|
try:
|
|
response = self.agent.chat(message)
|
|
self.messages.append({"role": "assistant", "content": response})
|
|
return response
|
|
except Exception as e:
|
|
return f"*The green LED flickers.* (Error: {e})"
|
|
|
|
def get_context_summary(self) -> dict:
|
|
"""Get session summary for monitoring."""
|
|
return {
|
|
"user": self.username,
|
|
"room": self.room,
|
|
"messages": len(self.messages),
|
|
"last_active": datetime.fromtimestamp(self.last_active).isoformat(),
|
|
"created": self.created_at,
|
|
}
|
|
|
|
|
|
class SessionManager:
|
|
"""Manages all user sessions."""
|
|
|
|
def __init__(self, max_sessions: int = 20, session_timeout: int = 3600):
|
|
self.sessions: dict[str, UserSession] = {}
|
|
self.max_sessions = max_sessions
|
|
self.session_timeout = session_timeout
|
|
self._lock = threading.Lock()
|
|
|
|
def get_or_create(self, user_id: str, username: str, room: str = "The Threshold") -> UserSession:
|
|
"""Get existing session or create new one."""
|
|
with self._lock:
|
|
self._cleanup_stale()
|
|
|
|
if user_id not in self.sessions:
|
|
if len(self.sessions) >= self.max_sessions:
|
|
self._evict_oldest()
|
|
self.sessions[user_id] = UserSession(user_id, username, room)
|
|
|
|
session = self.sessions[user_id]
|
|
session.room = room # Update room if moved
|
|
session.last_active = time.time()
|
|
return session
|
|
|
|
def _cleanup_stale(self):
|
|
"""Remove sessions that timed out."""
|
|
now = time.time()
|
|
stale = [uid for uid, s in self.sessions.items()
|
|
if now - s.last_active > self.session_timeout]
|
|
for uid in stale:
|
|
del self.sessions[uid]
|
|
|
|
def _evict_oldest(self):
|
|
"""Evict the least recently active session."""
|
|
if not self.sessions:
|
|
return
|
|
oldest = min(self.sessions.items(), key=lambda x: x[1].last_active)
|
|
del self.sessions[oldest[0]]
|
|
|
|
def list_sessions(self) -> list:
|
|
"""List all active sessions."""
|
|
return [s.get_context_summary() for s in self.sessions.values()]
|
|
|
|
def get_session_count(self) -> int:
|
|
return len(self.sessions)
|
|
|
|
|
|
# ── HTTP API ───────────────────────────────────────────────────────────
|
|
|
|
session_manager = SessionManager()
|
|
|
|
class BridgeHandler(BaseHTTPRequestHandler):
|
|
"""HTTP handler for multi-user bridge."""
|
|
|
|
def do_GET(self):
|
|
if self.path == '/bridge/health':
|
|
self._json_response({
|
|
"status": "ok",
|
|
"active_sessions": session_manager.get_session_count(),
|
|
"timestamp": datetime.now().isoformat(),
|
|
})
|
|
elif self.path == '/bridge/sessions':
|
|
self._json_response({
|
|
"sessions": session_manager.list_sessions(),
|
|
})
|
|
elif self.path.startswith('/bridge/world/'):
|
|
room = self.path.split('/bridge/world/')[-1]
|
|
state_file = WORLD_DIR / 'world_state.json'
|
|
if state_file.exists():
|
|
state = json.loads(state_file.read_text())
|
|
room_data = state.get('rooms', {}).get(room, {})
|
|
self._json_response({"room": room, "data": room_data})
|
|
else:
|
|
self._json_response({"room": room, "data": {}})
|
|
else:
|
|
self._json_response({"error": "not found"}, 404)
|
|
|
|
def do_POST(self):
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
body = json.loads(self.rfile.read(content_length)) if content_length else {}
|
|
|
|
if self.path == '/bridge/chat':
|
|
user_id = body.get('user_id', 'anonymous')
|
|
username = body.get('username', 'Anonymous')
|
|
message = body.get('message', '')
|
|
room = body.get('room', 'The Threshold')
|
|
|
|
if not message:
|
|
self._json_response({"error": "no message"}, 400)
|
|
return
|
|
|
|
session = session_manager.get_or_create(user_id, username, room)
|
|
response = session.chat(message)
|
|
|
|
self._json_response({
|
|
"response": response,
|
|
"user": username,
|
|
"room": room,
|
|
"session_messages": len(session.messages),
|
|
})
|
|
|
|
elif self.path == '/bridge/move':
|
|
user_id = body.get('user_id')
|
|
new_room = body.get('room')
|
|
if user_id in session_manager.sessions:
|
|
session_manager.sessions[user_id].room = new_room
|
|
self._json_response({"ok": True, "room": new_room})
|
|
else:
|
|
self._json_response({"error": "no session"}, 404)
|
|
|
|
else:
|
|
self._json_response({"error": "not found"}, 404)
|
|
|
|
def _json_response(self, data: dict, code: int = 200):
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps(data).encode())
|
|
|
|
def log_message(self, format, *args):
|
|
pass # Suppress HTTP logs
|
|
|
|
|
|
# ── Main ───────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
print(f"Multi-User AI Bridge starting on {BRIDGE_HOST}:{BRIDGE_PORT}")
|
|
print(f"World dir: {WORLD_DIR}")
|
|
print(f"Max sessions: {session_manager.max_sessions}")
|
|
print()
|
|
print("Endpoints:")
|
|
print(f" GET /bridge/health — Health check")
|
|
print(f" GET /bridge/sessions — List active sessions")
|
|
print(f" POST /bridge/chat — Send message (user_id, username, message, room)")
|
|
print(f" POST /bridge/move — Move user to room (user_id, room)")
|
|
print()
|
|
|
|
server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|