diff --git a/SESSION.md b/SESSION.md index 266cf5a..8acf771 100644 --- a/SESSION.md +++ b/SESSION.md @@ -17,6 +17,14 @@ This document links the two Minecraft AI God projects together, describes their --- +## Memory Discipline + +- Update `SESSION.md` immediately when a durable fact, decision, or fix is discovered. +- Before every final reply, run a memory check and append any missing durable notes. +- End every reply with one line: `Session memory: updated` or `Session memory: no new durable facts.` + +--- + ## The Two Projects ### Sister repo — minecraft-ai-god (Vanilla) @@ -103,6 +111,15 @@ This section captures decisions and context accumulated across conversations wit - **LLM backend:** Ollama at `192.168.0.179:11434` for the vanilla service. This fork uses `192.168.0.141:11434` (steel141, local GPU) for better throughput on heavier models (gemma3:12b message model, qwen3-coder:30b command model). - **Session gateway (this fork only):** The LangGraph-style FastAPI sidecar adds multi-turn memory without requiring a full LangGraph install. Safety enforcement stays in `mc_aigod_paper.py`, not the gateway. - **Build templates:** Deterministic templates are tried first for `sudo build/make/create` requests. LLM translation is the fallback. This reduces surprises for structure generation tasks. +- **Localized retrieval for sudo reasoning (2026-03-17):** `langgraph_gateway.py` now supports a local knowledge corpus (`/var/lib/mc-langgraph-gateway/knowledge`) with index/search/read tools (`local.search`, `local.read`), optional startup bootstrap from Minecraft/Paper docs URLs, and automatic search->read retrieval hop in sudo tool mode before command generation. +- **Post-deploy behavior check (2026-03-17):** retrieval is active (`tool_trace` shows `local.search` + `local.read`) for `sudo destroy my surroundings`, but generated command remained `execute as run fill ~-5 ~-5 ~-5 ~5 ~5 ~5 air` and produced no useful visible outcome in gameplay (empty/weak RCON feedback). Retrieval alone did not guarantee effective action selection. +- **Sudo execution hardening follow-up (2026-03-17):** added command-shape normalization for sudo (`execute as run ...` -> `execute at run ...`), blocked execute-wrapper bypass in validator (unsafe inner tails like `gameMode` inside `execute ... run`), and added adaptive destructive fallback for failed `destroy/nuke` intents (absolute `fill ... air` around player + TNT summons / TNT kit fallback when position unavailable). +- **Sudo effectiveness feedback loop (2026-03-17):** sudo now scores execution effectiveness from RCON results for all intents, reports translated commands + outcomes + ineffective flag back to gateway session memory (`mode=sudo_feedback`, `feedback_only=true`), and gateway stores this feedback without running extra LLM/tool calls. +- **Lookup contextual memory fix (2026-03-17):** `sudo lookup/search/wiki` now resolves pronoun references (e.g., "that command", "do it again") against the player's most recent executed sudo command, includes that command in lookup context payloads, and passes sudo history into gateway lookup context to improve follow-up reasoning. +- **Gamemode/effect sudo fix (2026-03-17):** Added `gamemode` to safe prefixes/whitelist and command palette, added syntax normalizer for malformed variants (`gameMode s`, short aliases, missing target) and execute-wrapped gamemode forms; confirmed valid RCON forms are `gamemode ` and `effect give [hideParticles]`. +- **AI-driven build/template flow (2026-03-17):** `process_sudo` now defaults to non-deterministic build planning (legacy deterministic builder templates disabled unless `sudo_deterministic_build_templates=true`), supports AI-emitted `template ...` meta-commands in sudo execution loop, and includes an AI template planner override for build/make/create prompts when initial translation does not emit template workflow steps. +- **Failed-execution retry pipeline upgrade (2026-03-17):** sudo now runs a generic retry-repair pass on ineffective results before intent fallback. Added TNT-specific repair for malformed `summon tnt ... ` outputs (expands into bounded multiple summon commands) and invulnerability-effect repair to valid protection effects. Added `tnt` to destructive-intent keywords so TNT requests can trigger destructive fallback when needed. +- **God voice update (2026-03-17):** Increased default God persona emphasis on irony, dark humor, and sarcastic one-liners in both command and message system prompts (vanilla + Paper variants) while keeping command strictness unchanged. ### Infrastructure decisions diff --git a/mc_aigod.py b/mc_aigod.py index c08dac5..4a2b514 100644 --- a/mc_aigod.py +++ b/mc_aigod.py @@ -570,7 +570,7 @@ POTIONS: potion (requires component syntax for type — prefer effect give i def build_system_prompt(config): return ( f"You are God in a Minecraft server called {config['server_name']}.\n" - "You are benevolent but just. Theatrical, ancient, and dramatic in speech.\n" + "You are benevolent but just. Theatrical, ancient, dramatic, and darkly funny in speech.\n" "You answer every prayer with a message. You pass judgement on players when they pray.\n\n" "Respond ONLY with a valid JSON object — no markdown, no explanation, nothing else:\n" '{\n' @@ -578,7 +578,7 @@ def build_system_prompt(config): ' "commands": ["command1", "command2"]\n' '}\n\n' "Rules:\n" - "- message: always present and non-empty. Speak as God. Be dramatic and biblical. KEEP IT UNDER 100 WORDS. Do not ramble — God is powerful, not verbose.\n" + "- message: always present and non-empty. Speak as God. Be dramatic, biblical, and ironic with dry humor. KEEP IT UNDER 100 WORDS. Do not ramble — God is powerful, not verbose.\n" "- commands: list of server commands WITHOUT a leading slash. May be empty [] ONLY if you are deliberately granting nothing.\n" "- CRITICAL: If your message says you will give something, grant something, or fulfil a request, you MUST include the corresponding command in the commands array. Words alone do nothing. The commands array is the ONLY way anything happens in the world. If commands is empty, nothing happens regardless of what your message says.\n" "- {player} is the player who prayed. You may also use any other online player's literal username as a target.\n" @@ -723,11 +723,11 @@ FIRST_LOGIN_BENEVOLENCE_PROMPT = ( def build_message_system_prompt(config) -> str: base = ( "You are God in a Minecraft server. You are benevolent but just. " - "Theatrical, ancient, and dramatic in speech — like the Old Testament.\n" + "Theatrical, ancient, dramatic, and laced with dry irony — like the Old Testament with a sharper wit.\n" "You will be told what action was taken (if any) in response to a player's prayer. " "Write a single spoken message to all players reacting to this prayer and action.\n" "Respond with ONLY the message text — no JSON, no quotes, no formatting. " - "Be vivid and dramatic. Any length is fine.\n" + "Be vivid and dramatic, with occasional godlike sarcasm and irony. Any length is fine.\n" ) lore = config.get("god_lore", "") if lore: diff --git a/mc_aigod_paper.json b/mc_aigod_paper.json index 656f053..9b4a24e 100644 --- a/mc_aigod_paper.json +++ b/mc_aigod_paper.json @@ -38,6 +38,11 @@ "sudo_enabled": true, "sudo_user": "slingshooter08", "sudo_max_commands": 12, + "bug_log_path": "/var/log/mc_aigod_paper_bug.log", + "bug_log_event_lines": 40, + "bug_log_raw_lines": 120, + "sudo_build_max_commands": 6, + "sudo_deterministic_build_templates": false, "tp_border_guard_enabled": true, "worldborder_center_x": 0, "worldborder_center_z": 0, diff --git a/mc_aigod_paper.py b/mc_aigod_paper.py index 6b2b595..e5fe1c1 100644 --- a/mc_aigod_paper.py +++ b/mc_aigod_paper.py @@ -37,6 +37,10 @@ SUDO_PATTERNS = [ re.compile(r'\[.*?\]: <(\w+)> [Ss]udo (.+)'), ] +BUG_LOG_PATTERNS = [ + re.compile(r'\[.*?\]: <(\w+)> [Bb]ug[_ ]?[Ll]og(?:\s+(.+))?\s*$'), +] + JOIN_PATTERN = re.compile(r'\[.*?\]: (\w+) joined the game') LEAVE_PATTERN = re.compile(r'\[.*?\]: (\w+) left the game') @@ -963,6 +967,90 @@ def get_sudo_history_block() -> str: ) return "\n=== LAST 10 SUDO ACTIONS ===\n" + "\n".join(lines) + "\n" + +def get_last_sudo_executed_command(player: str) -> str: + """Return the most recent executed sudo command for a player, if any.""" + with _memory_lock: + entries = list(sudo_history) + for _, p, _, _, executed in reversed(entries): + if p != player: + continue + if executed: + return str(executed[-1]) + return "" + + +def _bug_log_path(config) -> str: + return config.get("bug_log_path", "/var/log/mc_aigod_paper_bug.log") + + +def _read_recent_raw_log_lines(log_path: str, max_lines: int) -> list: + lines = deque(maxlen=max_lines) + try: + with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + for line in f: + lines.append(line.rstrip('\n')) + except Exception as e: + return [f""] + return list(lines) + + +def _format_recent_event_lines(max_events: int) -> list: + with _memory_lock: + entries = list(recent_log)[-max_events:] + out = [] + for ts, entry in entries: + stamp = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') + out.append(f"[{stamp}] {entry}") + return out + + +def process_bug_log(player: str, description: str, config): + desc = (description or "").strip() + if not desc: + desc = "(no description provided)" + + event_count = int(config.get("bug_log_event_lines", 40)) + raw_count = int(config.get("bug_log_raw_lines", 120)) + + recent_events = _format_recent_event_lines(event_count) + raw_lines = _read_recent_raw_log_lines(config.get("log_path", ""), raw_count) + bug_path = _bug_log_path(config) + timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') + + try: + parent = os.path.dirname(bug_path) + if parent: + os.makedirs(parent, exist_ok=True) + + with open(bug_path, 'a', encoding='utf-8') as bf: + bf.write("\n" + "=" * 80 + "\n") + bf.write(f"BUG LOG ENTRY: {timestamp}\n") + bf.write(f"Player: {player}\n") + bf.write(f"Description: {desc}\n") + bf.write("\n-- RECENT INTERESTING EVENTS --\n") + if recent_events: + bf.write("\n".join(recent_events) + "\n") + else: + bf.write("(no recent in-memory events captured)\n") + bf.write("\n-- RECENT RAW SERVER LOG LINES --\n") + if raw_lines: + bf.write("\n".join(raw_lines) + "\n") + else: + bf.write("(no raw lines available)\n") + + log.info(f"BUG_LOG recorded by {player}: {desc} -> {bug_path}") + rcon( + f'tellraw {player} {{"text":"[BUG_LOG] Logged. Thank you.","color":"green"}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + except Exception as e: + log.error(f"BUG_LOG write failed for {player}: {e}", exc_info=True) + rcon( + f'tellraw {player} {{"text":"[BUG_LOG] Failed to write bug log.","color":"red"}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + # --------------------------------------------------------------------------- # RCON # --------------------------------------------------------------------------- @@ -1225,18 +1313,18 @@ SERVER_CAPABILITIES = { "vanilla": { "safe_prefixes": [ 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', - 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', + 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', 'gamemode ', ], - "sudo_whitelist_note": "give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder", + "sudo_whitelist_note": "give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder, gamemode", "template_build": False, }, "paper": { "safe_prefixes": [ 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', - 'fill ', 'setblock ', 'clone ', + 'fill ', 'setblock ', 'clone ', 'gamemode ', ], - "sudo_whitelist_note": "give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder, fill, setblock, clone", + "sudo_whitelist_note": "give, effect, xp, tp, time, weather, execute, kill, summon, tellraw, worldborder, fill, setblock, clone, gamemode", "template_build": True, }, } @@ -1260,6 +1348,7 @@ GIVE (any item, based on player need — see Item Naming Rules below): xp add {target} levels EFFECTS (replace {target} with any online player's username): + SYNTAX: effect give minecraft: effect give {target} minecraft:regeneration 120 2 effect give {target} minecraft:strength 300 1 effect give {target} minecraft:speed 300 2 @@ -1275,6 +1364,12 @@ EFFECTS (replace {target} with any online player's username): effect give {target} minecraft:levitation 5 3 effect clear {target} +GAMEMODE: + gamemode survival {target} + gamemode creative {target} + gamemode adventure {target} + gamemode spectator {target} + MOVEMENT: tp {target} 0 64 0 tp {target} @@ -1329,7 +1424,7 @@ POTIONS: potion (requires component syntax for type — prefer effect give i def build_system_prompt(config): return ( f"You are God in a Minecraft server called {config['server_name']}.\n" - "You are benevolent but just. Theatrical, ancient, and dramatic in speech.\n" + "You are benevolent but just. Theatrical, ancient, dramatic, and darkly funny in speech.\n" "You answer every prayer with a message. You pass judgement on players when they pray.\n\n" "Respond ONLY with a valid JSON object — no markdown, no explanation, nothing else:\n" '{\n' @@ -1337,7 +1432,7 @@ def build_system_prompt(config): ' "commands": ["command1", "command2"]\n' '}\n\n' "Rules:\n" - "- message: always present and non-empty. Speak as God. Be dramatic and biblical. KEEP IT UNDER 100 WORDS. Do not ramble — God is powerful, not verbose.\n" + "- message: always present and non-empty. Speak as God. Be dramatic, biblical, and ironic with dry humor. KEEP IT UNDER 100 WORDS. Do not ramble — God is powerful, not verbose.\n" "- commands: list of server commands WITHOUT a leading slash. May be empty [] ONLY if you are deliberately granting nothing.\n" "- CRITICAL: If your message says you will give something, grant something, or fulfil a request, you MUST include the corresponding command in the commands array. Words alone do nothing. The commands array is the ONLY way anything happens in the world. If commands is empty, nothing happens regardless of what your message says.\n" "- {player} is the player who prayed. You may also use any other online player's literal username as a target.\n" @@ -1490,11 +1585,17 @@ def build_sudo_commands_system_prompt(config=None) -> str: "{\"commands\": [\"cmd1\", \"cmd2\"]}\n\n" "Rules:\n" f"- Use commands from this whitelist only: {whitelist}.\n" + "- You may also output meta-commands starting with 'template ' for schematic workflows (not RCON).\n" "- 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: (count LAST, namespace required)\n" "- Return commands only. No commentary.\n" + "- For build requests, prefer template workflow in one response when possible:\n" + " template search \n" + " template pick [name]\n" + " template build \n" + "- Keep template workflow concise (2-4 commands max).\n" "\n" "=== TELEPORT SYNTAX ===\n" "Same dimension: tp \n" @@ -1577,11 +1678,11 @@ SUDO_COMMANDS_SYSTEM_PROMPT = build_sudo_commands_system_prompt() def build_message_system_prompt(config) -> str: base = ( "You are God in a Minecraft server. You are benevolent but just. " - "Theatrical, ancient, and dramatic in speech — like the Old Testament.\n" + "Theatrical, ancient, dramatic, and laced with dry irony — like the Old Testament with a sharper wit.\n" "You will be told what action was taken (if any) in response to a player's prayer. " "Write a single spoken message to all players reacting to this prayer and action.\n" "Respond with ONLY the message text — no JSON, no quotes, no formatting. " - "Be vivid and dramatic. Any length is fine.\n" + "Be vivid and dramatic, with occasional godlike sarcasm and irony. Any length is fine.\n" ) lore = config.get("god_lore", "") if lore: @@ -2033,6 +2134,39 @@ def fix_effect_command(cmd: str) -> str: return fixed return cmd + +def fix_gamemode_command(cmd: str, fallback_player: str) -> str: + """ + Normalize common gamemode variants: + - gameMode -> gamemode + - short aliases (s/c/a/sp) -> survival/creative/adventure/spectator + - add target player when omitted + """ + raw = (cmd or "").strip() + m = re.match(r'^(?:gameMode|gamemode)\s+(\S+)(?:\s+(\w+))?$', raw, flags=re.IGNORECASE) + if not m: + return cmd + mode, target = m.group(1).lower(), (m.group(2) or "").strip() + alias = { + "s": "survival", + "0": "survival", + "c": "creative", + "1": "creative", + "a": "adventure", + "2": "adventure", + "sp": "spectator", + "3": "spectator", + } + mode = alias.get(mode, mode) + if mode not in ("survival", "creative", "adventure", "spectator"): + return cmd + if not target: + target = fallback_player + fixed = f"gamemode {mode} {target}" + if fixed != raw: + log.warning(f"Fixed gamemode syntax: '{cmd}' -> '{fixed}'") + return fixed + 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) @@ -2041,16 +2175,194 @@ def validate_command(cmd, online_players, fallback_player, config=None): resolved = resolved[1:] resolved = fix_give_command(resolved) resolved = fix_effect_command(resolved) + resolved = fix_gamemode_command(resolved, fallback_player) caps = get_server_capabilities(config) if config else SERVER_CAPABILITIES[DEFAULT_SERVER_TYPE] prefixes = caps["safe_prefixes"] if not any(resolved.startswith(p) for p in prefixes): log.warning(f"Command blocked (unknown prefix for server_type={config.get('server_type', DEFAULT_SERVER_TYPE) if config else DEFAULT_SERVER_TYPE}): {resolved}") return resolved, False + + # Prevent execute-wrapper bypass (e.g. execute ... run gameMode s) + if resolved.startswith("execute "): + tail = resolved + for _ in range(4): + if not tail.startswith("execute "): + break + marker = " run " + idx = tail.find(marker) + if idx < 0: + break + tail = tail[idx + len(marker):].strip() + if tail and not tail.startswith("execute "): + inner_prefixes = [p for p in prefixes if p != "execute "] + if not any(tail.startswith(p) for p in inner_prefixes): + log.warning(f"Command blocked (unsafe execute tail): {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 _is_destructive_intent(prompt: str) -> bool: + p = (prompt or "").lower() + keys = ( + "destroy", "destruction", "nuke", "blow up", "blowup", "explode", + "erase", "delete", "annihilate", "wreck", "flatten", "ruin", "tnt", + ) + return any(k in p for k in keys) + + +def _normalize_sudo_command_shape(cmd: str, player: str) -> str: + c = (cmd or "").strip() + if not c: + return c + + # Collapse execute-wrapped gamemode into direct targeted form. + gm = re.match(rf'^execute\s+as\s+{re.escape(player)}\s+run\s+(?:gameMode|gamemode)\s+(\S+)(?:\s+(\w+))?$', c, flags=re.IGNORECASE) + if gm: + mode = gm.group(1) + target = gm.group(2) or player + fixed = f"gamemode {mode} {target}" + log.warning(f"Normalized execute-wrapped gamemode: '{c}' -> '{fixed}'") + return fixed + + # Prefer position-context execution; this yields more reliable behavior than nested execute chains. + m = re.match(rf'^execute\s+as\s+{re.escape(player)}\s+run\s+execute\s+positioned\s+~\s+~\s+~\s+run\s+(.+)$', c) + if m: + fixed = f"execute at {player} run {m.group(1).strip()}" + log.warning(f"Normalized nested execute: '{c}' -> '{fixed}'") + return fixed + + m = re.match(rf'^execute\s+as\s+{re.escape(player)}\s+run\s+(.+)$', c) + if m: + fixed = f"execute at {player} run {m.group(1).strip()}" + log.warning(f"Normalized execute anchor: '{c}' -> '{fixed}'") + return fixed + + # Drop known no-op clone shape (source to same destination). + if re.match(r'^clone\s+~\S*\s+~\S*\s+~\S*\s+~\S*\s+~\S*\s+~\S*\s+~\s+~\s+~$', c): + log.warning(f"Dropped likely no-op clone: '{c}'") + return "" + + return c + + +def _build_destructive_fallback(player: str, config) -> list: + pos = get_player_xyz(player, config) + if not pos: + return [f"give {player} minecraft:tnt 64", f"give {player} minecraft:flint_and_steel 1"] + + x, y, z = pos + radius = int(config.get("sudo_destroy_radius", 12)) + depth = int(config.get("sudo_destroy_depth", 18)) + height = int(config.get("sudo_destroy_height", 6)) + y1 = max(-64, y - depth) + y2 = min(319, y + height) + return [ + f"fill {x-radius} {y1} {z-radius} {x+radius} {y2} {z+radius} minecraft:air", + f"summon minecraft:tnt {x} {min(319, y+2)} {z}", + f"summon minecraft:tnt {x+3} {min(319, y+2)} {z}", + f"summon minecraft:tnt {x-3} {min(319, y+2)} {z}", + ] + + +def _sudo_result_is_effective(result: str) -> bool: + r = (result or "").strip().lower() + if not r: + return False + bad_markers = ( + "unknown", "incorrect argument", "expected", "syntax error", + "no entity was found", "cannot", "failed", "error", + ) + if any(m in r for m in bad_markers): + return False + good_markers = ( + "successfully", "summoned", "set the", "set block", "filled", + "teleported", "gave", "applied effect", "killed", + ) + return any(m in r for m in good_markers) + + +def _report_sudo_feedback(player: str, prompt: str, translated: list, results_seen: list, ineffective: bool, config): + if not _gateway_enabled(config): + return + try: + summary_rows = [] + for cmd, res in results_seen[:8]: + summary_rows.append({"command": cmd, "result": (res or "")[:220]}) + _gateway_send( + player=player, + mode="sudo", + text=f"execution feedback for sudo request: {prompt}", + context_payload={ + "mode": "sudo_feedback", + "feedback_only": True, + "request": prompt, + "translated_commands": translated[:8], + "execution_results": summary_rows, + "ineffective": bool(ineffective), + }, + config=config, + allow_tools=False, + max_tool_steps=0, + ) + except Exception as e: + log.warning(f"Could not report sudo feedback to gateway: {e}") + + +def _repair_failed_sudo_commands(player: str, results_seen: list, config) -> list: + """Build bounded retry commands from observed command/result failures.""" + out = [] + max_retry = int(config.get("sudo_retry_max_commands", 10)) + + for cmd, result in results_seen: + c = (cmd or "").strip() + r = (result or "").lower() + if not c or not r: + continue + + # Fix malformed TNT count usage: + # execute at

