Semver rename + single-call gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-20 21:37:37 -04:00
parent f10e901fe0
commit f243384d4e
2 changed files with 317 additions and 35 deletions
+272 -4
View File
@@ -17,7 +17,9 @@ import hashlib
import logging
import os
import re
import socket
import sqlite3
import struct
import threading
import time
import uuid
@@ -83,6 +85,45 @@ _kb_lock = threading.Lock()
_kb_index_cache: Dict[str, Any] = {'loaded_at': 0.0, 'docs': []}
_KB_ALLOWED_EXTS = {'.md', '.txt', '.json'}
# ---------------------------------------------------------------------------
# RCON world observation helper
# ---------------------------------------------------------------------------
def _rcon_query(cmd: str, host: str = '127.0.0.1', port: int = 25577,
password: str = 'REDACTED_RCON', timeout: float = 5.0) -> str:
"""Send a single RCON command and return the response text."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect((host, int(port)))
def pkt(req_id: int, pkt_type: int, payload: str) -> bytes:
p = payload.encode('utf-8') + b'\x00\x00'
return struct.pack('<iii', len(p) + 8, req_id, pkt_type) + p
# Authenticate (type 3)
s.sendall(pkt(1, 3, password))
time.sleep(0.15)
s.recv(4096)
# Send command (type 2)
s.sendall(pkt(2, 2, cmd))
time.sleep(0.2)
r = s.recv(4096)
return r[12:-2].decode('utf-8', errors='replace')
except Exception as e:
return f'RCON error: {e}'
finally:
s.close()
def _rcon_cfg() -> Dict[str, Any]:
"""Return RCON connection params from config."""
return {
'host': str(CFG.get('rcon_host', '127.0.0.1')),
'port': int(CFG.get('rcon_port', 25577)),
'password': str(CFG.get('rcon_password', 'REDACTED_RCON')),
}
COMMAND_PREFIXES_BY_MODE = {
'sudo': [
@@ -651,6 +692,163 @@ def tool_wiki_lookup(query: str) -> Dict[str, Any]:
return {'ok': False, 'error': str(e), 'results': []}
# ---------------------------------------------------------------------------
# World observation tools (RCON-based)
# ---------------------------------------------------------------------------
_ENTITY_TYPES_SCAN = [
'zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch',
'phantom', 'drowned', 'husk', 'stray', 'pillager', 'vindicator',
'cow', 'pig', 'sheep', 'chicken', 'horse', 'wolf', 'cat', 'villager',
'iron_golem', 'snow_golem', 'bee', 'fox', 'rabbit', 'squid',
'bat', 'parrot', 'turtle', 'dolphin', 'axolotl', 'goat', 'frog',
'allay', 'sniffer', 'camel', 'armadillo', 'breeze', 'bogged',
'item', 'experience_orb', 'armor_stand', 'minecart', 'boat', 'tnt',
]
def _parse_pos(rcon_output: str) -> Optional[List[float]]:
"""Parse position from 'data get entity <p> Pos' RCON output."""
m = re.search(r'\[(-?[\d.]+)d,\s*(-?[\d.]+)d,\s*(-?[\d.]+)d\]', rcon_output)
if m:
return [float(m.group(1)), float(m.group(2)), float(m.group(3))]
return None
def tool_world_player_info(player: str) -> Dict[str, Any]:
"""Get player position, health, gamemode, and inventory summary."""
rc = _rcon_cfg()
try:
pos_raw = _rcon_query(f'data get entity {player} Pos', **rc)
pos = _parse_pos(pos_raw)
health_raw = _rcon_query(f'data get entity {player} Health', **rc)
health_m = re.search(r'([\d.]+)f', health_raw)
health = float(health_m.group(1)) if health_m else None
gm_raw = _rcon_query(f'data get entity {player} playerGameType', **rc)
gm_m = re.search(r'(\d+)', gm_raw)
gamemode_map = {0: 'survival', 1: 'creative', 2: 'adventure', 3: 'spectator'}
gamemode = gamemode_map.get(int(gm_m.group(1)), 'unknown') if gm_m else None
inv_raw = _rcon_query(f'data get entity {player} Inventory', **rc)
# Count inventory items (each item in the list is an entry)
inv_count = inv_raw.count('{') if 'entity data' in inv_raw.lower() else 0
return {
'ok': True,
'results': [{
'player': player,
'position': {'x': pos[0], 'y': pos[1], 'z': pos[2]} if pos else None,
'health': health,
'max_health': 20.0,
'gamemode': gamemode,
'inventory_items': inv_count,
}],
}
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def tool_world_nearby_entities(player: str, radius: int = 30) -> Dict[str, Any]:
"""Scan for entity types near a player within given radius."""
rc = _rcon_cfg()
radius = min(max(radius, 5), 60) # clamp to 5-60
try:
pos_raw = _rcon_query(f'data get entity {player} Pos', **rc)
pos = _parse_pos(pos_raw)
if not pos:
return {'ok': False, 'error': 'player not found or offline', 'results': []}
x, y, z = int(pos[0]), int(pos[1]), int(pos[2])
found = []
for etype in _ENTITY_TYPES_SCAN:
r = _rcon_query(
f'execute if entity @e[x={x},y={y},z={z},distance=..{radius},type=minecraft:{etype}]',
**rc
)
if 'passed' in r.lower():
count_m = re.search(r'Count:\s*(\d+)', r)
count = int(count_m.group(1)) if count_m else 1
found.append({'type': etype, 'count': count})
return {
'ok': True,
'results': [{
'player': player,
'scan_center': {'x': x, 'y': y, 'z': z},
'radius': radius,
'entities': found,
'total': sum(e['count'] for e in found),
}],
}
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def tool_world_check_block(x: int, y: int, z: int, block_type: str) -> Dict[str, Any]:
"""Check if a specific block type exists at coordinates."""
rc = _rcon_cfg()
try:
if not block_type.startswith('minecraft:'):
block_type = f'minecraft:{block_type}'
r = _rcon_query(f'execute if block {x} {y} {z} {block_type}', **rc)
return {
'ok': True,
'results': [{
'position': {'x': x, 'y': y, 'z': z},
'block_type': block_type,
'matches': 'passed' in r.lower(),
}],
}
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def tool_world_server_state() -> Dict[str, Any]:
"""Get server-level state: players, time, worldborder, difficulty."""
rc = _rcon_cfg()
try:
players_raw = _rcon_query('list', **rc)
time_raw = _rcon_query('time query daytime', **rc)
border_raw = _rcon_query('worldborder get', **rc)
diff_raw = _rcon_query('difficulty', **rc)
# Parse player list
players = []
m = re.search(r'online:\s*(.*)', players_raw)
if m and m.group(1).strip():
players = [p.strip() for p in m.group(1).split(',') if p.strip()]
count_m = re.search(r'(\d+) of a max of (\d+)', players_raw)
count = int(count_m.group(1)) if count_m else len(players)
max_players = int(count_m.group(2)) if count_m else 20
# Parse time
time_m = re.search(r'(\d+)', time_raw)
ticks = int(time_m.group(1)) if time_m else 0
# Convert to approximate in-game time (0=6:00, 6000=noon, 12000=18:00, 18000=midnight)
hours = ((ticks + 6000) % 24000) // 1000
is_night = ticks >= 13000 or ticks < 0
# Parse worldborder
border_m = re.search(r'(\d+)', border_raw)
border = int(border_m.group(1)) if border_m else None
# Parse difficulty
diff_m = re.search(r'difficulty is (\w+)', diff_raw)
difficulty = diff_m.group(1) if diff_m else 'unknown'
return {
'ok': True,
'results': [{
'players_online': players,
'player_count': count,
'max_players': max_players,
'time_ticks': ticks,
'approx_hour': hours,
'is_night': is_night,
'worldborder_width': border,
'difficulty': difficulty,
}],
}
except Exception as e:
return {'ok': False, 'error': str(e), 'results': []}
def tool_local_search(query: str) -> Dict[str, Any]:
try:
rows = _kb_search(query, limit=5)
@@ -673,6 +871,33 @@ def _tool_router(user_text: str, max_steps: int, mode: str, context: Dict[str, A
if max_steps <= 0:
return calls
# --- World observation tools (RCON-based) ---
# In sudo mode, get player info for position-aware command generation
world_enabled = bool(CFG.get('world_observation_enabled', True))
if world_enabled and mode == 'sudo':
player = str((context or {}).get('player') or '').strip()
if not player:
# Try to extract player from server_state context
ss = (context or {}).get('server_state') or {}
players = ss.get('online_players') or []
if players:
player = players[0] if len(players) == 1 else ''
if player:
# Always get player info for sudo -- position is critical for build/fill/tp commands
calls.append({'tool': 'world.player_info', 'player': player})
# Scan nearby entities if the request involves mobs, entities, or environmental awareness
if any(k in text for k in [
'mob', 'monster', 'entity', 'creature', 'animal', 'kill', 'clear',
'around', 'nearby', 'surround', 'area', 'here', 'near me',
'spawn', 'summon', 'destroy', 'nuke', 'tnt', 'protect', 'safe',
]):
calls.append({'tool': 'world.nearby_entities', 'player': player, 'radius': 30})
# In god/god_system mode, get server state for contextual awareness
if world_enabled and mode in ('god', 'god_system'):
calls.append({'tool': 'world.server_state'})
# --- Knowledge tools ---
if mode == 'sudo':
q = user_text
req = str((context or {}).get('request') or '').strip()
@@ -707,7 +932,10 @@ def _commands_prompt(mode: str) -> str:
'You may output template workflow meta-commands: template search <query>, template pick <n> [name], template build <name>.\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 still unsafe/unknown, return empty commands.\n'
'WORLD STATE: If world.player_info or world.nearby_entities tool results are present, use the player\'s '
'actual coordinates for fill/setblock/tp commands instead of ~ ~ ~ relative coords when absolute positioning '
'is more reliable. Use nearby entity info to make contextually aware decisions.'
)
if mode == 'god_system':
@@ -827,6 +1055,7 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
for c in calls:
tool = c['tool']
q = c.get('query', '')
tool_input_desc = q # default description for trace
if tool == 'web.search':
out = tool_web_search(q)
elif tool == 'minecraft.wiki_lookup':
@@ -835,10 +1064,27 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
out = tool_local_search(q)
elif tool == 'local.read':
out = tool_local_read(str(c.get('doc_id', '')), q)
elif tool == 'world.player_info':
p = str(c.get('player', ''))
tool_input_desc = p
out = tool_world_player_info(p)
elif tool == 'world.nearby_entities':
p = str(c.get('player', ''))
r = int(c.get('radius', 30))
tool_input_desc = f'{p} radius={r}'
out = tool_world_nearby_entities(p, r)
elif tool == 'world.check_block':
bx, by, bz = int(c.get('x', 0)), int(c.get('y', 0)), int(c.get('z', 0))
bt = str(c.get('block_type', ''))
tool_input_desc = f'{bx},{by},{bz} {bt}'
out = tool_world_check_block(bx, by, bz, bt)
elif tool == 'world.server_state':
tool_input_desc = 'server'
out = tool_world_server_state()
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"
tool_trace.append({'tool': tool, 'input': tool_input_desc, 'ok': out.get('ok', False), 'results_count': len(out.get('results', []))})
tool_results_block += f"\nTOOL {tool} query={tool_input_desc}\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)):
@@ -849,7 +1095,29 @@ def run_pipeline(session: SessionState, req: MessageRequest) -> MessageResponse:
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
# Single-call mode: one LLM call returns both commands and message
if CFG.get('single_call', False):
combined_prompt = _commands_prompt(session.mode)
if session.mode != 'sudo':
combined_prompt += '\n\nAlso include a "message" field with a dramatic in-character response.'
sc_messages = [
{'role': 'system', 'content': combined_prompt},
*session.messages[-12:],
{'role': 'user', 'content': user_blob + tool_results_block},
]
sc_raw = _ollama_chat(
CFG.get('command_model', CFG.get('model', 'mortdecai-v4')),
sc_messages,
fmt='json',
temperature=0.3,
max_tokens=600,
)
sc_parsed = _parse_json(sc_raw)
commands = _sanitize_commands(sc_parsed.get('commands') or [], session.mode)
message = sc_parsed.get('message') or None
else:
# Two-call mode: separate command and message calls
cmd_messages = [
{'role': 'system', 'content': _commands_prompt(session.mode)},
*session.messages[-12:],
+18 -4
View File
@@ -3687,11 +3687,25 @@ def _is_rcon_error(result: str) -> bool:
_ERROR_CORRECTION_SYSTEM = (
"A Minecraft command failed. Analyze the error and return a corrected command.\n"
"A Minecraft 1.21 command failed with an RCON error. Your job: return a DIFFERENT, corrected command.\n"
"Do NOT return the same command. Analyze the error and fix the syntax.\n\n"
"Respond with JSON: {\"corrected\": \"the fixed command\"}\n"
"The command must be a single complete string. Use minecraft: prefix for all items/effects.\n"
"Use 1.21 syntax: enchantments use [enchantments={name:level}] NOT {Enchantments:[...]}.\n"
"If you cannot fix it, return {\"corrected\": \"\"}."
"If you cannot fix it, return {\"corrected\": \"\"}.\n\n"
"Common fixes:\n"
"- 'Incorrect argument for command' → wrong arg order or missing subcommand\n"
" xp player 100 → xp add player 100 levels\n"
" gamemode player survival → gamemode survival player\n"
"- 'Unknown item' → missing minecraft: prefix or wrong item name\n"
" give player diamond → give player minecraft:diamond 1\n"
" bed → minecraft:white_bed, log → minecraft:oak_log\n"
"- 'Expected whitespace' → count before enchantments or bad syntax\n"
" give player sword 1[enchantments=...] → give player sword[enchantments=...] 1\n"
"- 'Unknown enchantment/effect' → wrong name\n"
" sharp → sharpness, prot → protection, speed_boost → speed\n"
"- Old NBT format → use 1.21 component syntax\n"
" {Enchantments:[...]} → [enchantments={name:level}]\n"
"- Missing 'give' subcommand for effects\n"
" effect player speed → effect give player minecraft:speed 30 1\n"
)
def _attempt_error_correction(failed_cmd: str, error_msg: str, config: dict) -> str: