diff --git a/LangGraph_Implementation_Idea.md b/LangGraph_Implementation_Idea.md new file mode 100644 index 0000000..3356398 --- /dev/null +++ b/LangGraph_Implementation_Idea.md @@ -0,0 +1,140 @@ +# LangGraph Gateway - Implemented MVP (Paper Fork) + +## Status + +Implemented in this fork as a FastAPI sidecar: + +- Script: `/usr/local/bin/langgraph_gateway.py` +- Service: `mc-langgraph-gateway.service` +- Config: `/etc/mc_langgraph_gateway.json` +- Bind: `127.0.0.1:8091` + +`mc_aigod_paper.py` can route pray/sudo/system flows through this gateway when: + +```json +"use_langgraph_gateway": true +``` + +Safety enforcement remains in `mc_aigod_paper.py` (whitelist, fixups, caps, auth checks). + +--- + +## Implemented API + +### Start session +`POST /v1/session/start` + +Request: +```json +{ + "player": "slingshooter08", + "mode": "god" +} +``` + +Response: +```json +{ + "session_id": "sess_xxxxx" +} +``` + +### Send message +`POST /v1/session/{session_id}/message` + +Request: +```json +{ + "role": "user", + "text": "pray I need wood for shelter", + "context": {"server_state": {}, "player_state": "...", "recent_events": "..."}, + "allow_tools": true, + "max_tool_steps": 4 +} +``` + +Response: +```json +{ + "message": "Divine response text", + "commands": ["give slingshooter08 minecraft:oak_log 64"], + "tool_trace": [{"tool": "minecraft.wiki_lookup", "ok": true, "results_count": 2}] +} +``` + +### Close session +`POST /v1/session/{session_id}/close` + +### Health +`GET /healthz` + +--- + +## Session + Memory Model + +- In-memory sessions keyed by `session_id` +- `mc_aigod_paper.py` keeps player+mode -> session_id mapping +- `/v1/session/start` reuses active session for same player+mode when possible +- Session TTL configurable (`session_ttl_seconds`, default 6h) +- Last message turns are fed back into gateway model calls +- SQLite persistence enabled by default via `session_db_path` (survives gateway restarts) + +Redis backend is not implemented yet (optional future step). + +--- + +## Modes Wired + +- `god` -> prayer flow +- `sudo` -> translator flow +- `god_system` -> interventions and first-login system events + +--- + +## Tool Loop (MVP) + +Bounded tool loop implemented with max step cap: + +- `minecraft.wiki_lookup` +- `web.search` + +Current planner is lightweight heuristic router; this is intentionally bounded and conservative. + +--- + +## Runtime Safeguards (still in mc_aigod_paper.py) + +Even with gateway enabled, commands are still post-processed by runtime safety: + +- command family whitelist +- syntax repair + normalization +- max commands cap +- sudo user authorization +- first-login constraints (e.g. max one player kill) + +So gateway/model/tool errors cannot directly bypass execution constraints. + +Gateway also applies an early command sanitation pass before returning output: + +- strips leading `/` +- drops prose/non-command payloads +- enforces mode-specific command prefixes +- deduplicates and caps command count + +--- + +## Known Current Behavior + +- Session state survives gateway restarts when SQLite persistence is enabled. +- Tool traces are logged in `mc_aigod_paper.log` but not shown in-game. +- Redis persistence backend is not implemented yet. + +--- + +## Next Steps + +1. Add optional Redis persistence backend +2. Add richer tool planner node (LangGraph proper state machine) +3. Add optional tool trace exposure in debug mode +4. Add stricter command schema output in gateway (model-side) +5. Add MCP-based tool adapters diff --git a/README.md b/README.md index de76854..334499f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,35 @@ This fork targets a dedicated **Paper** server on port `25567` and adds advanced - Message model: `gemma3:12b` - Command model: `qwen3-coder:30b` +### LangGraph-style Gateway Sidecar (implemented) + +- Service: `mc-langgraph-gateway.service` +- API: `http://127.0.0.1:8091` +- Script: `/usr/local/bin/langgraph_gateway.py` +- Config: `/etc/mc_langgraph_gateway.json` + +`mc_aigod_paper.py` can route modes through session APIs: + +- `god` (prayer) +- `sudo` (translator) +- `god_system` (intervention / first-login) + +Enable via config: + +```json +"use_langgraph_gateway": true, +"langgraph_gateway_url": "http://127.0.0.1:8091" +``` + +The runtime still enforces whitelist/repair/caps/auth after gateway output. + +Gateway hardening currently included: + +- Session reuse by `player+mode` when still active (less duplicate session churn) +- SQLite-backed session persistence across gateway restarts +- Command sanitization at gateway return time (strips leading `/`, rejects prose/non-command lines) +- Mode-specific command family filtering and command dedupe/cap + --- ## Services @@ -60,6 +89,32 @@ Examples: - `sudo create church` - `sudo build wall` +Template manager commands: + +- `sudo template search ` +- `sudo template download [name]` +- `sudo template install [name]` +- `sudo template pick [name]` (download from last search result index) +- `sudo template sync` (pull from configured sync sources/manifest) +- `sudo template build ` (or no arg = last downloaded template) +- `sudo template list` +- `sudo template delete ` +- `sudo template hosts` + +Notes: + +- Template download now requires direct file URLs only (`.schem/.schematic/.nbt/.zip`) +- Template search is direct-link only (no page scraping) +- Recommended workflow is `template sync` + `template build` + +Info lookup mode via sudo: + +- `sudo lookup ` +- `sudo wiki ` +- `sudo search ` + +Lookup mode is information-only (wiki/web retrieval + optional justification), and does not execute game commands. + These trigger multi-command `fill/setblock/give` sequences near the player and are optimized for Paper performance. --- diff --git a/langgraph_gateway.py b/langgraph_gateway.py new file mode 100644 index 0000000..429c0b0 --- /dev/null +++ b/langgraph_gateway.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 +""" +langgraph_gateway.py + +Session-based LLM gateway sidecar for Minecraft AI. +Provides: +- per-player sessions +- bounded tool loop (web.search, minecraft.wiki_lookup) +- final {message, commands, tool_trace} payload + +This is intentionally lightweight and API-first. +Execution safety remains in mc_aigod_paper.py. +""" + +import json +import logging +import os +import re +import sqlite3 +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import requests +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [gateway] %(levelname)s: %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('/var/log/mc_langgraph_gateway.log'), + ] +) +log = logging.getLogger(__name__) + +CONFIG_PATH = '/etc/mc_langgraph_gateway.json' + + +class StartSessionRequest(BaseModel): + player: str + mode: str = Field(pattern='^(god|sudo|god_system)$') + + +class StartSessionResponse(BaseModel): + session_id: str + + +class MessageRequest(BaseModel): + role: str = Field(default='user') + text: str + context: Dict[str, Any] = Field(default_factory=dict) + allow_tools: bool = True + max_tool_steps: int = 4 + + +class MessageResponse(BaseModel): + message: Optional[str] = None + commands: List[str] = Field(default_factory=list) + tool_trace: List[Dict[str, Any]] = Field(default_factory=list) + + +@dataclass +class SessionState: + session_id: str + player: str + mode: str + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + messages: List[Dict[str, str]] = field(default_factory=list) + + +_sessions: Dict[str, SessionState] = {} +_sessions_lock = threading.Lock() + + +COMMAND_PREFIXES_BY_MODE = { + 'sudo': [ + 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', + 'kill ', 'summon ', 'tellraw ', 'worldborder ', 'fill ', 'setblock ', + 'clone ', + ], + 'god': [ + 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', + 'kill ', 'summon ', 'tellraw ', 'worldborder ', + ], + 'god_system': [ + 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', + 'kill ', 'summon ', 'tellraw ', 'worldborder ', + ], +} + + +def load_config() -> Dict[str, Any]: + try: + with open(CONFIG_PATH) as f: + return json.load(f) + except FileNotFoundError: + log.warning('Config not found, using defaults') + return { + 'ollama_url': 'http://127.0.0.1:11434', + 'message_model': 'gemma3:12b', + 'command_model': 'qwen3-coder:30b', + 'tool_model': 'qwen2.5:1.5b', + 'session_ttl_seconds': 21600, + } + + +CFG = load_config() +DB_PATH = CFG.get('session_db_path', '/var/lib/mc-langgraph-gateway/sessions.db') +_db_lock = threading.Lock() + + +def _db_enabled() -> bool: + return bool(CFG.get('session_persistence_enabled', True)) + + +def _db_conn() -> sqlite3.Connection: + return sqlite3.connect(DB_PATH, timeout=10) + + +def _db_init(): + if not _db_enabled(): + return + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + with _db_lock: + conn = _db_conn() + try: + conn.execute( + 'CREATE TABLE IF NOT EXISTS sessions (' + 'session_id TEXT PRIMARY KEY,' + 'player TEXT NOT NULL,' + 'mode TEXT NOT NULL,' + 'created_at REAL NOT NULL,' + 'updated_at REAL NOT NULL,' + 'messages_json TEXT NOT NULL' + ')' + ) + conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_player_mode ON sessions(player, mode)') + conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)') + conn.commit() + finally: + conn.close() + + +def _session_to_row(s: SessionState): + return ( + s.session_id, + s.player, + s.mode, + s.created_at, + s.updated_at, + json.dumps(s.messages[-60:], ensure_ascii=True), + ) + + +def _row_to_session(row) -> SessionState: + messages: List[Dict[str, str]] + try: + messages = json.loads(row[5]) + if not isinstance(messages, list): + messages = [] + except Exception: + messages = [] + return SessionState( + session_id=row[0], + player=row[1], + mode=row[2], + created_at=float(row[3]), + updated_at=float(row[4]), + messages=messages, + ) + + +def _db_upsert_session(s: SessionState): + if not _db_enabled(): + return + with _db_lock: + conn = _db_conn() + try: + conn.execute( + 'INSERT INTO sessions (session_id, player, mode, created_at, updated_at, messages_json) ' + 'VALUES (?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(session_id) DO UPDATE SET ' + 'player=excluded.player, mode=excluded.mode, created_at=excluded.created_at, ' + 'updated_at=excluded.updated_at, messages_json=excluded.messages_json', + _session_to_row(s), + ) + conn.commit() + finally: + conn.close() + + +def _db_get_by_id(session_id: str) -> Optional[SessionState]: + if not _db_enabled(): + return None + with _db_lock: + conn = _db_conn() + try: + cur = conn.execute( + 'SELECT session_id, player, mode, created_at, updated_at, messages_json ' + 'FROM sessions WHERE session_id = ? LIMIT 1', + (session_id,), + ) + row = cur.fetchone() + finally: + conn.close() + if not row: + return None + return _row_to_session(row) + + +def _db_get_by_player_mode(player: str, mode: str) -> Optional[SessionState]: + if not _db_enabled(): + return None + with _db_lock: + conn = _db_conn() + try: + cur = conn.execute( + 'SELECT session_id, player, mode, created_at, updated_at, messages_json ' + 'FROM sessions WHERE player = ? AND mode = ? ORDER BY updated_at DESC LIMIT 1', + (player, mode), + ) + row = cur.fetchone() + finally: + conn.close() + if not row: + return None + return _row_to_session(row) + + +def _db_delete(session_id: str): + if not _db_enabled(): + return + with _db_lock: + conn = _db_conn() + try: + conn.execute('DELETE FROM sessions WHERE session_id = ?', (session_id,)) + conn.commit() + finally: + conn.close() + + +def _db_cleanup_expired(ttl_seconds: int) -> int: + if not _db_enabled(): + return 0 + cutoff = time.time() - ttl_seconds + with _db_lock: + conn = _db_conn() + try: + cur = conn.execute('DELETE FROM sessions WHERE updated_at < ?', (cutoff,)) + conn.commit() + return int(cur.rowcount or 0) + finally: + conn.close() + + +def _cleanup_sessions(): + ttl = int(CFG.get('session_ttl_seconds', 21600)) + now = time.time() + with _sessions_lock: + dead = [sid for sid, s in _sessions.items() if now - s.updated_at > ttl] + for sid in dead: + _sessions.pop(sid, None) + if dead: + log.info('Cleaned %d expired sessions', len(dead)) + db_dead = _db_cleanup_expired(ttl) + if db_dead: + log.info('Cleaned %d expired DB sessions', db_dead) + + +def _session_get(session_id: str) -> SessionState: + _cleanup_sessions() + with _sessions_lock: + s = _sessions.get(session_id) + if not s: + s = _db_get_by_id(session_id) + if s: + with _sessions_lock: + _sessions[session_id] = s + if not s: + raise HTTPException(status_code=404, detail='session not found') + return s + + +def _session_create(player: str, mode: str) -> SessionState: + _cleanup_sessions() + with _sessions_lock: + for existing in _sessions.values(): + if existing.player == player and existing.mode == mode: + existing.updated_at = time.time() + _db_upsert_session(existing) + return existing + + persisted = _db_get_by_player_mode(player, mode) + if persisted: + persisted.updated_at = time.time() + with _sessions_lock: + _sessions[persisted.session_id] = persisted + _db_upsert_session(persisted) + return persisted + + sid = 'sess_' + uuid.uuid4().hex[:16] + s = SessionState(session_id=sid, player=player, mode=mode) + with _sessions_lock: + _sessions[sid] = s + _db_upsert_session(s) + return s + + +def _ollama_chat(model: str, messages: List[Dict[str, str]], *, fmt: Optional[str] = None, + temperature: float = 0.7, max_tokens: int = 400, timeout: int = 60) -> str: + payload: Dict[str, Any] = { + 'model': model, + 'messages': messages, + 'stream': False, + 'options': { + 'temperature': temperature, + 'num_predict': max_tokens, + } + } + if fmt: + payload['format'] = fmt + + r = requests.post(f"{CFG['ollama_url']}/api/chat", json=payload, timeout=timeout) + r.raise_for_status() + return r.json()['message']['content'] + + +def _parse_json(content: str) -> Dict[str, Any]: + try: + return json.loads(content) + except json.JSONDecodeError: + # salvage commands if partially valid + cmds = [] + m = re_search_commands(content) + if m: + cmds = m + return {'commands': cmds, 'message': ''} + + +def re_search_commands(content: str) -> List[str]: + import re + m = re.search(r'"commands"\s*:\s*\[(.*?)(?:\]|$)', content, re.DOTALL) + if not m: + return [] + return re.findall(r'"([^"]+)"', m.group(1)) + + +def tool_web_search(query: str) -> Dict[str, Any]: + try: + r = requests.get('https://api.duckduckgo.com/', params={ + 'q': query, + 'format': 'json', + 'no_redirect': 1, + 'no_html': 1, + }, timeout=20) + r.raise_for_status() + data = r.json() + out = [] + if data.get('AbstractText'): + out.append({'title': 'Abstract', 'text': data['AbstractText']}) + for item in (data.get('RelatedTopics') or [])[:3]: + if isinstance(item, dict) and item.get('Text'): + out.append({'title': item.get('FirstURL', ''), 'text': item['Text']}) + return {'ok': True, 'results': out} + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def tool_wiki_lookup(query: str) -> Dict[str, Any]: + try: + s = requests.get('https://minecraft.wiki/api.php', params={ + 'action': 'opensearch', + 'format': 'json', + 'search': query, + 'limit': 3, + }, timeout=20) + s.raise_for_status() + data = s.json() + titles = data[1] if len(data) > 1 else [] + if not titles: + return {'ok': True, 'results': []} + + results = [] + for t in titles: + e = requests.get('https://minecraft.wiki/api.php', params={ + 'action': 'query', + 'format': 'json', + 'prop': 'extracts', + 'exintro': 1, + 'explaintext': 1, + 'titles': t, + }, timeout=20) + e.raise_for_status() + pages = e.json().get('query', {}).get('pages', {}) + extract = '' + for p in pages.values(): + extract = (p.get('extract') or '')[:500] + break + results.append({'title': t, 'text': extract}) + return {'ok': True, 'results': results} + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def _tool_router(user_text: str, max_steps: int) -> List[Dict[str, Any]]: + """Very small bounded heuristic tool planner.""" + text = user_text.lower() + calls: List[Dict[str, Any]] = [] + if max_steps <= 0: + return calls + + if any(k in text for k in ['wiki', 'minecraft', 'item id', 'recipe', 'craft']): + calls.append({'tool': 'minecraft.wiki_lookup', 'query': user_text}) + + if len(calls) < max_steps and any(k in text for k in ['what is', 'how to', 'search', 'lookup']): + calls.append({'tool': 'web.search', 'query': user_text}) + + return calls[:max_steps] + + +def _commands_prompt(mode: str) -> str: + allowed = ','.join( + p.strip() for p in COMMAND_PREFIXES_BY_MODE.get(mode, COMMAND_PREFIXES_BY_MODE['god']) + ) + + if mode == 'sudo': + return ( + 'You are a Minecraft command translator. Return ONLY JSON: {"commands": ["..."]}.\n' + f'Allowed command prefixes: {allowed}.\n' + 'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n' + 'If unsafe/unknown, return empty commands.' + ) + + if mode == 'god_system': + return ( + 'You are Minecraft divine system automation. Return ONLY JSON: {"commands": ["..."]}.\n' + f'Allowed command prefixes: {allowed}.\n' + 'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n' + 'This mode is for intervention/first-login events. Prefer benevolent or thematic world actions.\n' + 'If you include kill commands, keep it to at most one player.' + ) + + return ( + 'You are Minecraft God command planner. Return ONLY JSON: {"commands": ["..."]}.\n' + f'Allowed command prefixes: {allowed}.\n' + 'Output must be command strings only, no prose, no markdown, no labels, no leading slash.\n' + 'Balance benevolence and judgment based on context.\n' + 'Use valid Minecraft command syntax only.' + ) + + +def _message_prompt(mode: str) -> str: + if mode == 'sudo': + return 'Return empty string.' + if mode == 'god_system': + return ( + 'You are God speaking to all players about a system event. ' + 'Return plain text only, no JSON.' + ) + return ( + 'You are God in Minecraft. Return a dramatic but clear message as plain text only.' + ) + + +def _sanitize_commands(commands_raw: Any, mode: str) -> List[str]: + allowed_prefixes = COMMAND_PREFIXES_BY_MODE.get(mode, COMMAND_PREFIXES_BY_MODE['god']) + max_commands = int(CFG.get('gateway_max_commands', 8)) + cleaned: List[str] = [] + + if not isinstance(commands_raw, list): + return [] + + for entry in commands_raw: + if not isinstance(entry, str): + continue + + pieces = re.split(r'[\n;]+', entry) + for piece in pieces: + cmd = piece.strip() + if not cmd: + continue + + if cmd.startswith('/'): + cmd = cmd[1:].strip() + + cmd = cmd.rstrip(' .') + if len(cmd) < 3 or len(cmd) > 240: + continue + + if cmd.lower().startswith('commands'): + continue + + if not any(cmd.startswith(p) for p in allowed_prefixes): + continue + + cleaned.append(cmd) + if len(cleaned) >= max_commands: + break + + if len(cleaned) >= max_commands: + break + + deduped: List[str] = [] + seen = set() + for cmd in cleaned: + if cmd in seen: + continue + seen.add(cmd) + deduped.append(cmd) + return deduped + + +def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse: + session.updated_at = time.time() + + # Compose input text + context_json = json.dumps(req.context or {}, ensure_ascii=True) + user_text = req.text.strip() + user_blob = f"message: {user_text}\ncontext: {context_json}" + + session.messages.append({'role': req.role, 'content': user_blob}) + _db_upsert_session(session) + + tool_trace: List[Dict[str, Any]] = [] + tool_results_block = '' + + if req.allow_tools: + calls = _tool_router(user_text, max(0, min(req.max_tool_steps, 6))) + for c in calls: + tool = c['tool'] + q = c['query'] + if tool == 'web.search': + out = tool_web_search(q) + elif tool == 'minecraft.wiki_lookup': + out = tool_wiki_lookup(q) + else: + out = {'ok': False, 'error': 'unknown tool', 'results': []} + tool_trace.append({'tool': tool, 'input': q, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))}) + tool_results_block += f"\nTOOL {tool} query={q}\nRESULT={json.dumps(out, ensure_ascii=True)[:3000]}\n" + + # Commands call + cmd_messages = [ + {'role': 'system', 'content': _commands_prompt(session.mode)}, + *session.messages[-12:], + {'role': 'user', 'content': user_blob + tool_results_block}, + ] + cmd_raw = _ollama_chat( + CFG.get('command_model', 'qwen3-coder:30b'), + cmd_messages, + fmt='json', + temperature=0.2, + max_tokens=220, + ) + cmd_parsed = _parse_json(cmd_raw) + commands = _sanitize_commands(cmd_parsed.get('commands') or [], session.mode) + + # Message call (not for sudo) + message = None + if session.mode != 'sudo': + msg_messages = [ + {'role': 'system', 'content': _message_prompt(session.mode)}, + *session.messages[-12:], + {'role': 'user', 'content': user_blob + f"\nChosen commands: {commands}" + tool_results_block}, + ] + message = _ollama_chat( + CFG.get('message_model', 'gemma3:12b'), + msg_messages, + fmt=None, + temperature=0.8, + max_tokens=500, + ).strip() + + # Save assistant summary back to session memory + session.messages.append({ + 'role': 'assistant', + 'content': json.dumps({'message': message, 'commands': commands}, ensure_ascii=True) + }) + _db_upsert_session(session) + + return MessageResponse(message=message, commands=commands, tool_trace=tool_trace) + + +app = FastAPI(title='Minecraft LangGraph Gateway', version='0.1.0') + + +@app.get('/healthz') +def healthz(): + return {'ok': True, 'sessions': len(_sessions)} + + +@app.post('/v1/session/start', response_model=StartSessionResponse) +def start_session(req: StartSessionRequest): + s = _session_create(req.player, req.mode) + log.info('session start player=%s mode=%s session=%s', req.player, req.mode, s.session_id) + return StartSessionResponse(session_id=s.session_id) + + +@app.post('/v1/session/{session_id}/message', response_model=MessageResponse) +def send_message(session_id: str, req: MessageRequest): + session = _session_get(session_id) + out = run_pipeline(session, req) + return out + + +@app.post('/v1/session/{session_id}/close') +def close_session(session_id: str): + with _sessions_lock: + existed = session_id in _sessions + _sessions.pop(session_id, None) + if not existed and _db_enabled(): + existed = _db_get_by_id(session_id) is not None + _db_delete(session_id) + return {'closed': existed} + + +_db_init() diff --git a/mc-langgraph-gateway.service b/mc-langgraph-gateway.service new file mode 100644 index 0000000..16d8eb8 --- /dev/null +++ b/mc-langgraph-gateway.service @@ -0,0 +1,15 @@ +[Unit] +Description=Minecraft LangGraph Gateway Sidecar +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 -m uvicorn langgraph_gateway:app --host 127.0.0.1 --port 8091 --workers 1 +WorkingDirectory=/usr/local/bin +Restart=always +RestartSec=3 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/mc_aigod_paper.json b/mc_aigod_paper.json index 0adefbb..bb9b770 100644 --- a/mc_aigod_paper.json +++ b/mc_aigod_paper.json @@ -9,6 +9,26 @@ "command_model": "qwen3-coder:30b", "temperature": 0.85, "max_tokens": 700, + "use_langgraph_gateway": true, + "langgraph_gateway_url": "http://127.0.0.1:8091", + "langgraph_gateway_timeout": 60, + "gateway_allow_tools_god": true, + "gateway_allow_tools_sudo": true, + "gateway_allow_tools_system": false, + "gateway_max_tool_steps": 4, + "gateway_shared_mode_sessions": [], + "template_sync_manifest_url": "", + "template_sync_urls": [ + "https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge1.schem", + "https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge2.schem", + "https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/sponge3.schem", + "https://raw.githubusercontent.com/IntellectualSites/FastAsyncWorldEdit/master/worldedit-core/src/test/resources/fastasyncworldedit/schematics/minecraft_structure.nbt" + ], + "template_sync_max_files": 50, + "template_search_blocked_hosts": [ + "planetminecraft.com", + "www.planetminecraft.com" + ], "cooldown_seconds": 10, "max_commands_per_response": 8, "interventions_per_day": 24, @@ -17,6 +37,11 @@ "sudo_enabled": true, "sudo_user": "slingshooter08", "sudo_max_commands": 12, + "tp_border_guard_enabled": true, + "worldborder_center_x": 0, + "worldborder_center_z": 0, + "tp_border_margin": 2, + "template_max_bytes": 41943040, "first_login_benevolence_enabled": true, "first_login_benevolence_max_commands": 12, "first_login_path": "/opt/paper-ai-25567/aigod_first_login_seen.json", diff --git a/mc_aigod_paper.py b/mc_aigod_paper.py index 9fdaad8..75fd4f3 100644 --- a/mc_aigod_paper.py +++ b/mc_aigod_paper.py @@ -9,6 +9,8 @@ Config: /etc/mc_aigod.json import json, os, random, re, socket, struct, threading, time, logging from collections import deque from datetime import datetime +import shutil +from urllib.parse import parse_qs, unquote, urljoin, urlparse import requests logging.basicConfig( @@ -72,6 +74,29 @@ sudo_history: deque = deque() # entries: (ts, player, prompt, translated_ _memory_lock = threading.Lock() +# Gateway client session mapping (player+mode -> session_id) +_gateway_sessions = {} +_gateway_lock = threading.Lock() + +DEFAULT_TEMPLATE_DIR = "/opt/paper-ai-25567/worldkit_templates" +DEFAULT_TEMPLATE_HOSTS = [ + "raw.githubusercontent.com", + "github.com", + "planetminecraft.com", + "www.planetminecraft.com", + "modrinth.com", + "cdn.modrinth.com", +] +DEFAULT_TEMPLATE_SEARCH_BLOCKED_HOSTS = [ + "planetminecraft.com", + "www.planetminecraft.com", +] +DEFAULT_FAWE_SCHEM_DIR = "/opt/paper-ai-25567/plugins/FastAsyncWorldEdit/schematics" +TEMPLATE_SEARCH_CACHE_SIZE = 20 +_template_search_cache = {} +_template_search_lock = threading.Lock() +_template_last_downloaded = {} + def add_log_event(line: str): """Add a meaningful log line to the rolling buffer.""" @@ -93,6 +118,697 @@ def add_log_event(line: str): recent_log.popleft() +def _send_private(player: str, text: str, config, color: str = "gray", italic: bool = False): + safe = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ") + italic_raw = "true" if italic else "false" + rcon( + f'tellraw {player} {{"text":"{safe}","color":"{color}","italic":{italic_raw}}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"], + ) + + +def _template_path(config) -> str: + return config.get("template_dir", DEFAULT_TEMPLATE_DIR) + + +def _fawe_schem_path(config) -> str: + return config.get("fawe_schem_dir", DEFAULT_FAWE_SCHEM_DIR) + + +def _template_hosts(config) -> list: + hosts = config.get("template_allowed_hosts", DEFAULT_TEMPLATE_HOSTS) + return [str(h).lower().strip() for h in hosts if str(h).strip()] + + +def _template_search_blocked_hosts(config) -> list: + hosts = config.get("template_search_blocked_hosts", DEFAULT_TEMPLATE_SEARCH_BLOCKED_HOSTS) + return [str(h).lower().strip() for h in hosts if str(h).strip()] + + +def _is_search_blocked_host(url: str, config) -> bool: + try: + host = urlparse(url).netloc.lower() + except Exception: + return False + for h in _template_search_blocked_hosts(config): + if host == h or host.endswith("." + h): + return True + return False + + +def _safe_template_name(name: str) -> str: + n = re.sub(r'[^a-zA-Z0-9._-]+', '_', name.strip()) + return n[:80] if n else f"template_{int(time.time())}" + + +def _is_allowed_template_url(url: str, config) -> bool: + try: + p = urlparse(url) + if p.scheme not in ("https",): + return False + host = (p.netloc or "").lower() + allowed = _template_hosts(config) + return any(host == h or host.endswith("." + h) for h in allowed) + except Exception: + return False + + +def _extract_ddg_links_from_html(html: str) -> list: + links = [] + for href in re.findall(r'href="([^"]+)"', html): + if "/l/?" not in href: + continue + parsed = urlparse(href) + qs = parse_qs(parsed.query) + target = (qs.get("uddg") or [""])[0] + if not target: + continue + url = unquote(target) + if url.startswith("http://") or url.startswith("https://"): + links.append(url) + return links + + +def _search_templates(query: str, config) -> list: + # No page scraping: return direct file URLs only. + limit = int(config.get("template_search_limit", 8)) + out = [] + seen = set() + qlow = query.lower().strip() + + def add_url(url: str, text: str = ""): + if url in seen: + return + if not _is_allowed_template_url(url, config): + return + path = urlparse(url).path.lower() + if not re.search(r'\.(schem|schematic|nbt|zip)$', path): + return + seen.add(url) + out.append({"url": url, "text": text[:160]}) + + # First: include configured sync catalog entries that match query. + try: + for e in _template_sync_entries(config): + u = e.get("url", "") + nm = e.get("name", "") + blob = f"{u} {nm}".lower() + if qlow and qlow not in blob: + continue + add_url(u, "sync source") + if len(out) >= limit: + return out + except Exception as e: + log.debug(f"Template sync catalog presearch failed: {e}") + + for ext in ("schem", "schematic", "nbt", "zip"): + if len(out) >= limit: + break + try: + r = requests.get( + "https://api.github.com/search/code", + params={"q": f"{query} extension:{ext} in:path", "per_page": 10}, + timeout=20, + headers={"Accept": "application/vnd.github+json"}, + ) + if r.status_code != 200: + continue + data = r.json() + for item in data.get("items") or []: + html_url = item.get("html_url") or "" + if not html_url: + continue + p = urlparse(html_url) + if p.netloc.lower() == "github.com" and "/blob/" in p.path: + raw_path = p.path.replace("/blob/", "/", 1) + raw = f"https://raw.githubusercontent.com{raw_path}" + add_url(raw, "github code search") + if len(out) >= limit: + break + except Exception as e: + log.debug(f"Template GitHub code search failed: {e}") + + return out + + +def _format_bytes(n: int) -> str: + if n < 1024: + return f"{n}B" + if n < 1024 * 1024: + return f"{n/1024:.1f}KB" + return f"{n/(1024*1024):.1f}MB" + + +def _cache_template_search(player: str, rows: list): + urls = [str(r.get("url", "")).strip() for r in rows if isinstance(r, dict)] + urls = [u for u in urls if u] + with _template_search_lock: + _template_search_cache[player] = { + "ts": time.time(), + "urls": urls[:TEMPLATE_SEARCH_CACHE_SIZE], + } + + +def _get_cached_template_url(player: str, token: str) -> str: + tok = token.strip().strip("[](){}\\") + if tok.startswith("#"): + tok = tok[1:] + if not tok.isdigit(): + return "" + idx = int(tok) + if idx <= 0: + return "" + with _template_search_lock: + entry = _template_search_cache.get(player) or {} + urls = entry.get("urls") or [] + if idx > len(urls): + return "" + return urls[idx - 1] + + +def _get_cached_template_urls(player: str) -> list: + with _template_search_lock: + entry = _template_search_cache.get(player) or {} + urls = entry.get("urls") or [] + return [str(u) for u in urls if str(u).strip()] + + +def _list_templates(config, max_rows: int = 12) -> list: + root = _template_path(config) + os.makedirs(root, exist_ok=True) + rows = [] + for name in sorted(os.listdir(root)): + full = os.path.join(root, name) + if not os.path.isfile(full): + continue + try: + size = os.path.getsize(full) + except OSError: + size = 0 + rows.append({"name": name, "size": size}) + rows.sort(key=lambda x: x["name"].lower()) + return rows[:max_rows] + + +def _delete_template(name: str, config) -> str: + safe = os.path.basename(name.strip()) + if not safe: + raise ValueError("Missing template filename") + if safe != name.strip(): + raise ValueError("Invalid template filename") + full = os.path.join(_template_path(config), safe) + if not os.path.isfile(full): + raise ValueError("Template file not found") + os.remove(full) + return full + + +def _download_template(url: str, name_hint: str, config) -> str: + return _download_template_inner(url, name_hint, config, depth=0) + + +def _template_sync_entries(config) -> list: + entries = [] + + for url in config.get("template_sync_urls", []) or []: + u = str(url).strip() + if u: + entries.append({"url": u, "name": ""}) + + manifest_url = str(config.get("template_sync_manifest_url", "")).strip() + if manifest_url: + r = requests.get(manifest_url, timeout=25) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + for item in data: + if isinstance(item, str): + entries.append({"url": item.strip(), "name": ""}) + elif isinstance(item, dict): + entries.append({"url": str(item.get("url", "")).strip(), "name": str(item.get("name", "")).strip()}) + elif isinstance(data, dict): + for item in data.get("templates", []) or []: + if isinstance(item, str): + entries.append({"url": item.strip(), "name": ""}) + elif isinstance(item, dict): + entries.append({"url": str(item.get("url", "")).strip(), "name": str(item.get("name", "")).strip()}) + + deduped = [] + seen = set() + for e in entries: + u = e.get("url", "").strip() + if not u or u in seen: + continue + if not _is_allowed_template_url(u, config): + continue + seen.add(u) + deduped.append({"url": u, "name": e.get("name", "").strip()}) + return deduped + + +def _template_sync(config) -> dict: + entries = _template_sync_entries(config) + if not entries: + return {"ok": 0, "failed": 0, "errors": ["No template sync sources configured"]} + + max_files = int(config.get("template_sync_max_files", 50)) + ok = 0 + failed = 0 + errors = [] + for e in entries[:max_files]: + try: + _download_template(e["url"], e.get("name", ""), config) + ok += 1 + except Exception as ex: + failed += 1 + errors.append(f"{e['url']}: {ex}") + return {"ok": ok, "failed": failed, "errors": errors[:6]} + + +def _extract_template_candidates_from_html(base_url: str, html: str, config) -> list: + candidates = [] + seen = set() + + for href in re.findall(r'href=["\']([^"\']+)["\']', html, re.IGNORECASE): + full = urljoin(base_url, href.strip()) + if full in seen: + continue + seen.add(full) + + p = urlparse(full) + host = p.netloc.lower() + path = p.path.lower() + + if host == "github.com" and "/blob/" in p.path and re.search(r'\.(schem|schematic|nbt|zip)$', path): + raw_path = p.path.replace("/blob/", "/", 1) + raw = f"https://raw.githubusercontent.com{raw_path}" + if _is_allowed_template_url(raw, config): + candidates.append(raw) + continue + + if re.search(r'\.(schem|schematic|nbt|zip)$', path): + if _is_allowed_template_url(full, config): + candidates.append(full) + + return candidates + + +def _extract_template_page_candidates_from_html(base_url: str, html: str, config) -> list: + pages = [] + seen = set() + base_host = urlparse(base_url).netloc.lower() + for href in re.findall(r'href=["\']([^"\']+)["\']', html, re.IGNORECASE): + full = urljoin(base_url, href.strip()) + if full in seen: + continue + seen.add(full) + p = urlparse(full) + host = p.netloc.lower() + path = p.path.lower() + if _is_search_blocked_host(full, config): + continue + if not _is_allowed_template_url(full, config): + continue + if re.search(r'\.(schem|schematic|nbt|zip)$', path): + continue + if re.search(r'\.(png|jpg|jpeg|gif|webp|svg|ico|css|js|txt|json|xml|pdf)$', path): + continue + if '/search' in path or '/projects/' == path or path.endswith('/projects/'): + continue + # Prefer detail pages on same domain as seed page. + if host == base_host or 'planetminecraft.com' in host or 'modrinth.com' in host or host == 'github.com': + pages.append(full) + return pages[:6] + + +def _looks_like_template_binary(first_bytes: bytes, content_type: str) -> bool: + if not first_bytes: + return False + + # Known binary containers used by schematics/templates. + if first_bytes.startswith(b"\x1f\x8b"): # gzip + return True + if first_bytes.startswith(b"PK\x03\x04"): # zip + return True + if first_bytes[:1] == b"\x0a": # NBT tag root (common for uncompressed NBT) + return True + + # Common non-template binary signatures to reject. + if first_bytes.startswith(b"\x89PNG\r\n\x1a\n"): + return False + if first_bytes.startswith(b"\xff\xd8\xff"): # jpeg + return False + if first_bytes.startswith(b"GIF87a") or first_bytes.startswith(b"GIF89a"): + return False + if first_bytes.startswith(b"%PDF"): + return False + + ct = (content_type or "").lower() + if ct.startswith("text/") or "html" in ct or "css" in ct or "javascript" in ct or "json" in ct: + return False + + # Heuristic: templates should not look like plain text/CSS/HTML. + sample = first_bytes[:256] + printable = sum(1 for b in sample if 9 <= b <= 13 or 32 <= b <= 126) + ratio = printable / max(1, len(sample)) + low = sample.lower() + if ratio > 0.9 and (b" 0.97: + return False + # If it is not one of known schematic container signatures, reject. + return False + + +def _download_template_inner(url: str, name_hint: str, config, depth: int = 0, visited=None) -> str: + if visited is None: + visited = set() + if url in visited: + raise ValueError("Template URL loop detected") + visited.add(url) + + if depth > 2: + raise ValueError("Could not resolve a direct template file URL") + + p_in = urlparse(url) + if p_in.netloc.lower() == "github.com" and "/blob/" in p_in.path: + # Convert common GitHub blob URL to raw URL automatically. + raw_path = p_in.path.replace("/blob/", "/", 1) + url = f"https://raw.githubusercontent.com{raw_path}" + + if not _is_allowed_template_url(url, config): + raise ValueError("Template URL host not allowed") + + p = urlparse(url) + base = os.path.basename(p.path) or "template.schem" + if "." in base: + ext = "." + base.split(".")[-1].lower() + else: + ext = ".schem" + if ext not in (".schem", ".schematic", ".nbt", ".zip"): + ext = ".schem" + + fname = _safe_template_name(name_hint or os.path.splitext(base)[0]) + ext + out_dir = _template_path(config) + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, fname) + + try: + with requests.get(url, stream=True, timeout=45) as r: + if r.status_code == 403: + raise ValueError(f"Source blocked automated download (403): {urlparse(url).netloc}") + r.raise_for_status() + content_type = (r.headers.get("Content-Type") or "").lower() + if "text/html" in content_type: + raise ValueError("URL returned HTML page. template download requires a direct file URL") + + total = 0 + max_bytes = int(config.get("template_max_bytes", 10 * 1024 * 1024)) + first_bytes = b"" + with open(out_path, "wb") as f: + for chunk in r.iter_content(chunk_size=65536): + if not chunk: + continue + if not first_bytes: + first_bytes = chunk[:512] + if not _looks_like_template_binary(first_bytes, content_type): + raise ValueError("Downloaded content is not a valid binary template file") + total += len(chunk) + if total > max_bytes: + raise ValueError("Template file too large") + f.write(chunk) + if os.path.isfile(out_path) and os.path.getsize(out_path) == 0: + raise ValueError("Downloaded empty template file") + except Exception: + try: + if os.path.isfile(out_path): + os.remove(out_path) + except Exception: + pass + raise + return out_path + + +def _cache_last_download(player: str, path: str): + with _template_search_lock: + _template_last_downloaded[player] = path + + +def _last_download(player: str) -> str: + with _template_search_lock: + return _template_last_downloaded.get(player, "") + + +def _resolve_template_file(name_or_file: str, player: str, config) -> str: + token = (name_or_file or "").strip().strip("[](){}\\") + if not token: + return _last_download(player) + + root = _template_path(config) + direct = os.path.join(root, os.path.basename(token)) + if os.path.isfile(direct): + return direct + + stem = os.path.basename(token) + for ext in (".schem", ".schematic", ".nbt", ".zip"): + cand = os.path.join(root, stem + ext) + if os.path.isfile(cand): + return cand + return "" + + +def _stage_template_for_fawe(src_path: str, config) -> str: + schem_dir = _fawe_schem_path(config) + os.makedirs(schem_dir, exist_ok=True) + base = os.path.basename(src_path) + name_no_ext = _safe_template_name(os.path.splitext(base)[0]) + dest = os.path.join(schem_dir, name_no_ext + ".schem") + shutil.copyfile(src_path, dest) + return name_no_ext + + +def _info_lookup_wiki(query: str) -> list: + try: + r = requests.get( + "https://minecraft.wiki/api.php", + params={ + "action": "query", + "list": "search", + "srsearch": query, + "srlimit": 3, + "format": "json", + }, + timeout=20, + ) + r.raise_for_status() + data = r.json() + rows = [] + for it in (data.get("query", {}).get("search", []) or [])[:3]: + title = str(it.get("title", "")).strip() + if not title: + continue + rows.append(f"minecraft.wiki: {title}") + return rows + except Exception: + return [] + + +def _info_lookup_web(query: str) -> list: + try: + r = requests.get( + "https://api.duckduckgo.com/", + params={"q": query, "format": "json", "no_redirect": 1, "no_html": 1}, + timeout=20, + ) + r.raise_for_status() + data = r.json() + rows = [] + abs_text = (data.get("AbstractText") or "").strip() + if abs_text: + rows.append(abs_text[:220]) + heading = (data.get("Heading") or "").strip() + if heading and not abs_text: + rows.append(f"Topic: {heading}") + for it in data.get("RelatedTopics") or []: + if isinstance(it, dict) and it.get("Text"): + rows.append(str(it.get("Text"))[:220]) + if len(rows) >= 3: + break + return rows[:3] + except Exception: + return [] + + +def process_sudo_template_command(player: str, prompt: str, config) -> bool: + """ + Handle deterministic template management via sudo. + Returns True if prompt was a template command and has been handled. + """ + raw = prompt.strip() + p = raw.lower() + + if not p.startswith("template "): + return False + + if not ( + p == "template help" + or + p.startswith("template search ") + or p.startswith("template download ") + or p.startswith("template install ") + or p.startswith("template pick ") + or p == "template sync" + or p.startswith("template build") + or p == "template list" + or p.startswith("template delete ") + or p == "template hosts" + ): + _send_private(player, "[SUDO-TEMPLATE] Unknown template command. Use: template help", config, "yellow") + return True + + try: + if p == "template help": + _send_private(player, "[SUDO-TEMPLATE] Commands:", config, "aqua") + _send_private(player, "- template search ", config, "gray") + _send_private(player, "- template download [name]", config, "gray") + _send_private(player, "- template install [name]", config, "gray") + _send_private(player, "- template pick [name] (download from last search)", config, "gray") + _send_private(player, "- template sync (pull from configured sync sources)", config, "gray") + _send_private(player, "- template build (or no arg = last download)", config, "gray") + _send_private(player, "- template list", config, "gray") + _send_private(player, "- template delete ", config, "gray") + _send_private(player, "- template hosts", config, "gray") + return True + + if p == "template hosts": + _send_private(player, "[SUDO-TEMPLATE] Allowed hosts:", config, "aqua") + for h in _template_hosts(config): + _send_private(player, f"- {h}", config, "gray") + return True + + if p == "template list": + rows = _list_templates(config) + if not rows: + _send_private(player, "[SUDO-TEMPLATE] Template folder is empty.", config, "yellow") + return True + _send_private(player, "[SUDO-TEMPLATE] Local templates:", config, "aqua") + for row in rows: + _send_private(player, f"- {row['name']} ({_format_bytes(row['size'])})", config, "gray") + return True + + if p.startswith("template delete "): + name = raw[len("template delete "):].strip() + if not name: + _send_private(player, "[SUDO-TEMPLATE] Usage: template delete ", config, "yellow") + return True + deleted = _delete_template(name, config) + _send_private(player, f"[SUDO-TEMPLATE] Deleted: {deleted}", config, "green") + return True + + if p.startswith("template search "): + query = raw[len("template search "):].strip() + if not query: + _send_private(player, "[SUDO-TEMPLATE] Usage: template search ", config, "yellow") + return True + rows = _search_templates(query, config) + if not rows: + _send_private(player, "[SUDO-TEMPLATE] No direct template links found.", config, "yellow") + _send_private(player, "[SUDO-TEMPLATE] Tip: configure sync sources then run: template sync", config, "dark_aqua") + return True + _cache_template_search(player, rows) + _send_private(player, "[SUDO-TEMPLATE] Top results:", config, "aqua") + for i, row in enumerate(rows[:8], 1): + _send_private(player, f"{i}) {row['url']}", config, "gray") + _send_private(player, "[SUDO-TEMPLATE] Tip: use 'sudo template pick [name]'.", config, "dark_aqua") + return True + + if p.startswith("template pick "): + rest = raw[len("template pick "):].strip() + if not rest: + _send_private(player, "[SUDO-TEMPLATE] Usage: template pick [name]", config, "yellow") + return True + parts = rest.split() + token = parts[0] + name_start = 1 + if token.lower() in ("option", "result") and len(parts) > 1: + token = parts[1] + name_start = 2 + + url = _get_cached_template_url(player, token) + if not url: + _send_private(player, "[SUDO-TEMPLATE] Invalid index. Run template search first.", config, "yellow") + return True + name_hint = " ".join(parts[name_start:]).strip() if len(parts) > name_start else "" + try: + out_path = _download_template(url, name_hint, config) + except Exception as first_err: + out_path = "" + urls = _get_cached_template_urls(player) + for fallback_url in urls: + if fallback_url == url: + continue + try: + out_path = _download_template(fallback_url, name_hint, config) + break + except Exception: + continue + if not out_path: + raise first_err + _cache_last_download(player, out_path) + _send_private(player, f"[SUDO-TEMPLATE] Downloaded from #{token.lstrip('#')}: {out_path}", config, "green") + return True + + if p == "template sync": + _send_private(player, "[SUDO-TEMPLATE] Syncing templates...", config, "aqua") + res = _template_sync(config) + _send_private(player, f"[SUDO-TEMPLATE] Sync complete: ok={res['ok']} failed={res['failed']}", config, "green" if res['failed'] == 0 else "yellow") + for err in res.get("errors", [])[:3]: + _send_private(player, f"[SUDO-TEMPLATE] {err}", config, "gray") + return True + + if p.startswith("template build"): + rest = raw[len("template build"):].strip() + src = _resolve_template_file(rest, player, config) + if not src: + _send_private(player, "[SUDO-TEMPLATE] Template not found. Use template list or download first.", config, "yellow") + return True + + staged_name = _stage_template_for_fawe(src, config) + _send_private(player, f"[SUDO-TEMPLATE] Staged for FAWE: {os.path.basename(src)}", config, "aqua") + _send_private(player, f"[SUDO-TEMPLATE] Run in chat: //schem load {staged_name}", config, "gray") + _send_private(player, "[SUDO-TEMPLATE] Then run: //paste -a", config, "gray") + return True + + cmd = "template download " if p.startswith("template download ") else "template install " + rest = raw[len(cmd):].strip() + if not rest: + _send_private(player, "[SUDO-TEMPLATE] Usage: template download [name]", config, "yellow") + return True + + parts = rest.split() + first = parts[0] + token = first + name_start = 1 + if first.lower() in ("option", "result") and len(parts) > 1: + token = parts[1] + name_start = 2 + + cached_url = _get_cached_template_url(player, token) + url = cached_url or first + if cached_url: + name_hint = " ".join(parts[name_start:]).strip() if len(parts) > name_start else "" + else: + name_hint = " ".join(parts[1:]).strip() if len(parts) > 1 else "" + out_path = _download_template(url, name_hint, config) + _cache_last_download(player, out_path) + _send_private(player, f"[SUDO-TEMPLATE] Downloaded: {out_path}", config, "green") + return True + except Exception as e: + log.warning(f"Template sudo command failed: {e}") + _send_private(player, f"[SUDO-TEMPLATE] Failed: {e}", config, "red") + return True + + def _memory_path(config) -> str: return config.get( "memory_path", @@ -526,6 +1242,14 @@ EFFECTS (replace {target} with any online player's username): MOVEMENT: tp {target} 0 64 0 + tp {target} + tp {target} ~ ~10 ~ + execute in minecraft:the_nether run tp {target} + execute in minecraft:the_end run tp {target} 0 64 0 + execute in minecraft:overworld run tp {target} + NOTE: To teleport a player to another dimension ALWAYS use: + execute in minecraft: run tp + NEVER use: tp minecraft:the_nether (this is wrong syntax) WORLD/ENVIRONMENT (affects all players): time set day @@ -668,6 +1392,36 @@ def _parse_llm_json(content: str) -> dict: log.warning(f"Repaired JSON: message={len(message)}chars, commands={commands}") return result +ENCHANTMENT_CONTEXT = """ +=== ENCHANTMENT RULES FOR GOD === +When giving weapons, tools, or armor as a divine gift, you should ALMOST ALWAYS enchant them. +Enchanted gifts feel more divine. Unenchanted items are acceptable only as a deliberate +choice (e.g. giving basic materials, a punishment of mediocrity, or items that cannot be enchanted). + +Use 1.21 component syntax: give minecraft:[enchantments={ench1:lvl,ench2:lvl}] 1 + +MAX ENCHANTMENT REFERENCE (use as baseline for "fully enchanted" or "blessed" gifts): + +SWORD: netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}] +PICKAXE: netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] +AXE: netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}] +SHOVEL: netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] +HOE: netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] +BOW: bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}] +CROSSBOW: crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}] +TRIDENT: trident[enchantments={channeling:1,loyalty:3,unbreaking:3,mending:1,impaling:5}] +HELMET: netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}] +CHEST: netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}] +LEGS: netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}] +BOOTS: netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}] +FISHING: fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}] +ELYTRA: elytra[enchantments={unbreaking:3,mending:1}] +SHIELD: shield[enchantments={unbreaking:3,mending:1}] + +You do NOT need to always give max enchants — a modest reward may have fewer. +But unenchanted weapons/tools/armor from God should be the exception, not the rule. +""" + COMMANDS_SYSTEM_PROMPT = ( "You are a Minecraft server command executor. Given a player's prayer and server context, " "decide what server commands to run (if any) as an act of God.\n\n" @@ -688,6 +1442,7 @@ COMMANDS_SYSTEM_PROMPT = ( + COMMAND_PALETTE + "\n=== ITEM LIBRARY ===\n" + ITEM_LIBRARY + + ENCHANTMENT_CONTEXT ) SUDO_COMMANDS_SYSTEM_PROMPT = ( @@ -700,9 +1455,67 @@ SUDO_COMMANDS_SYSTEM_PROMPT = ( "- If the request cannot be mapped safely, return commands: [].\n" "- If player says 'me' or 'my', target the requesting player.\n" "- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n" - "- For give syntax: give minecraft: \n" - "- Count is last. Namespace minecraft: is required.\n" + "- For give syntax: give minecraft: (count LAST, namespace required)\n" "- Return commands only. No commentary.\n" + "\n" + "=== TELEPORT SYNTAX ===\n" + "Same dimension: tp \n" + "Relative: tp ~ ~10 ~\n" + "To Nether: execute in minecraft:the_nether run tp \n" + "To End: execute in minecraft:the_end run tp 64 \n" + "To Overworld: execute in minecraft:overworld run tp \n" + "WRONG (never do this): tp minecraft:the_nether\n" + "When dimension is unspecified, use a sensible default spawn coord for that dimension.\n" + "\n" + "=== FULLY ENCHANTED (max enchantments per item type, 1.21 syntax) ===\n" + "Use item[enchantments={...}] syntax. Count is always 1 for enchanted items.\n" + "\n" + "SWORD (netherite_sword):\n" + " give

minecraft:netherite_sword[enchantments={sharpness:5,unbreaking:3,looting:3,fire_aspect:2,mending:1,sweeping_edge:3}] 1\n" + "\n" + "PICKAXE (netherite_pickaxe):\n" + " give

minecraft:netherite_pickaxe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n" + "\n" + "AXE (netherite_axe):\n" + " give

minecraft:netherite_axe[enchantments={efficiency:5,unbreaking:3,fortune:3,sharpness:5,mending:1}] 1\n" + "\n" + "SHOVEL (netherite_shovel):\n" + " give

minecraft:netherite_shovel[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n" + "\n" + "HOE (netherite_hoe):\n" + " give

minecraft:netherite_hoe[enchantments={efficiency:5,unbreaking:3,fortune:3,mending:1}] 1\n" + "\n" + "BOW:\n" + " give

minecraft:bow[enchantments={power:5,unbreaking:3,infinity:1,flame:1,punch:2}] 1\n" + "\n" + "CROSSBOW:\n" + " give

minecraft:crossbow[enchantments={multishot:1,quick_charge:3,unbreaking:3,mending:1}] 1\n" + "\n" + "TRIDENT:\n" + " give

minecraft:trident[enchantments={channeling:1,loyalty:3,unbreaking:3,mending:1,impaling:5}] 1\n" + "\n" + "HELMET (netherite_helmet):\n" + " give

minecraft:netherite_helmet[enchantments={protection:4,unbreaking:3,respiration:3,aqua_affinity:1,thorns:3,mending:1}] 1\n" + "\n" + "CHESTPLATE (netherite_chestplate):\n" + " give

minecraft:netherite_chestplate[enchantments={protection:4,unbreaking:3,thorns:3,mending:1}] 1\n" + "\n" + "LEGGINGS (netherite_leggings):\n" + " give

minecraft:netherite_leggings[enchantments={protection:4,unbreaking:3,swift_sneak:3,mending:1}] 1\n" + "\n" + "BOOTS (netherite_boots):\n" + " give

minecraft:netherite_boots[enchantments={protection:4,unbreaking:3,feather_falling:4,depth_strider:3,soul_speed:3,mending:1}] 1\n" + "\n" + "FISHING ROD:\n" + " give

minecraft:fishing_rod[enchantments={luck_of_the_sea:3,lure:3,unbreaking:3,mending:1}] 1\n" + "\n" + "ELYTRA:\n" + " give

minecraft:elytra[enchantments={unbreaking:3,mending:1}] 1\n" + "\n" + "SHIELD:\n" + " give

minecraft:shield[enchantments={unbreaking:3,mending:1}] 1\n" + "\n" + "When player asks for 'fully enchanted', 'max enchanted', 'best', 'godlike' gear — use the above templates.\n" ) FIRST_LOGIN_BENEVOLENCE_PROMPT = ( @@ -756,6 +1569,81 @@ def _llm_call(model: str, system: str, user: str, config: dict, r.raise_for_status() return r.json()["message"]["content"] + +def _gateway_enabled(config) -> bool: + return bool(config.get("use_langgraph_gateway", False)) + + +def _gateway_key(player: str, mode: str) -> str: + return f"{mode}:{player}" + + +def _gateway_actor(player: str, mode: str, config) -> str: + shared_modes = set(str(m).strip().lower() for m in config.get("gateway_shared_mode_sessions", []) if str(m).strip()) + if mode.lower() in shared_modes: + return "__shared__" + return player + + +def _gateway_start_session(player: str, mode: str, config) -> str: + actor = _gateway_actor(player, mode, config) + key = _gateway_key(actor, mode) + with _gateway_lock: + sid = _gateway_sessions.get(key) + if sid: + return sid + + url = config.get("langgraph_gateway_url", "http://127.0.0.1:8091") + timeout = int(config.get("langgraph_gateway_timeout", 45)) + r = requests.post( + f"{url}/v1/session/start", + json={"player": actor, "mode": mode}, + timeout=timeout, + ) + r.raise_for_status() + sid = r.json()["session_id"] + with _gateway_lock: + _gateway_sessions[key] = sid + return sid + + +def _gateway_send(player: str, mode: str, text: str, context_payload: dict, config, + allow_tools: bool = True, max_tool_steps: int = 4) -> dict: + """Call session gateway and return {message, commands, tool_trace}.""" + sid = _gateway_start_session(player, mode, config) + url = config.get("langgraph_gateway_url", "http://127.0.0.1:8091") + timeout = int(config.get("langgraph_gateway_timeout", 45)) + payload = { + "role": "user", + "text": text, + "context": context_payload, + "allow_tools": allow_tools, + "max_tool_steps": max_tool_steps, + } + try: + r = requests.post(f"{url}/v1/session/{sid}/message", json=payload, timeout=timeout) + r.raise_for_status() + data = r.json() + return { + "message": data.get("message"), + "commands": data.get("commands") or [], + "tool_trace": data.get("tool_trace") or [], + } + except Exception: + # Session might be expired/reset in gateway. Retry once with fresh session. + actor = _gateway_actor(player, mode, config) + with _gateway_lock: + _gateway_sessions.pop(_gateway_key(actor, mode), None) + sid = _gateway_start_session(player, mode, config) + r = requests.post(f"{url}/v1/session/{sid}/message", json=payload, timeout=timeout) + r.raise_for_status() + data = r.json() + return { + "message": data.get("message"), + "commands": data.get("commands") or [], + "tool_trace": data.get("tool_trace") or [], + } + def _build_prayer_context(player, prayer, context, config) -> str: """Build the full user message block shared by both calls.""" try: @@ -785,6 +1673,31 @@ def ask_god(player, prayer, context, config): history = get_prayer_history_messages() user_msg = _build_prayer_context(player, prayer, context, config) + # Optional session gateway path + if _gateway_enabled(config): + try: + player_state = get_player_context(player, config) + except Exception: + player_state = "" + payload = { + "server_state": context, + "player_state": player_state, + "recent_events": get_log_context_block(), + "history_count": len(history) // 2, + "mode": "god", + } + out = _gateway_send( + player=player, + mode="god", + text=f"pray {prayer}", + context_payload=payload, + config=config, + allow_tools=bool(config.get("gateway_allow_tools_god", True)), + max_tool_steps=int(config.get("gateway_max_tool_steps", 4)), + ) + log.info(f"Gateway god tool_trace={out.get('tool_trace', [])}") + return {"message": out.get("message"), "commands": out.get("commands") or []} + # --- Call 1: commands --- log.info(f"Commands call ({command_model}) — {player}: {prayer[:60]} (history={len(history)//2})") try: @@ -870,6 +1783,23 @@ def ask_god_intervention(context, config): ctx = _build_context_block(context, extras=get_log_context_block()) user_msg = INTERVENTION_PROMPT + ctx + if _gateway_enabled(config): + out = _gateway_send( + player="__system__", + mode="god_system", + text="intervention event", + context_payload={ + "server_state": context, + "recent_events": get_log_context_block(), + "mode": "god_system", + }, + config=config, + allow_tools=bool(config.get("gateway_allow_tools_system", False)), + max_tool_steps=int(config.get("gateway_max_tool_steps", 4)), + ) + log.info(f"Gateway god_system tool_trace={out.get('tool_trace', [])}") + return {"message": out.get("message"), "commands": out.get("commands") or []} + # --- Call 1: commands --- log.info(f"Intervention commands call ({command_model})") try: @@ -922,6 +1852,82 @@ SAFE_PREFIXES = [ 'fill ', 'setblock ', 'clone ', ] +_border_cache = {"ts": 0.0, "half": None} + + +def _tp_border_guard_enabled(config) -> bool: + return bool(config.get("tp_border_guard_enabled", True)) + + +def _parse_abs_coord(tok: str): + tok = (tok or "").strip() + if not tok or tok.startswith("~") or tok.startswith("^"): + return None + try: + return float(tok) + except Exception: + return None + + +def _extract_tp_abs_xyz(cmd: str): + for m in re.finditer(r'\btp\s+\S+\s+(\S+)\s+(\S+)\s+(\S+)', cmd): + x = _parse_abs_coord(m.group(1)) + y = _parse_abs_coord(m.group(2)) + z = _parse_abs_coord(m.group(3)) + if x is None or y is None or z is None: + continue + return x, y, z + return None + + +def _worldborder_half_extent(config): + now = time.time() + if now - float(_border_cache.get("ts", 0.0)) < 10.0 and _border_cache.get("half") is not None: + return float(_border_cache["half"]) + + try: + raw = rcon("worldborder get", config["rcon_host"], config["rcon_port"], config["rcon_password"]) + nums = re.findall(r'(-?[\d.]+)', raw or "") + if not nums: + return None + width = float(nums[0]) + half = max(0.0, width / 2.0) + _border_cache["ts"] = now + _border_cache["half"] = half + return half + except Exception: + return None + + +_OTHER_DIMENSION_RE = re.compile( + r'\bexecute\s+in\s+minecraft:(the_nether|the_end|nether|end)\b', re.IGNORECASE +) + + +def _tp_inside_worldborder(cmd: str, config) -> bool: + if not _tp_border_guard_enabled(config): + return True + + # Nether/End dimension teleports use different coordinate spaces — skip border check. + if _OTHER_DIMENSION_RE.search(cmd): + return True + + xyz = _extract_tp_abs_xyz(cmd) + if not xyz: + return True + + half = _worldborder_half_extent(config) + if half is None: + return True + + x, _, z = xyz + cx = float(config.get("worldborder_center_x", 0.0)) + cz = float(config.get("worldborder_center_z", 0.0)) + margin = max(0.0, float(config.get("tp_border_margin", 2.0))) + limit = max(0.0, half - margin) + + return abs(x - cx) <= limit and abs(z - cz) <= limit + def fix_give_command(cmd: str) -> str: """ Correct common LLM give command mistakes: @@ -986,14 +1992,20 @@ def fix_effect_command(cmd: str) -> str: return fixed return cmd -def validate_command(cmd, online_players, fallback_player): +def validate_command(cmd, online_players, fallback_player, config=None): """Replace placeholders, auto-fix common give syntax errors, check safe prefix.""" resolved = cmd.replace("{player}", fallback_player).replace("{target}", fallback_player) + resolved = resolved.strip() + if resolved.startswith("/"): + resolved = resolved[1:] resolved = fix_give_command(resolved) resolved = fix_effect_command(resolved) if not any(resolved.startswith(p) for p in SAFE_PREFIXES): log.warning(f"Command blocked (unknown prefix): {resolved}") return resolved, False + if config and _extract_tp_abs_xyz(resolved) and not _tp_inside_worldborder(resolved, config): + log.warning(f"Command blocked (tp outside worldborder): {resolved}") + return resolved, False return resolved, True def execute_response(response, context, config, praying_player=None): @@ -1051,7 +2063,7 @@ def execute_response(response, context, config, praying_player=None): ) for cmd in commands[:max_cmds]: - resolved, is_safe = validate_command(cmd, context["online_players"], fallback) + resolved, is_safe = validate_command(cmd, context["online_players"], fallback, config) if not is_safe: continue log.info(f"Executing RCON: {resolved}") @@ -1112,22 +2124,41 @@ def process_first_login_benevolence(player, config): command_model = config.get("command_model", config["model"]) message_model = config.get("model") + message = "" - try: - cmd_content = _llm_call( - model=command_model, - system=FIRST_LOGIN_BENEVOLENCE_PROMPT, - user=user_msg, + if _gateway_enabled(config): + out = _gateway_send( + player=player, + mode="god_system", + text=f"first login benevolence event for {player}", + context_payload={ + "server_state": context, + "recent_events": get_log_context_block(), + "event": "first_login_benevolence", + "target_player": player, + }, config=config, - fmt="json", - temperature=0.4, - max_tokens=220, + allow_tools=bool(config.get("gateway_allow_tools_system", False)), + max_tool_steps=int(config.get("gateway_max_tool_steps", 4)), ) - parsed = _parse_llm_json(cmd_content) - commands = (parsed.get("commands") or []) - except Exception as e: - log.error(f"First-login benevolence commands call failed: {e}") - commands = [] + commands = out.get("commands") or [] + message = out.get("message") or "" + else: + try: + cmd_content = _llm_call( + model=command_model, + system=FIRST_LOGIN_BENEVOLENCE_PROMPT, + user=user_msg, + config=config, + fmt="json", + temperature=0.4, + max_tokens=220, + ) + parsed = _parse_llm_json(cmd_content) + commands = (parsed.get("commands") or []) + except Exception as e: + log.error(f"First-login benevolence commands call failed: {e}") + commands = [] commands = _limit_player_kills(commands, context.get("online_players", [])) @@ -1139,23 +2170,26 @@ def process_first_login_benevolence(player, config): f"execute at {player} run summon minecraft:firework_rocket ~ ~1 ~", ] - # Optional message - try: - msg_user = ( - f"First login blessing for {player}.\n" - f"Commands chosen: {commands}\n" - "Write a benevolent divine proclamation to all players." - ) - message = _llm_call( - model=message_model, - system=build_message_system_prompt(config), - user=msg_user, - config=config, - fmt=None, - temperature=0.85, - max_tokens=min(220, int(config.get("max_tokens", 600))), - ).strip() - except Exception: + # Optional message (local path only if gateway did not already provide one) + if not _gateway_enabled(config): + try: + msg_user = ( + f"First login blessing for {player}.\n" + f"Commands chosen: {commands}\n" + "Write a benevolent divine proclamation to all players." + ) + message = _llm_call( + model=message_model, + system=build_message_system_prompt(config), + user=msg_user, + config=config, + fmt=None, + temperature=0.85, + max_tokens=min(220, int(config.get("max_tokens", 600))), + ).strip() + except Exception: + message = f"A blessing descends upon {player} for their first steps in this world." + elif not message: message = f"A blessing descends upon {player} for their first steps in this world." max_cmds = int(config.get("first_login_benevolence_max_commands", 10)) @@ -1194,6 +2228,78 @@ def process_sudo(player, prompt, config): config["rcon_host"], config["rcon_port"], config["rcon_password"] ) + # Deterministic template manager (search/download/install) + if process_sudo_template_command(player, prompt, config): + return + + # Deterministic lookup mode: information only, no command execution. + low = prompt.lower().strip() + lookup_prefixes = ("lookup ", "search ", "wiki ", "explain ", "what is ", "how do i ", "how to ") + if low.startswith(lookup_prefixes): + query = prompt.strip() + if low.startswith("lookup "): + query = prompt[len("lookup "):].strip() + elif low.startswith("search "): + query = prompt[len("search "):].strip() + elif low.startswith("wiki "): + query = prompt[len("wiki "):].strip() + if not query: + _send_private(player, "[SUDO-LOOKUP] Usage: sudo lookup ", config, "yellow") + return + try: + _send_private(player, "[SUDO-LOOKUP] Result:", config, "aqua") + + wiki_rows = _info_lookup_wiki(query) + web_rows = _info_lookup_web(query) + + if wiki_rows: + _send_private(player, "minecraft.wiki:", config, "dark_aqua") + for row in wiki_rows[:3]: + _send_private(player, f"- {row}", config, "gray") + + if web_rows: + _send_private(player, "web.search:", config, "dark_aqua") + for row in web_rows[:3]: + _send_private(player, f"- {row}", config, "gray") + + # Optional model-assisted explanation/justification. + if _gateway_enabled(config): + out = _gateway_send( + player=player, + mode="sudo", + text=f"lookup only: {query}", + context_payload={ + "mode": "sudo_lookup", + "lookup_only": True, + "query": query, + "wiki_hits": wiki_rows, + "web_hits": web_rows, + }, + config=config, + allow_tools=bool(config.get("gateway_allow_tools_sudo", True)), + max_tool_steps=int(config.get("gateway_max_tool_steps", 4)), + ) + msg = (out.get("message") or "").strip() + trace = out.get("tool_trace") or [] + if msg: + _send_private(player, "justification:", config, "dark_aqua") + for ln in re.split(r"\n+", msg)[:3]: + ln = ln.strip() + if ln: + _send_private(player, f"- {ln[:250]}", config, "gray") + if trace: + _send_private(player, "sources used:", config, "dark_aqua") + for t in trace[:3]: + tool = str(t.get("tool", "tool")) + q = str(t.get("input", ""))[:80] + _send_private(player, f"- {tool}: {q}", config, "dark_gray") + + if not wiki_rows and not web_rows: + _send_private(player, "No lookup results found.", config, "yellow") + except Exception as e: + _send_private(player, f"[SUDO-LOOKUP] Failed: {e}", config, "red") + return + online = players_online(config) # Deterministic builder templates (Paper-optimized) for sudo build requests. @@ -1207,7 +2313,7 @@ def process_sudo(player, prompt, config): ) executed = [] for cmd in commands: - resolved, is_safe = validate_command(cmd, online, player) + resolved, is_safe = validate_command(cmd, online, player, config) if not is_safe: continue log.info(f"SUDO-BUILD execute: {resolved}") @@ -1226,7 +2332,8 @@ def process_sudo(player, prompt, config): ) command_model = config.get("command_model", config["model"]) - try: + + def _local_sudo_translate() -> list: content = _llm_call( model=command_model, system=SUDO_COMMANDS_SYSTEM_PROMPT, @@ -1237,7 +2344,31 @@ def process_sudo(player, prompt, config): max_tokens=180, ) parsed = _parse_llm_json(content) - commands = parsed.get("commands") or [] + return parsed.get("commands") or [] + + try: + if _gateway_enabled(config): + try: + out = _gateway_send( + player=player, + mode="sudo", + text=prompt, + context_payload={ + "online_players": online, + "sudo_history": get_sudo_history_block(), + "mode": "sudo", + }, + config=config, + allow_tools=bool(config.get("gateway_allow_tools_sudo", False)), + max_tool_steps=int(config.get("gateway_max_tool_steps", 2)), + ) + log.info(f"Gateway sudo tool_trace={out.get('tool_trace', [])}") + commands = out.get("commands") or [] + except Exception as e: + log.warning(f"Gateway sudo failed, falling back to local translator: {e}") + commands = _local_sudo_translate() + else: + commands = _local_sudo_translate() except Exception as e: log.error(f"SUDO translation failed: {e}") rcon( @@ -1266,7 +2397,7 @@ def process_sudo(player, prompt, config): executed = [] for cmd in commands: - resolved, is_safe = validate_command(cmd, online, player) + resolved, is_safe = validate_command(cmd, online, player, config) if not is_safe: continue log.info(f"SUDO execute: {resolved}") diff --git a/mc_langgraph_gateway.json b/mc_langgraph_gateway.json new file mode 100644 index 0000000..f14b6c8 --- /dev/null +++ b/mc_langgraph_gateway.json @@ -0,0 +1,9 @@ +{ + "ollama_url": "http://192.168.0.141:11434", + "message_model": "gemma3:12b", + "command_model": "qwen3-coder:30b", + "tool_model": "qwen2.5:1.5b", + "session_ttl_seconds": 21600, + "session_persistence_enabled": true, + "session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db" +}