run summon tnt ~ ~1 ~ 80 -> multiple summon commands. + if "expected compound tag" in r and "summon" in c and "tnt" in c: + prefix = "" + body = c + m_pref = re.match(rf'^(execute\s+at\s+{re.escape(player)}\s+run\s+)(.+)$', c) + if m_pref: + prefix = m_pref.group(1) + body = m_pref.group(2) + + m = re.match(r'^summon\s+(?:minecraft:)?tnt\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(\d+))?$', body) + if m: + x, y, z, cnt = m.groups() + count = int(cnt) if cnt and cnt.isdigit() else 1 + count = max(1, min(count, int(config.get("sudo_tnt_retry_cap", 24)))) + for i in range(count): + dx = (i % 6) - 3 + dz = (i // 6) - 2 + xx = x if x.startswith("~") else str(int(float(x)) + dx) + zz = z if z.startswith("~") else str(int(float(z)) + dz) + if x.startswith("~") and dx != 0: + xx = f"~{dx:+d}" + if z.startswith("~") and dz != 0: + zz = f"~{dz:+d}" + out.append(f"{prefix}summon minecraft:tnt {xx} {y} {zz}") + if len(out) >= max_retry: + return out + + # Fix nonexistent invulnerability effect to a real durable protection set. + if "mob_effect" in r and "invulnerability" in r: + out.extend([ + f"effect give {player} minecraft:resistance 1200 4 true", + f"effect give {player} minecraft:regeneration 1200 2 true", + f"effect give {player} minecraft:absorption 1200 4 true", + ]) + if len(out) >= max_retry: + return out[:max_retry] + + return out[:max_retry] + def execute_response(response, context, config, praying_player=None): message = response.get("message") or "" commands = response.get("commands") or [] @@ -2289,11 +2601,19 @@ def process_sudo(player, prompt, config): if not query: _send_private(player, "[SUDO-LOOKUP] Usage: sudo lookup ", config, "yellow") return + + # Resolve contextual references like "that command" using sudo memory. + last_cmd = get_last_sudo_executed_command(player) + lookup_query = query + if last_cmd and re.search(r'\b(that|it|same|again|last command)\b', query.lower()): + lookup_query = f"{query} (reference command: {last_cmd})" try: _send_private(player, "[SUDO-LOOKUP] Result:", config, "aqua") + if last_cmd: + _send_private(player, f"context command: {last_cmd}", config, "dark_gray") - wiki_rows = _info_lookup_wiki(query) - web_rows = _info_lookup_web(query) + wiki_rows = _info_lookup_wiki(lookup_query) + web_rows = _info_lookup_web(lookup_query) if wiki_rows: _send_private(player, "minecraft.wiki:", config, "dark_aqua") @@ -2314,7 +2634,9 @@ def process_sudo(player, prompt, config): context_payload={ "mode": "sudo_lookup", "lookup_only": True, - "query": query, + "query": lookup_query, + "reference_command": last_cmd, + "sudo_history": get_sudo_history_block(), "wiki_hits": wiki_rows, "web_hits": web_rows, }, @@ -2345,27 +2667,28 @@ def process_sudo(player, prompt, config): online = players_online(config) - # Deterministic builder templates (Paper-optimized) for sudo build requests. - templated = build_template_commands(player, prompt, config) - if templated is not None: - commands = templated[: int(config.get("sudo_max_commands", 12))] - safe_preview = " | ".join(commands).replace("\\", "\\\\").replace('"', '\\"') - rcon( - f'tellraw {player} {{"text":"[SUDO-BUILD] {safe_preview}","color":"dark_aqua","italic":true}}', - config["rcon_host"], config["rcon_port"], config["rcon_password"] - ) - executed = [] - for cmd in commands: - resolved, is_safe = validate_command(cmd, online, player, config) - if not is_safe: - continue - log.info(f"SUDO-BUILD execute: {resolved}") - result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) - log.info(f"SUDO-BUILD result: {result!r}") - executed.append(resolved) - time.sleep(0.15) - add_sudo_history(player, prompt, commands, executed) - return + # Legacy deterministic builder templates are optional; default is AI-driven build planning. + if bool(config.get("sudo_deterministic_build_templates", False)): + templated = build_template_commands(player, prompt, config) + if templated is not None: + commands = templated[: int(config.get("sudo_max_commands", 12))] + safe_preview = " | ".join(commands).replace("\\", "\\\\").replace('"', '\\"') + rcon( + f'tellraw {player} {{"text":"[SUDO-BUILD] {safe_preview}","color":"dark_aqua","italic":true}}', + config["rcon_host"], config["rcon_port"], config["rcon_password"] + ) + executed = [] + for cmd in commands: + resolved, is_safe = validate_command(cmd, online, player, config) + if not is_safe: + continue + log.info(f"SUDO-BUILD execute: {resolved}") + result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"SUDO-BUILD result: {result!r}") + executed.append(resolved) + time.sleep(0.15) + add_sudo_history(player, prompt, commands, executed) + return # Collect positions for all online players. position_lines = [] @@ -2390,6 +2713,7 @@ def process_sudo(player, prompt, config): context_hint = ( f"Requesting player: {player}\n" f"Online players: {', '.join(online) or 'none'}\n" + + "Template workflow commands available: template search ; template pick [name] ; template build \n" + (positions_block + "\n" if positions_block else "") + f"Natural language request: {prompt}\n" + get_sudo_history_block() @@ -2410,6 +2734,32 @@ def process_sudo(player, prompt, config): parsed = _parse_llm_json(content) return parsed.get("commands") or [] + def _ai_template_plan() -> list: + planner_system = ( + "You are a Minecraft template workflow planner. Return ONLY JSON: {\"commands\": [\"...\"]}.\n" + "Output only template workflow meta-commands (not RCON):\n" + "- template search \n" + "- template pick [name]\n" + "- template build \n" + "Keep to 2-4 commands and preserve user intent." + ) + content = _llm_call( + model=command_model, + system=planner_system, + user=( + f"Player: {player}\n" + f"Request: {prompt}\n" + f"Recent sudo history:\n{get_sudo_history_block()}" + ), + config=config, + fmt="json", + temperature=0.2, + max_tokens=140, + ) + parsed = _parse_llm_json(content) + cmds = parsed.get("commands") or [] + return [c for c in cmds if isinstance(c, str) and c.lower().startswith("template ")] + try: if _gateway_enabled(config): try: @@ -2418,6 +2768,7 @@ def process_sudo(player, prompt, config): mode="sudo", text=prompt, context_payload={ + "request": prompt, "online_players": online, "sudo_history": get_sudo_history_block(), "mode": "sudo", @@ -2441,8 +2792,26 @@ def process_sudo(player, prompt, config): ) return - max_cmds = config.get("sudo_max_commands", 3) - commands = commands[:max_cmds] + build_intent = any(prompt.lower().strip().startswith(x) for x in ("build ", "make ", "create ")) + has_template_cmd = any(isinstance(c, str) and c.lower().startswith("template ") for c in commands) + if build_intent and not has_template_cmd: + try: + planned = _ai_template_plan() + if planned: + log.info(f"SUDO template planner override: {planned}") + commands = planned + except Exception as e: + log.warning(f"SUDO template planner failed: {e}") + + max_cmds = int(config.get("sudo_max_commands", 3)) + low_prompt = prompt.lower().strip() + if any(low_prompt.startswith(x) for x in ("build ", "make ", "create ")): + max_cmds = max(max_cmds, int(config.get("sudo_build_max_commands", 6))) + commands = [ + _normalize_sudo_command_shape(c, player) + for c in commands[:max_cmds] + ] + commands = [c for c in commands if c] if not commands: add_sudo_history(player, prompt, [], []) @@ -2460,7 +2829,20 @@ def process_sudo(player, prompt, config): ) executed = [] + results_seen = [] for cmd in commands: + if cmd.lower().startswith("template "): + log.info(f"SUDO template action: {cmd}") + try: + process_sudo_template_command(player, cmd, config) + executed.append(cmd) + results_seen.append((cmd, "template action executed")) + except Exception as e: + log.warning(f"SUDO template action failed: {e}") + results_seen.append((cmd, f"template action failed: {e}")) + time.sleep(0.15) + continue + resolved, is_safe = validate_command(cmd, online, player, config) if not is_safe: continue @@ -2468,8 +2850,53 @@ def process_sudo(player, prompt, config): result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) log.info(f"SUDO result: {result!r}") executed.append(resolved) + results_seen.append((resolved, str(result or ""))) time.sleep(0.2) + effective_hits = sum(1 for _, res in results_seen if _sudo_result_is_effective(res)) + ineffective = (len(executed) == 0) or (effective_hits == 0) + + # Generic failed-execution repair pass. + if ineffective: + retry_cmds = _repair_failed_sudo_commands(player, results_seen, config) + if retry_cmds: + log.warning(f"SUDO retry pipeline engaged: {retry_cmds}") + _send_private(player, "[SUDO] Initial command failed; retrying with repaired syntax.", config, "yellow") + for cmd in retry_cmds: + resolved, is_safe = validate_command(cmd, online, player, config) + if not is_safe: + continue + log.info(f"SUDO retry execute: {resolved}") + result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"SUDO retry result: {result!r}") + executed.append(resolved) + results_seen.append((resolved, str(result or ""))) + time.sleep(0.12) + + effective_hits = sum(1 for _, res in results_seen if _sudo_result_is_effective(res)) + ineffective = (len(executed) == 0) or (effective_hits == 0) + + # Adaptive fallback for destructive intent when output appears ineffective. + if _is_destructive_intent(prompt) and ineffective: + fallback_cmds = _build_destructive_fallback(player, config) + log.warning(f"SUDO destructive fallback engaged for prompt={prompt!r}: {fallback_cmds}") + _send_private(player, "[SUDO] Initial plan was weak; applying destructive fallback.", config, "yellow") + for cmd in fallback_cmds[:max_cmds]: + resolved, is_safe = validate_command(cmd, online, player, config) + if not is_safe: + continue + log.info(f"SUDO fallback execute: {resolved}") + result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"SUDO fallback result: {result!r}") + executed.append(resolved) + results_seen.append((resolved, str(result or ""))) + time.sleep(0.15) + + effective_hits = sum(1 for _, res in results_seen if _sudo_result_is_effective(res)) + ineffective = (len(executed) == 0) or (effective_hits == 0) + + _report_sudo_feedback(player, prompt, commands, results_seen, ineffective, config) + add_sudo_history(player, prompt, commands, executed) @@ -2586,6 +3013,7 @@ BIBLE_LINES = [ ("God watches over this server.", "yellow", False), ("Speak to him by typing in chat:", "white", False), (" pray ", "green", True), + (" bug_log ", "aqua", True), ("", "white", False), ("God is benevolent, but just.", "yellow", False), ("He hears every prayer — but answers as he sees fit.", "white", False), @@ -2595,6 +3023,7 @@ BIBLE_LINES = [ (" pray Lord, bless my journey through the mines.", "gray", False), (" pray Smite my enemy, for they have wronged me.", "gray", False), (" pray Forgive me, I have sinned against thy creations.", "gray", False), + (" bug_log creeper explosion desynced and killed me", "dark_aqua", False), ("", "white", False), ("Thou may only pray once every 20 seconds.", "red", False), ("Type \"bible\" in chat to see this again.", "gray", False), @@ -2783,6 +3212,24 @@ def main(): # Feed every line into the rolling log buffer add_log_event(line) + # bug logging trigger + matched = False + for pat in BUG_LOG_PATTERNS: + m = pat.search(line) + if m: + player = m.group(1) + description = (m.group(2) or "").strip() + log.info(f"BUG_LOG from {player}: {description or '(no description)'}") + try: + process_bug_log(player, description, config) + except Exception as e: + log.error(f"Error processing bug_log: {e}", exc_info=True) + matched = True + break + + if matched: + continue + # sudo translator matched = False for pat in SUDO_PATTERNS: