From 40b4da345a0f7e6933c2731fac49401d575d16bb Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 17 Mar 2026 19:53:32 -0400 Subject: [PATCH] Add retrieval-grounded sudo flow and execution feedback loop --- README.md | 10 ++ SESSION.md | 19 +++ langgraph_gateway.py | 324 +++++++++++++++++++++++++++++++++++- mc_aigod_paper.json | 4 + mc_aigod_paper.py | 338 +++++++++++++++++++++++++++++++++++++- mc_langgraph_gateway.json | 25 ++- 6 files changed, 705 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 334499f..23e3e72 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Gateway hardening currently included: - 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 +- Localized knowledge retrieval for tool mode: + - local corpus under `/var/lib/mc-langgraph-gateway/knowledge` + - indexed search + document snippet retrieval (`local.search` -> `local.read`) + - optional bootstrap download of Minecraft/Paper/WorldEdit docs at startup --- @@ -115,6 +119,8 @@ Info lookup mode via sudo: Lookup mode is information-only (wiki/web retrieval + optional justification), and does not execute game commands. +For normal `sudo` translation, the gateway now also runs localized retrieval before command generation, so the model can ground command synthesis in local indexed docs rather than relying only on prompt memory. + These trigger multi-command `fill/setblock/give` sequences near the player and are optimized for Paper performance. --- @@ -130,6 +136,10 @@ Important keys: - `sudo_max_commands`: default `12` (higher for build bursts) - `interventions_per_day`: default `24` - `first_login_benevolence_max_commands`: default `12` +- `bug_log_path`: default `/var/log/mc_aigod_paper_bug.log` +- `bug_log_service_lines`: recent AI action lines attached to each bug entry +- `tp_safety_enabled`: auto-wrap risky vertical teleports with fall protection +- `tp_safety_vertical_delta`: relative Y threshold for teleport safety wrapper --- diff --git a/SESSION.md b/SESSION.md index 8acf771..8d144dc 100644 --- a/SESSION.md +++ b/SESSION.md @@ -119,7 +119,26 @@ This section captures decisions and context accumulated across conversations wit - **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. +- **TNT retry syntax correction (2026-03-17):** retry expansion for malformed TNT count commands initially emitted invalid relative coords with `~+1`/`~+2`; corrected generator to use valid forms (`~1`, `~2`) so repaired TNT summons no longer fail with `Expected double`. +- **User preference (2026-03-17):** prioritize context/prompt refinement and better model grounding over adding more deterministic hardcoded action paths. +- **Context-first refinement pass (2026-03-17):** added sudo failure memory (`sudo_failures`) and anti-repeat context block (`RECENT FAILED SUDO PATTERNS`) into paper sudo translator input and gateway sudo context payload; prompt rules now explicitly forbid repeated failed shapes, old enchant NBT syntax, and `summon tnt ... ` style outputs. +- **Prompt safety clarifications (2026-03-17):** updated paper God/system prompts to enforce `effect give ...` syntax, `weather clear|rain|thunder` only, and avoid accidental high vertical teleports in benevolent outputs unless explicitly requested. +- **Sudo scope grounding (2026-03-17):** paper sudo prompts now explicitly keep target scope narrow (`me/my` -> requesting player), discouraging broad `@a` outputs unless explicitly requested. +- **Unresolved post-update issues (2026-03-17):** (1) AI template planner override can trigger on non-build sudo intents (example: `make me invisible` routed into `template search/pick/build`), and (2) sudo follow-up questions like `did that command ...?` may not return an informational answer unless phrased with explicit lookup prefixes. +- **Resolved sudo routing issues (2026-03-17):** tightened build-intent detection so template planner override only triggers for actual structure/template intents (no longer on generic `make me ...` phrasing), and broadened sudo info-mode detection to treat natural question-style prompts (e.g., `did that command ...?`) as lookup queries even without explicit `lookup/search/wiki` prefix. +- **Remaining issues after routing fix deploy (2026-03-17):** (1) sudo still outputs old bow enchant NBT (`bow{Enchantments:[...]}`) in some runs and fails with trailing-data parse error, and (2) God prayer outputs still occasionally include unnecessary teleport actions (`tp ~ ~5 ~`) that users report as unhelpful side effects. +- **Bugfix pass after in-game retest (2026-03-17):** added command-repair normalizers for (a) old bow enchant NBT to 1.21 component syntax (`minecraft:bow[enchantments={...}]`), and (b) legacy fire fill syntax (`... fire 0 replace air` -> `... minecraft:fire replace air`), plus dynamic sudo command budget scaling for TNT prompts based on requested quantity (capped by `sudo_tnt_max_commands`, default 80). +- **Lookup and benevolent TP context refinements (2026-03-17):** lookup mode now has local fallback answering for contextual gameplay questions (e.g., invisibility vs mobs) when retrieval returns no hits, and prompts/gateway guidance now explicitly discourage teleport use in helpful responses unless movement is explicitly requested. +- **Post-retest fixes (2026-03-17):** added execute-tail syntax repair so command fixers apply inside `execute ... run ...` payloads (fixes old bow NBT + fill fire variants emitted under execute wrappers), and added TNT quantity expansion from prompt count for summon-heavy intents (bounded by `sudo_tnt_max_commands`, default 80) when model output under-produces summons. +- **Retest outcome (2026-03-17 late):** bow repair now works in live runs (`give strongest bow` successfully converted old NBT to `minecraft:bow[enchantments={...}]` and delivered item). Remaining issues: fire-spread requests still often execute as no-op/invalid hybrid fill chains (`execute ... run fill ... fire ...` with mixed legacy args), and TNT intent can still collapse into single-command failure then destructive fallback (large `fill ... air` + few TNT) instead of honoring requested count semantics. - **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. +- **Bug-log triage (2026-03-17):** `bug_log` entry confirmed an unintended-feeling movement reward in prayer flow (`execute as slingshooter08 run tp slingshooter08 ~ ~10 ~`) during a build-oriented prayer; prioritize pray-path teleport safety guards and intent alignment. +- **Bug follow-up (2026-03-17):** second `bug_log` entry reported God feeling "too nice" after greedy follow-up prayer; prompt context updated to bias repeated greedy demands toward corrective responses (rebuke/debuff/symbolic punishment) instead of extra rewards. +- **Teleport safety guard (2026-03-17):** added execution-time TP safety wrapper in prayer/intervention path (`execute_response`) to auto-apply `slow_falling` + `resistance` before risky upward teleports, reducing accidental player death from unintended high drops. +- **Bug-log signal upgrade (2026-03-17):** `bug_log` now writes filtered raw server log lines (RCON thread noise removed) and includes recent AI action lines from `/var/log/mc_aigod_paper.log` for better root-cause visibility. +- **Unaddressed bug triage (2026-03-17):** third `bug_log` entry ("output not in correct format") maps to prayer execution using invalid command `weather storm` (RCON: Incorrect argument). Current validator allows `weather` prefix but lacks argument normalization (`storm` -> `rain`) and no retry/repair pass for pray-path commands. +- **Weather normalization fix (2026-03-17):** added explicit prompt/context rule and code normalizer mapping `weather storm|rainstorm|thunderstorm` -> `weather thunder` before validation/execution (paper + vanilla), then redeployed both services. +- **New bug cluster observed (2026-03-17):** additional paper `bug_log` entries report failed command outcomes around selector-based targets and legacy enchant syntax in sudo (`minecraft:bow{Enchantments:...}`), indicating retry/repair still needed for 1.21 enchantment conversion and selector reliability feedback. ### Infrastructure decisions diff --git a/langgraph_gateway.py b/langgraph_gateway.py index 429c0b0..5931aa0 100644 --- a/langgraph_gateway.py +++ b/langgraph_gateway.py @@ -13,6 +13,7 @@ Execution safety remains in mc_aigod_paper.py. """ import json +import hashlib import logging import os import re @@ -20,6 +21,8 @@ import sqlite3 import threading import time import uuid +from pathlib import Path +from urllib.parse import urlparse from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -76,12 +79,16 @@ class SessionState: _sessions: Dict[str, SessionState] = {} _sessions_lock = threading.Lock() +_kb_lock = threading.Lock() +_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []} +_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'} + COMMAND_PREFIXES_BY_MODE = { 'sudo': [ 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', 'kill ', 'summon ', 'tellraw ', 'worldborder ', 'fill ', 'setblock ', - 'clone ', + 'clone ', 'gamemode ', 'template ', ], 'god': [ 'give ', 'effect ', 'xp ', 'tp ', 'time ', 'weather ', 'execute ', @@ -106,6 +113,21 @@ def load_config() -> Dict[str, Any]: 'command_model': 'qwen3-coder:30b', 'tool_model': 'qwen2.5:1.5b', 'session_ttl_seconds': 21600, + 'knowledge_base_dir': '/var/lib/mc-langgraph-gateway/knowledge', + 'knowledge_index_file': '/var/lib/mc-langgraph-gateway/knowledge/index.json', + 'knowledge_auto_index_on_start': True, + 'knowledge_bootstrap_on_start': True, + 'knowledge_bootstrap_urls': [ + 'https://minecraft.wiki/w/Commands/fill', + 'https://minecraft.wiki/w/Commands/setblock', + 'https://minecraft.wiki/w/Commands/clone', + 'https://minecraft.wiki/w/Commands/summon', + 'https://minecraft.wiki/w/Commands/execute', + 'https://minecraft.wiki/w/TNT', + 'https://minecraft.wiki/w/Explosion', + 'https://minecraft.wiki/w/Tutorial:Worldedit', + ], + 'knowledge_max_doc_bytes': 200000, } @@ -114,6 +136,228 @@ DB_PATH = CFG.get('session_db_path', '/var/lib/mc-langgraph-gateway/sessions.db' _db_lock = threading.Lock() +def _kb_root() -> str: + root = str(CFG.get('knowledge_base_dir', '/var/lib/mc-langgraph-gateway/knowledge')).strip() + return root or '/var/lib/mc-langgraph-gateway/knowledge' + + +def _kb_index_path() -> str: + path = str(CFG.get('knowledge_index_file', '')).strip() + if path: + return path + return os.path.join(_kb_root(), 'index.json') + + +def _kb_tokenize(text: str) -> List[str]: + toks = re.findall(r'[a-z0-9_]{2,}', (text or '').lower()) + if not toks: + return [] + out: List[str] = [] + seen = set() + for t in toks: + if t in seen: + continue + seen.add(t) + out.append(t) + if len(out) >= 300: + break + return out + + +def _kb_html_to_text(html: str) -> str: + body = re.sub(r'(?is).*?', ' ', html or '') + body = re.sub(r'(?is).*?', ' ', body) + body = re.sub(r'(?is)<[^>]+>', ' ', body) + body = re.sub(r'\s+', ' ', body).strip() + return body + + +def _kb_slug(s: str) -> str: + n = re.sub(r'[^a-zA-Z0-9._-]+', '_', (s or '').strip()) + n = n.strip('._-') + return (n[:80] or 'doc').lower() + + +def _kb_fetch_url(url: str) -> Dict[str, Any]: + max_bytes = int(CFG.get('knowledge_max_doc_bytes', 200000)) + r = requests.get(url, timeout=25) + r.raise_for_status() + ct = (r.headers.get('content-type') or '').lower() + raw = r.content[:max_bytes] + if 'html' in ct: + text = _kb_html_to_text(raw.decode(errors='replace')) + else: + text = raw.decode(errors='replace') + title = '' + m = re.search(r'(?is)(.*?)', r.text if 'html' in ct else '') + if m: + title = re.sub(r'\s+', ' ', m.group(1)).strip() + return {'title': title, 'text': text} + + +def _kb_ingest_url(url: str) -> Dict[str, Any]: + parsed = urlparse(url) + host = (parsed.netloc or '').lower() + if host not in set(str(h).lower() for h in CFG.get('knowledge_allowed_hosts', [ + 'minecraft.wiki', 'www.minecraft.wiki', 'docs.papermc.io', 'intellectualsites.github.io', 'enginehub.org', 'worldedit.enginehub.org' + ])): + return {'ok': False, 'error': f'host not allowed: {host}'} + try: + fetched = _kb_fetch_url(url) + text = (fetched.get('text') or '').strip() + if len(text) < 80: + return {'ok': False, 'error': 'document too short'} + title = fetched.get('title') or os.path.basename(parsed.path) or host + root = Path(_kb_root()) + root.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha1(url.encode()).hexdigest()[:12] + fname = f"{_kb_slug(title)}_{digest}.md" + out = root / fname + out.write_text(f"# {title}\n\nSource: {url}\n\n{text}\n", encoding='utf-8') + return {'ok': True, 'path': str(out), 'source': url, 'title': title} + except Exception as e: + return {'ok': False, 'error': str(e)} + + +def _kb_build_index() -> Dict[str, Any]: + root = Path(_kb_root()) + root.mkdir(parents=True, exist_ok=True) + docs = [] + for p in root.rglob('*'): + if not p.is_file() or p.suffix.lower() not in _KB_ALLOWED_EXTS: + continue + try: + text = p.read_text(encoding='utf-8', errors='replace') + except Exception: + continue + title = p.name + m = re.search(r'^#\s+(.+)$', text, re.MULTILINE) + if m: + title = m.group(1).strip()[:120] + snippet = re.sub(r'\s+', ' ', text[:800]).strip() + tokens = _kb_tokenize(text) + rel = str(p.relative_to(root)) + doc_id = hashlib.sha1(rel.encode()).hexdigest()[:12] + docs.append({ + 'id': doc_id, + 'path': rel, + 'title': title, + 'snippet': snippet[:260], + 'tokens': tokens, + 'mtime': p.stat().st_mtime, + }) + + out = {'generated_at': time.time(), 'docs': docs} + idx = Path(_kb_index_path()) + idx.parent.mkdir(parents=True, exist_ok=True) + idx.write_text(json.dumps(out, ensure_ascii=True), encoding='utf-8') + with _kb_lock: + _kb_index_cache['loaded_at'] = time.time() + _kb_index_cache['docs'] = docs + return {'ok': True, 'count': len(docs), 'path': str(idx)} + + +def _kb_load_index(force: bool = False) -> List[Dict[str, Any]]: + with _kb_lock: + if _kb_index_cache.get('docs') and not force: + return list(_kb_index_cache['docs']) + idx = Path(_kb_index_path()) + if not idx.exists(): + _kb_build_index() + try: + data = json.loads(idx.read_text(encoding='utf-8')) + except Exception: + _kb_build_index() + data = json.loads(idx.read_text(encoding='utf-8')) + docs = data.get('docs') or [] + with _kb_lock: + _kb_index_cache['loaded_at'] = time.time() + _kb_index_cache['docs'] = docs + return docs + + +def _kb_bootstrap_if_needed() -> None: + if not bool(CFG.get('knowledge_bootstrap_on_start', True)): + return + root = Path(_kb_root()) + root.mkdir(parents=True, exist_ok=True) + existing = [p for p in root.rglob('*') if p.is_file() and p.suffix.lower() in _KB_ALLOWED_EXTS] + if existing: + return + urls = CFG.get('knowledge_bootstrap_urls', []) or [] + if not urls: + return + ok = 0 + for url in urls: + res = _kb_ingest_url(str(url)) + if res.get('ok'): + ok += 1 + log.info('knowledge bootstrap completed: %d/%d docs ingested', ok, len(urls)) + + +def _kb_search(query: str, limit: int = 5) -> List[Dict[str, Any]]: + docs = _kb_load_index() + q_tokens = set(_kb_tokenize(query)) + if not q_tokens: + return [] + scored = [] + q_lower = query.lower() + for d in docs: + tokens = set(d.get('tokens') or []) + overlap = len(q_tokens.intersection(tokens)) + if overlap <= 0: + continue + score = overlap + if q_lower in (d.get('title', '').lower()): + score += 3 + if q_lower in (d.get('snippet', '').lower()): + score += 1 + scored.append((score, d)) + scored.sort(key=lambda x: x[0], reverse=True) + out = [] + for _, d in scored[:max(1, limit)]: + out.append({ + 'doc_id': d.get('id'), + 'title': d.get('title'), + 'path': d.get('path'), + 'snippet': d.get('snippet'), + }) + return out + + +def _kb_read(doc_id: str, query: str = '') -> Dict[str, Any]: + docs = _kb_load_index() + hit = None + for d in docs: + if d.get('id') == doc_id: + hit = d + break + if not hit: + return {'ok': False, 'error': 'doc_id not found', 'results': []} + + full = Path(_kb_root()) / str(hit.get('path')) + if not full.exists(): + return {'ok': False, 'error': 'file missing', 'results': []} + text = full.read_text(encoding='utf-8', errors='replace') + q = (query or '').strip().lower() + if q and q in text.lower(): + idx = text.lower().find(q) + start = max(0, idx - 350) + end = min(len(text), idx + 650) + excerpt = text[start:end] + else: + excerpt = text[:1000] + return { + 'ok': True, + 'results': [{ + 'doc_id': doc_id, + 'title': hit.get('title'), + 'path': hit.get('path'), + 'text': re.sub(r'\s+', ' ', excerpt).strip(), + }], + } + + def _db_enabled() -> bool: return bool(CFG.get('session_persistence_enabled', True)) @@ -407,13 +651,35 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]: return {'ok': False, 'error': str(e), 'results': []} -def _tool_router(user_text: str, max_steps: int) -> List[Dict[str, Any]]: +def tool_local_search(query: str) -> Dict[str, Any]: + try: + rows = _kb_search(query, limit=5) + return {'ok': True, 'results': rows} + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def tool_local_read(doc_id: str, query: str = '') -> Dict[str, Any]: + try: + return _kb_read(doc_id, query) + except Exception as e: + return {'ok': False, 'error': str(e), 'results': []} + + +def _tool_router(user_text: str, max_steps: int, mode: str, context: Dict[str, Any]) -> 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 mode == 'sudo': + q = user_text + req = str((context or {}).get('request') or '').strip() + if req: + q = req + calls.append({'tool': 'local.search', 'query': q}) + if any(k in text for k in ['wiki', 'minecraft', 'item id', 'recipe', 'craft']): calls.append({'tool': 'minecraft.wiki_lookup', 'query': user_text}) @@ -433,7 +699,15 @@ def _commands_prompt(mode: str) -> str: '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.' + 'Use TOOL results as your source of truth. Do not invent command syntax not supported by retrieved context.\n' + 'Read context.sudo_failures and avoid repeating those exact failing patterns.\n' + 'Never use old enchantment NBT {Enchantments:[...]} syntax; use item[enchantments={...}] format.\n' + 'For TNT, never append a count to summon; use multiple summon commands instead.\n' + 'Keep target scope narrow: if request is about "me/my", do not use @a unless explicitly requested.\n' + 'You may output template workflow meta-commands: template search , template pick [name], template build .\n' + 'For build/make/create requests, prefer the template workflow instead of raw block-by-block commands.\n' + 'If request is ambiguous or unsupported, choose a closest valid in-game workaround and keep scope bounded.\n' + 'If still unsafe/unknown, return empty commands.' ) if mode == 'god_system': @@ -441,6 +715,7 @@ def _commands_prompt(mode: str) -> str: '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' + 'Use valid 1.21 syntax: effect give ..., and weather is clear/rain/thunder only.\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.' ) @@ -449,6 +724,9 @@ def _commands_prompt(mode: str) -> str: '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' + 'Use valid 1.21 syntax: effect give ..., and weather is clear/rain/thunder only.\n' + 'Avoid accidental lethal vertical teleports in benevolent responses unless explicitly requested.\n' + 'Do not use tp in helpful responses unless user explicitly asks for movement.\n' 'Balance benevolence and judgment based on context.\n' 'Use valid Minecraft command syntax only.' ) @@ -524,25 +802,53 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse: user_blob = f"message: {user_text}\ncontext: {context_json}" session.messages.append({'role': req.role, 'content': user_blob}) + + # Feedback-only messages update session state without running LLM/tools. + if bool((req.context or {}).get('feedback_only', False)): + session.messages.append({ + 'role': 'assistant', + 'content': json.dumps({'message': '', 'commands': []}, ensure_ascii=True) + }) + _db_upsert_session(session) + return MessageResponse(message=None, commands=[], tool_trace=[]) + _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))) + calls = _tool_router( + user_text, + max(0, min(req.max_tool_steps, 6)), + session.mode, + req.context or {}, + ) for c in calls: tool = c['tool'] - q = c['query'] + q = c.get('query', '') if tool == 'web.search': out = tool_web_search(q) elif tool == 'minecraft.wiki_lookup': out = tool_wiki_lookup(q) + elif tool == 'local.search': + out = tool_local_search(q) + elif tool == 'local.read': + out = tool_local_read(str(c.get('doc_id', '')), 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" + # localized retrieval hop: after index search, fetch one top document excerpt + if tool == 'local.search' and out.get('ok') and out.get('results') and len(tool_trace) < max(0, min(req.max_tool_steps, 6)): + top = out['results'][0] + doc_id = str(top.get('doc_id', '')) + if doc_id: + read_out = tool_local_read(doc_id, q) + tool_trace.append({'tool': 'local.read', 'input': doc_id, 'ok': read_out.get('ok', False), 'results_count': len(read_out.get('results', []))}) + tool_results_block += f"\nTOOL local.read doc_id={doc_id}\nRESULT={json.dumps(read_out, ensure_ascii=True)[:3000]}\n" + # Commands call cmd_messages = [ {'role': 'system', 'content': _commands_prompt(session.mode)}, @@ -618,4 +924,12 @@ def close_session(session_id: str): return {'closed': existed} +try: + _kb_bootstrap_if_needed() + if bool(CFG.get('knowledge_auto_index_on_start', True)): + meta = _kb_build_index() + log.info('knowledge index ready: %s docs=%s', meta.get('path'), meta.get('count')) +except Exception as e: + log.warning('knowledge bootstrap/index failed: %s', e) + _db_init() diff --git a/mc_aigod_paper.json b/mc_aigod_paper.json index 9b4a24e..60479b6 100644 --- a/mc_aigod_paper.json +++ b/mc_aigod_paper.json @@ -41,6 +41,10 @@ "bug_log_path": "/var/log/mc_aigod_paper_bug.log", "bug_log_event_lines": 40, "bug_log_raw_lines": 120, + "bug_log_service_lines": 30, + "tp_safety_enabled": true, + "tp_safety_vertical_delta": 8, + "tp_safety_absolute_y": 120, "sudo_build_max_commands": 6, "sudo_deterministic_build_templates": false, "tp_border_guard_enabled": true, diff --git a/mc_aigod_paper.py b/mc_aigod_paper.py index e5fe1c1..4a453d0 100644 --- a/mc_aigod_paper.py +++ b/mc_aigod_paper.py @@ -76,6 +76,10 @@ first_login_seen = set() SUDO_HISTORY_SIZE = 10 sudo_history: deque = deque() # entries: (ts, player, prompt, translated_cmds, executed_cmds) +# Sudo failure memory — last N failed command/result pairs, for anti-repeat context. +SUDO_FAILURE_SIZE = 20 +sudo_failures: deque = deque() # entries: (ts, player, command, error) + _memory_lock = threading.Lock() # Gateway client session mapping (player+mode -> session_id) @@ -980,19 +984,71 @@ def get_last_sudo_executed_command(player: str) -> str: return "" +def add_sudo_failure(player: str, command: str, error: str): + """Record a failed sudo command/result so future prompts can avoid repeats.""" + c = (command or "").strip()[:220] + e = re.sub(r'\s+', ' ', (error or "")).strip()[:220] + if not c or not e: + return + with _memory_lock: + sudo_failures.append((time.time(), player, c, e)) + while len(sudo_failures) > SUDO_FAILURE_SIZE: + sudo_failures.popleft() + + +def get_sudo_failures_block(player: str = "") -> str: + """Return recent failed sudo commands as anti-pattern context.""" + with _memory_lock: + entries = list(sudo_failures) + if player: + entries = [e for e in entries if e[1] == player] + if not entries: + return "" + now = time.time() + lines = [] + for ts, p, cmd, err in entries[-8:]: + mins = int((now - ts) / 60) + lines.append(f" [{mins}m ago] {p}: {cmd} -> {err}") + return "\n=== RECENT FAILED SUDO PATTERNS ===\n" + "\n".join(lines) + "\n" + + 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) + lines = deque(maxlen=max_lines * 4) + noise = re.compile(r'RCON (?:Listener|Client)|Thread RCON Client') try: with open(log_path, 'r', encoding='utf-8', errors='replace') as f: for line in f: - lines.append(line.rstrip('\n')) + clean = line.rstrip('\n') + if noise.search(clean): + continue + lines.append(clean) except Exception as e: return [f""] - return list(lines) + return list(lines)[-max_lines:] + + +def _service_log_path(config) -> str: + return config.get("service_log_path", "/var/log/mc_aigod_paper.log") + + +def _read_recent_service_action_lines(path: str, max_lines: int) -> list: + lines = deque(maxlen=max_lines * 8) + keep = re.compile( + r'Prayer from|SUDO from|Executing RCON:|SUDO execute:|RCON result:|SUDO result:|BUG_LOG from' + ) + try: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + for line in f: + clean = line.rstrip('\n') + if keep.search(clean): + lines.append(clean) + except Exception as e: + return [f""] + return list(lines)[-max_lines:] def _format_recent_event_lines(max_events: int) -> list: @@ -1012,9 +1068,11 @@ def process_bug_log(player: str, description: str, config): event_count = int(config.get("bug_log_event_lines", 40)) raw_count = int(config.get("bug_log_raw_lines", 120)) + service_count = int(config.get("bug_log_service_lines", 30)) recent_events = _format_recent_event_lines(event_count) raw_lines = _read_recent_raw_log_lines(config.get("log_path", ""), raw_count) + service_lines = _read_recent_service_action_lines(_service_log_path(config), service_count) bug_path = _bug_log_path(config) timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') @@ -1033,6 +1091,11 @@ def process_bug_log(player: str, description: str, config): bf.write("\n".join(recent_events) + "\n") else: bf.write("(no recent in-memory events captured)\n") + bf.write("\n-- RECENT AI ACTIONS (SERVICE LOG) --\n") + if service_lines: + bf.write("\n".join(service_lines) + "\n") + else: + bf.write("(no AI service lines available)\n") bf.write("\n-- RECENT RAW SERVER LOG LINES --\n") if raw_lines: bf.write("\n".join(raw_lines) + "\n") @@ -1382,6 +1445,8 @@ MOVEMENT: NEVER use: tp minecraft:the_nether (this is wrong syntax) WORLD/ENVIRONMENT (affects all players): + SYNTAX: weather [duration] + NOTE: 'storm' is invalid; if intent says storm/rainstorm/thunderstorm use thunder. time set day time set night weather clear 6000 @@ -1443,9 +1508,12 @@ def build_system_prompt(config): "- Do not ask a player to gather materials they clearly don't have access to or that are rare relative to their current situation.\n" "- For give commands: use any valid Minecraft 1.21 item ID following the Item Naming Rules. Do not guess item IDs — consult the naming rules and common IDs list.\n" "- For all other commands: only use forms shown in the Command Palette. Do not invent new command types.\n" + "- Weather values are only clear, rain, or thunder. If you mean storm/rainstorm/thunderstorm, output thunder.\n" "- Reward humble, genuine prayers. Punish hubris, blasphemy, or naked greed.\n" + "- Repeated greedy demands after recent gifts should usually receive correction (rebuke, debuff, or symbolic punishment), not more rewards.\n" "- Powerful rewards (netherite, enchanted_golden_apple, totem) must be rare.\n" "- kill {target} is reserved for extreme blasphemy only.\n" + "- Avoid lethal accidents from vertical teleports. If using a high upward teleport as punishment or spectacle, pair it with slow_falling or resistance unless explicit execution is intended.\n" "- When angered, chain commands: thunder + lightning + debuffs = divine wrath.\n\n" "=== COMMAND PALETTE ===\n" f"{COMMAND_PALETTE}\n" @@ -1566,6 +1634,10 @@ COMMANDS_SYSTEM_PROMPT = ( "- kill is reserved for extreme blasphemy only.\n" "- For give: syntax is always give minecraft: \n" "- Count comes LAST. Namespace prefix minecraft: is REQUIRED.\n" + "- For effects: use 'effect give minecraft: '.\n" + "- For weather use only clear/rain/thunder (NOT storm).\n" + "- Avoid accidental lethal movement in benevolent responses; do not launch players high unless explicitly asked.\n" + "- Do not use tp in helpful/benevolent responses unless the player explicitly requests movement/teleportation.\n" "- Beds: white_bed not bed. Logs: oak_log not log. Wool: white_wool not wool.\n" "- Chain commands for dramatic effect: thunder + lightning + blindness = wrath.\n\n" + "=== COMMAND PALETTE ===\n" @@ -1588,8 +1660,14 @@ def build_sudo_commands_system_prompt(config=None) -> str: "- 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" + "- Do not broaden scope to @a/@e unless the user explicitly asks for all players/entities.\n" "- You will receive LAST 10 SUDO ACTIONS. Use them for continuity and corrections when the player says previous output was wrong.\n" + "- You will also receive RECENT FAILED SUDO PATTERNS. Do not repeat those broken shapes.\n" "- For give syntax: give minecraft: (count LAST, namespace required)\n" + "- For effect syntax: effect give minecraft: [hideParticles]\n" + "- For summon tnt: summon minecraft:tnt (NO trailing count number).\n" + " If quantity is requested, output multiple summon commands.\n" + "- Never use old NBT enchant syntax like {Enchantments:[...]}; use item[enchantments={...}] only.\n" "- Return commands only. No commentary.\n" "- For build requests, prefer template workflow in one response when possible:\n" " template search \n" @@ -1683,6 +1761,7 @@ def build_message_system_prompt(config) -> str: "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, with occasional godlike sarcasm and irony. Any length is fine.\n" + "For punishments, prefer fear and humiliation over accidental instant death unless destruction is explicitly intended.\n" ) lore = config.get("god_lore", "") if lore: @@ -2070,6 +2149,53 @@ def _tp_inside_worldborder(cmd: str, config) -> bool: return abs(x - cx) <= limit and abs(z - cz) <= limit + +def _tp_safety_enabled(config) -> bool: + return bool(config.get("tp_safety_enabled", True)) + + +def _extract_tp_target_y(cmd: str): + m = re.search(r'\btp\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)', cmd) + if not m: + return None, None + return m.group(1), m.group(3) + + +def _needs_vertical_tp_safety(resolved_cmd: str, config) -> tuple: + target, ytok = _extract_tp_target_y(resolved_cmd) + if not target or not ytok: + return False, "" + + risky_delta = float(config.get("tp_safety_vertical_delta", 8.0)) + risky_abs_y = float(config.get("tp_safety_absolute_y", 120.0)) + + if ytok.startswith("~"): + offs = ytok[1:].strip() + dy = float(offs) if offs else 0.0 + if dy >= risky_delta: + return True, target + return False, target + + try: + abs_y = float(ytok) + if abs_y >= risky_abs_y: + return True, target + except Exception: + pass + return False, target + + +def _tp_safety_prefix_commands(resolved_cmd: str, config) -> list: + if not _tp_safety_enabled(config): + return [] + needs, target = _needs_vertical_tp_safety(resolved_cmd, config) + if not needs or not target: + return [] + return [ + f"effect give {target} minecraft:slow_falling 20 0", + f"effect give {target} minecraft:resistance 8 1", + ] + def fix_give_command(cmd: str) -> str: """ Correct common LLM give command mistakes: @@ -2167,6 +2293,80 @@ def fix_gamemode_command(cmd: str, fallback_player: str) -> str: log.warning(f"Fixed gamemode syntax: '{cmd}' -> '{fixed}'") return fixed + +def fix_weather_command(cmd: str) -> str: + """Normalize weather synonyms to valid Minecraft literals.""" + raw = (cmd or "").strip() + fixed = re.sub(r'\bweather\s+(storm|rainstorm|thunderstorm)\b', 'weather thunder', raw, flags=re.IGNORECASE) + if fixed != raw: + log.warning(f"Fixed weather syntax: '{cmd}' -> '{fixed}'") + return fixed + + +def fix_fill_fire_command(cmd: str) -> str: + """Fix legacy fill syntax like `... fire 0 replace air` for 1.21.""" + raw = (cmd or "").strip() + m = re.match(r'^(fill\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+)(minecraft:)?(fire|soul_fire)\s+0\s+replace\s+air$', raw, flags=re.IGNORECASE) + if not m: + return raw + prefix, ns, block = m.groups() + block_id = f"minecraft:{block.lower()}" if not ns else f"{ns.lower()}{block.lower()}" + fixed = f"{prefix}{block_id} replace air" + log.warning(f"Fixed fill fire syntax: '{cmd}' -> '{fixed}'") + return fixed + + +def fix_bow_enchant_syntax(cmd: str) -> str: + """Rewrite old bow Enchantments NBT to 1.21 component format.""" + raw = (cmd or "").strip() + if "Enchantments:[" not in raw or "bow{" not in raw: + return raw + m = re.match(r'^(give\s+\S+\s+)(minecraft:)?bow\{Enchantments:\[(.+)\]\}\s+(\d+)$', raw) + if not m: + return raw + pre, _, body, count = m.groups() + ench = {} + for eid, lvl in re.findall(r'id:?\"?([a-z_]+)\"?,\s*lvl:([0-9]+)s?', body): + ench[eid] = lvl + if not ench: + return raw + ench_part = ",".join(f"{k}:{v}" for k, v in ench.items()) + fixed = f"{pre}minecraft:bow[enchantments={{{ench_part}}}] {count}" + log.warning(f"Fixed bow enchant syntax: '{cmd}' -> '{fixed}'") + return fixed + + +def _repair_execute_tail(cmd: str, fallback_player: str) -> str: + """Apply syntax repairers to `execute ... run ` payloads.""" + raw = (cmd or "").strip() + tail = raw + stack = [] + # Peel execute wrappers up to a small depth. + for _ in range(4): + if not tail.startswith("execute "): + break + marker = " run " + idx = tail.find(marker) + if idx < 0: + break + stack.append(tail[: idx + len(marker)]) + tail = tail[idx + len(marker):].strip() + + fixed_tail = tail + fixed_tail = fix_give_command(fixed_tail) + fixed_tail = fix_effect_command(fixed_tail) + fixed_tail = fix_gamemode_command(fixed_tail, fallback_player) + fixed_tail = fix_weather_command(fixed_tail) + fixed_tail = fix_fill_fire_command(fixed_tail) + fixed_tail = fix_bow_enchant_syntax(fixed_tail) + + if stack: + rebuilt = "".join(stack) + fixed_tail + if rebuilt != raw: + log.warning(f"Fixed execute-tail syntax: '{raw}' -> '{rebuilt}'") + return rebuilt + return raw + 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) @@ -2176,6 +2376,10 @@ def validate_command(cmd, online_players, fallback_player, config=None): resolved = fix_give_command(resolved) resolved = fix_effect_command(resolved) resolved = fix_gamemode_command(resolved, fallback_player) + resolved = fix_weather_command(resolved) + resolved = fix_fill_fire_command(resolved) + resolved = fix_bow_enchant_syntax(resolved) + resolved = _repair_execute_tail(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): @@ -2344,9 +2548,9 @@ def _repair_failed_sudo_commands(player: str, results_seen: list, config) -> lis 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}" + xx = f"~{dx}" if dx < 0 else f"~{dx}" if z.startswith("~") and dz != 0: - zz = f"~{dz:+d}" + zz = f"~{dz}" if dz < 0 else f"~{dz}" out.append(f"{prefix}summon minecraft:tnt {xx} {y} {zz}") if len(out) >= max_retry: return out @@ -2363,6 +2567,53 @@ def _repair_failed_sudo_commands(player: str, results_seen: list, config) -> lis return out[:max_retry] + +def _expand_tnt_commands_from_prompt(commands: list, prompt: str, player: str, config) -> list: + """If user asked for many TNT and model returned too few summons, expand boundedly.""" + p = (prompt or "").lower() + if "tnt" not in p: + return commands + nums = [int(n) for n in re.findall(r'\b(\d{1,3})\b', p)] + if not nums: + return commands + requested = max(nums) + cap = int(config.get("sudo_tnt_max_commands", 80)) + target = max(1, min(requested, cap)) + if len(commands) >= target: + return commands + + summons = [c for c in commands if "summon" in c and "tnt" in c] + if not summons: + return commands + + base = summons[0] + prefix = "" + body = base + m_pref = re.match(rf'^(execute\s+at\s+{re.escape(player)}\s+run\s+)(.+)$', base) + 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+\{.*\})?$', body) + if not m: + return commands + x, y, z = m.groups() + + expanded = [] + for i in range(target): + dx = (i % 9) - 4 + dz = (i // 9) - 4 + xx = x + zz = z + if x.startswith("~"): + xx = "~" if dx == 0 else f"~{dx}" + if z.startswith("~"): + zz = "~" if dz == 0 else f"~{dz}" + expanded.append(f"{prefix}summon minecraft:tnt {xx} {y} {zz}") + + log.warning(f"Expanded TNT commands from {len(commands)} to {len(expanded)} (requested={requested}, cap={cap})") + return expanded + def execute_response(response, context, config, praying_player=None): message = response.get("message") or "" commands = response.get("commands") or [] @@ -2421,6 +2672,17 @@ def execute_response(response, context, config, praying_player=None): resolved, is_safe = validate_command(cmd, context["online_players"], fallback, config) if not is_safe: continue + + safety_prefix = _tp_safety_prefix_commands(resolved, config) + for scmd in safety_prefix: + sresolved, safe_ok = validate_command(scmd, context["online_players"], fallback, config) + if not safe_ok: + continue + log.info(f"Executing RCON: {sresolved}") + sresult = rcon(sresolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) + log.info(f"RCON result: {sresult!r}") + time.sleep(0.15) + log.info(f"Executing RCON: {resolved}") result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) log.info(f"RCON result: {result!r}") @@ -2587,10 +2849,33 @@ def process_sudo(player, prompt, config): if process_sudo_template_command(player, prompt, config): return + def _looks_like_lookup_question(text: str) -> bool: + t = (text or "").strip().lower() + if not t: + return False + if t.endswith("?"): + return True + q_starts = ( + "what ", "why ", "how ", "did ", "does ", "is ", "are ", + "can ", "could ", "should ", "would ", "where ", "when ", + ) + if t.startswith(q_starts): + return True + if re.search(r'\b(that command|last command|did that|does that)\b', t): + return True + return False + + def _local_lookup_fallback_answer(query: str, ref_cmd: str) -> str: + q = (query or "").lower() + rc = (ref_cmd or "").lower() + if "invisible" in q and "mob" in q and "invisibility" in rc: + return "Invisibility greatly reduces mob detection, but it does not make you perfectly undetectable at close range or while making noise/actions." + 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): + if low.startswith(lookup_prefixes) or _looks_like_lookup_question(prompt): query = prompt.strip() if low.startswith("lookup "): query = prompt[len("lookup "):].strip() @@ -2614,6 +2899,7 @@ def process_sudo(player, prompt, config): wiki_rows = _info_lookup_wiki(lookup_query) web_rows = _info_lookup_web(lookup_query) + gateway_msg = "" if wiki_rows: _send_private(player, "minecraft.wiki:", config, "dark_aqua") @@ -2647,6 +2933,7 @@ def process_sudo(player, prompt, config): msg = (out.get("message") or "").strip() trace = out.get("tool_trace") or [] if msg: + gateway_msg = msg _send_private(player, "justification:", config, "dark_aqua") for ln in re.split(r"\n+", msg)[:3]: ln = ln.strip() @@ -2659,9 +2946,14 @@ def process_sudo(player, prompt, config): 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") + if not wiki_rows and not web_rows and not gateway_msg: + fb = _local_lookup_fallback_answer(lookup_query, last_cmd) + if fb: + _send_private(player, f"- {fb}", config, "gray") + else: + _send_private(player, "No lookup results found.", config, "yellow") except Exception as e: + log.warning(f"SUDO lookup failed for query={lookup_query!r}: {e}") _send_private(player, f"[SUDO-LOOKUP] Failed: {e}", config, "red") return @@ -2717,6 +3009,7 @@ def process_sudo(player, prompt, config): + (positions_block + "\n" if positions_block else "") + f"Natural language request: {prompt}\n" + get_sudo_history_block() + + get_sudo_failures_block(player) ) command_model = config.get("command_model", config["model"]) @@ -2771,6 +3064,7 @@ def process_sudo(player, prompt, config): "request": prompt, "online_players": online, "sudo_history": get_sudo_history_block(), + "sudo_failures": get_sudo_failures_block(player), "mode": "sudo", }, config=config, @@ -2792,7 +3086,22 @@ def process_sudo(player, prompt, config): ) return - build_intent = any(prompt.lower().strip().startswith(x) for x in ("build ", "make ", "create ")) + def _is_build_intent(text: str) -> bool: + t = (text or "").strip().lower() + if t.startswith("build ") or t.startswith("create "): + return True + if "schem" in t or "schematic" in t or "template" in t: + return True + if t.startswith("make "): + build_nouns = ( + "house", "base", "tower", "wall", "castle", "bridge", "barn", + "bar", "shop", "village", "room", "road", "farm", "portal", + "structure", "schem", "schematic", "template", "statue", "arena", + ) + return any(n in t for n in build_nouns) + return False + + build_intent = _is_build_intent(prompt) 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: @@ -2807,11 +3116,18 @@ def process_sudo(player, prompt, config): 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))) + if "tnt" in low_prompt: + nums = re.findall(r'\b(\d{1,3})\b', low_prompt) + if nums: + requested = max(int(n) for n in nums) + cap = int(config.get("sudo_tnt_max_commands", 80)) + max_cmds = max(max_cmds, min(requested, cap)) commands = [ _normalize_sudo_command_shape(c, player) for c in commands[:max_cmds] ] commands = [c for c in commands if c] + commands = _expand_tnt_commands_from_prompt(commands, prompt, player, config) if not commands: add_sudo_history(player, prompt, [], []) @@ -2895,6 +3211,10 @@ def process_sudo(player, prompt, config): effective_hits = sum(1 for _, res in results_seen if _sudo_result_is_effective(res)) ineffective = (len(executed) == 0) or (effective_hits == 0) + for cmd, res in results_seen: + if not _sudo_result_is_effective(res): + add_sudo_failure(player, cmd, res) + _report_sudo_feedback(player, prompt, commands, results_seen, ineffective, config) add_sudo_history(player, prompt, commands, executed) diff --git a/mc_langgraph_gateway.json b/mc_langgraph_gateway.json index f14b6c8..11272c1 100644 --- a/mc_langgraph_gateway.json +++ b/mc_langgraph_gateway.json @@ -5,5 +5,28 @@ "tool_model": "qwen2.5:1.5b", "session_ttl_seconds": 21600, "session_persistence_enabled": true, - "session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db" + "session_db_path": "/var/lib/mc-langgraph-gateway/sessions.db", + "knowledge_base_dir": "/var/lib/mc-langgraph-gateway/knowledge", + "knowledge_index_file": "/var/lib/mc-langgraph-gateway/knowledge/index.json", + "knowledge_auto_index_on_start": true, + "knowledge_bootstrap_on_start": true, + "knowledge_max_doc_bytes": 200000, + "knowledge_allowed_hosts": [ + "minecraft.wiki", + "www.minecraft.wiki", + "docs.papermc.io", + "intellectualsites.github.io", + "enginehub.org", + "worldedit.enginehub.org" + ], + "knowledge_bootstrap_urls": [ + "https://minecraft.wiki/w/Commands/fill", + "https://minecraft.wiki/w/Commands/setblock", + "https://minecraft.wiki/w/Commands/clone", + "https://minecraft.wiki/w/Commands/summon", + "https://minecraft.wiki/w/Commands/execute", + "https://minecraft.wiki/w/TNT", + "https://minecraft.wiki/w/Explosion", + "https://minecraft.wiki/w/Tutorial:Worldedit" + ] }