diff --git a/.dockerignore b/.dockerignore index 9f56aa826..b7d6bb62e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,33 @@ -.vscode -node_modules -bots/*/ -!bots/* -keys.json \ No newline at end of file +# Runtime bot data — large and should never be in the image +bots/*/logs/ +bots/*/histories/ +bots/*/action-code/ +bots/*/ensemble_log.json + +# Git history +.git/ + +# Local secrets / keys +keys.json +.env +.env.* + +# Node dev artifacts +node_modules/ + +# Editor / OS +.vscode/ +*.DS_Store +Thumbs.db + +# Tasks output +tasks/**/__pycache__/ +tasks/**/*.pyc + +# AWS deploy scripts (not needed in container) +aws/ + +# Docs not needed at runtime +docs/ +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..0530905dc --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Minecraft Server Configuration +RCON_PASSWORD=your_rcon_password_here + +# LiteLLM Configuration (required for docker-compose validation) +LITELLM_MASTER_KEY=your_litellm_key_here + +# EC2 Deployment Configuration +EC2_PUBLIC_IP=your_ec2_ip_here + +# Discord Bot (optional - leave blank if not using) +DISCORD_BOT_TOKEN= +DISCORD_ADMIN_IDS= +BOT_DM_CHANNEL= +BACKUP_CHAT_CHANNEL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..de3b22c07 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + lint: + name: ESLint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm install --no-audit --no-fund + env: + HUSKY: '0' + + - name: Run ESLint (0-warning tolerance) + run: npm run lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..19a01c99e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Deploy to EC2 + +on: + push: + branches: [main] + +concurrency: + group: deploy-ec2 + cancel-in-progress: true + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - name: Write SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/mindcraft-ec2.pem + chmod 600 ~/.ssh/mindcraft-ec2.pem + ssh-keyscan -H "${{ secrets.EC2_HOST }}" >> ~/.ssh/known_hosts + + - name: Deploy on EC2 + env: + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + run: | + ssh -i ~/.ssh/mindcraft-ec2.pem \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=30 \ + "${EC2_USER}@${EC2_HOST}" \ + 'cd /app && git fetch origin main && git reset --hard origin/main && bash aws/ec2-go.sh' + + - name: Clean up SSH key + if: always() + run: rm -f ~/.ssh/mindcraft-ec2.pem diff --git a/.gitignore b/.gitignore index d838f969d..a595e2010 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,27 @@ tasks/construction_tasks/train/** server_data* **/.DS_Store src/mindcraft-py/__pycache__/ +.dockerignore + +# Environment variables and secrets +.env +.env.local +.env.*.local +*.pem +*.key +*.p12 +*.pfx +secrets.json + +# Claude Code +.claude/ + +# Minecraft server data +minecraft-data/ + +# Binary assets (hosted on GitHub Releases) +tasks/construction_tasks/*.pdf + +# AWS runtime config +aws/config.env +docker-compose.override.yml diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..0312b7602 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c51e5da0c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Mindcraft is an AI-powered Minecraft bot framework (research fork v0.1.3) where multiple LLMs play Minecraft autonomously. This fork adds a **Hybrid Research Rig** with: +- **CloudGrok** — cloud ensemble bot: 4 panel models (Gemini + Grok) voted by a heuristic arbiter + optional LLM judge; always-on on EC2 +- **DragonSlayer** — local GPU bot (currently active): `sweaterdog/andy-4:q8_0` via Ollama on RTX 3090; autonomous Ender Dragon speedrun with RC29 persistent state +- **LocalAndy** — local GPU bot (dormant): `sweaterdog/andy-4` via Ollama; research/exploration profile +- All bots connect to one persistent Minecraft server on AWS EC2 (Paper 1.21.11) with ChromaDB-backed memory + +This is an **ES module** project (`"type": "module"` in package.json). Use `import`/`export`, not `require`. + +## Commands + +```bash +npm install # Install deps (runs patch-package postinstall automatically) +npm start # Start bots: node main.js +npm run lint # ESLint with 0-warning tolerance (enforced pre-commit via husky) +npm test # No-op (no tests configured) + +# Run DragonSlayer (current active local bot — connects to EC2 server) +node main.js --profiles ./profiles/dragon-slayer.json + +# Run a specific bot profile +node main.js --profiles ./profiles/ensemble.json + +# Run with task automation +node main.js --task_path tasks/basic/single_agent.json --task_id gather_oak_logs + +# EC2 deployment (run on EC2 instance) +bash aws/ec2-go.sh # Quick restart (code pull + rebuild) +bash aws/ec2-go.sh --full # Full redeploy (+ secrets from SSM) +bash aws/ec2-go.sh --secrets # Refresh API keys from AWS SSM only + +# Windows launcher +.\start.ps1 both # Start both bots +.\start.ps1 local -Detach # Start local bot detached +.\start.ps1 stop # Stop all +``` + +## Architecture + +### Entry Point & Agent Lifecycle +`main.js` → parses profiles from `settings.js` → spawns one `Agent` per profile via `src/process/agent_process.js` → each agent connects to Minecraft via mineflayer. + +### Core Agent Loop (`src/agent/`) +- **`agent.js`** — Main `Agent` class: event handling, conversation loop (`promptConvo()`), mode management +- **`action_manager.js`** — Validates and executes `!commands` (e.g., `!collectBlocks`, `!goToPlayer`) +- **`conversation.js`** — Chat message routing; inter-bot messaging protocol +- **`coder.js`** — JavaScript code execution in an SES sandbox +- **`modes.js`** — Behavioral modes: survival, cowardice, hunting, etc. +- **`history.js`** / **`memory_bank.js`** / **`learnings.js`** — Persistent memory across sessions +- **`library/skills.js`** — All in-game action implementations (~89k LOC) +- **`library/world.js`** — World navigation and block/entity queries + +### Ensemble Decision Pipeline (`src/ensemble/`) +The `EnsembleModel` class in `controller.js` runs a 3-phase decision process on every LLM call: +1. **Panel** (`panel.js`) — Queries all 4 panel models in parallel +2. **Arbiter** (`arbiter.js`) — Scores responses heuristically (length, completeness, action quality, latency). If top 2 scores are within 0.08 margin, escalates to Judge. +3. **Judge** (`judge.js`) — LLM-as-Judge (Gemini Flash) picks the best response; 10s timeout +4. **ChromaDB Memory** (`feedback.js`) — Embeds recent context, retrieves similar past decisions (similarity > 0.6), injects as `[PAST EXPERIENCE]` before panel queries, then logs outcome for future retrieval + +Every ensemble decision is written to `bots/{BotName}/ensemble_log.json`. + +### Model Abstraction (`src/models/`) +- **`_model_map.js`** — Dynamically discovers all provider modules +- **`prompter.js`** — Unified prompt builder: injects `$MEMORY`, `$INVENTORY`, `$STATS`, `$EXAMPLES` into system prompt +- **`{provider}.js`** — 23 provider implementations (gpt, gemini, grok, claude, ollama, etc.) + +Model routing: a string like `"gemini-2.5-pro"` is auto-matched to its provider; `"openrouter/google/gemini-2.5-pro"` uses explicit routing; profile can also pass an object `{ api, model, url, params }`. + +### Configuration +- **`settings.js`** — Global defaults. Override any key via `SETTINGS_JSON` env var (prototype-pollution protected). API keys in `.env` take priority over `keys.json`. +- **`profiles/*.json`** — Per-bot personality, model selection, system prompts, and per-profile `blocked_actions`. Profiles inherit from `profiles/defaults/{base_profile}.json`. +- **`src/utils/keys.js`** — Loads API keys; env vars always override `keys.json`. + +### Web UI & Multi-Agent +- **`src/mindcraft/mindserver.js`** — WebSocket server on port 8080; hosts HUD overlay and bot registry +- **`src/mindcraft/public/`** — Frontend HUD with per-bot runtime, goal, and command log +- Multiple bots share one MindServer. Inter-bot messaging uses `!startConversation()` protocol and an alias system (`/msg gk` → `Grok_En`). + +### Security Guards (do not remove) +- `src/utils/message_validator.js` — Injection detection and char sanitization on all chat input +- `src/utils/rate_limiter.js` — Per-user rate limiting +- `settings.js` `deepSanitize()` — Prototype pollution guard on `SETTINGS_JSON` +- `discord-bot.js` — Path traversal guard on profile loading; command injection detection +- `allow_insecure_coding: false` by default (controls `!newAction` code execution) + +## Key Configuration Notes + +- **Node.js**: v18+ required; v20 LTS recommended; v24+ may cause issues +- **Minecraft version**: Set `minecraft_version` in `settings.js` (default `"auto"` for up to v1.21.6) +- **EC2 server**: set `host` in `settings.js` to your EC2 public IP; `port: 19565` (non-default external port, internal 25565) +- **Docker host**: `"host": "minecraft-server"` is the Docker service name; change to `"localhost"` for non-Docker runs +- **Vision**: Requires `LIBGL_ALWAYS_SOFTWARE=1` and Xvfb (only works in Docker); prismarine-viewer canvas bindings broken on Windows +- **Active local profile**: `profiles/dragon-slayer.json` — DragonSlayer bot with `sweaterdog/andy-4:q8_0` via Ollama +- **Ensemble profile**: `profiles/ensemble.json` — CloudGrok config with 4-panel voting (runs on EC2) +- **Whitelist**: `ENFORCE_WHITELIST=TRUE`; `whitelist.json` mounted into container with pre-built offline UUIDs. **Do not** use the `WHITELIST` env var — it queries Playerdb and crashes for offline-mode bot names. + +## Deployment Topologies + +| Mode | Compose File | Notes | +|------|-------------|-------| +| Local dev | `docker-compose.yml` | Ollama on host via `host.docker.internal:11434` | +| EC2 production | `docker-compose.aws.yml` | Includes LiteLLM proxy (:4000), ChromaDB, Tailscale sidecar | +| Local bot → EC2 server | `settings.js` | set `host` to EC2 public IP, `port: 19565`; bot on Windows, server on EC2 | +| EC2 production | `docker-compose.aws.yml` | Includes LiteLLM proxy (:4000), ChromaDB, Tailscale sidecar | + +AWS secrets managed via SSM Parameter Store; `aws/ec2-go.sh --secrets` pulls and writes them. diff --git a/Dockerfile b/Dockerfile index 153624512..c01c70368 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,19 @@ RUN apt-get update && \ WORKDIR /app -COPY package*.json . +# Copy package files and patches for better caching +COPY package*.json ./ +COPY patches/ ./patches/ RUN npm install +# Copy source code COPY . . +# Run tests during build +RUN npm test + +# Drop root privileges — node:slim includes a non-root 'node' user +RUN chown -R node:node /app +USER node + CMD ["npm", "start"] \ No newline at end of file diff --git a/FAQ.md b/FAQ.md index 989dec318..dd4d91e5e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,4 +1,5 @@ # Common Issues + - `Error: connect ECONNREFUSED`: Minecraft refused to connect with mindcraft program. Most likely due to: - you have not opened your game to LAN in game settings - your LAN port is incorrect, make sure the one you enter in game is the same as specified in `settings.js` @@ -11,12 +12,14 @@ - **`npm install` fails with Python or C++ build errors**: This typically happens when building native modules like `gl`. Common solutions: - **Python not found** (macOS/Linux): If you see `python: command not found`, create a symlink: `sudo ln -s $(which python3) /usr/local/bin/python` - **C++20 errors or Node version issues**: If you see `"C++20 or later required"` errors, you're likely using Node v24 or newer. The `gl` package requires Node LTS (v18 or v20). Switch versions using: + ```bash nvm install 20 nvm use 20 rm -rf node_modules package-lock.json npm install ``` + - **Skip optional packages**: If you don't need the vision feature (disabled by default), you can skip the problematic `gl` package: `npm install --no-optional` - `My brain disconnected, try again`: Something is wrong with the LLM api. You may have the wrong API key, exceeded your rate limits, or other. Check the program outputs for more details. @@ -28,9 +31,10 @@ - `Why I added the api key but still prompted that the key can't be found?` - Possible reason 1: Did not modify keys.example.json to keys.json. - Possible reason 2: If you use vscode to edit, you need to `ctrl+s` to save the file for the changes to take effect. - - Possible reason 3: Not setting the code path correctly in setting.js, use andy.js by default. + - Possible reason 3: Not setting the code path correctly in setting.js, use andy.js by default. + +## Common Questions -# Common Questions - Mod Support? Mindcraft only supports client-side mods like optifine and sodium, though they can be tricky to set up. Mods that change minecraft game mechanics are not supported. - Texture Packs? Apparently these cause issues and refuse to connect. Not sure why diff --git a/README.md b/README.md index 07002074c..bda52b894 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,138 @@ -

🧠mindcraft⛏️

-

- kolbytn%2Fmindcraft | Trendshift -

- -

Crafting minds for Minecraft with LLMs and Mineflayer!

- -

- FAQ | - Discord Support | - Video Tutorial | - Blog Post | - Contributor TODO | - Paper Website | - MineCollab -

+ +# 🧠mindcraft⛏️ + +[![Trendshift](https://trendshift.io/api/badge/repositories/9163)](https://trendshift.io/repositories/9163) + +Crafting minds for Minecraft with LLMs and [Mineflayer](https://prismarinejs.github.io/mineflayer/#/)! + +**Links:** [FAQ](https://github.com/mindcraft-bots/mindcraft/blob/main/FAQ.md) | +[Discord Support](https://discord.gg/mp73p35dzC) | +[Video Tutorial](https://www.youtube.com/watch?v=gRotoL8P8D8) | +[Blog Post](https://kolbynottingham.com/mindcraft/) | +[Contributor TODO](https://github.com/users/kolbytn/projects/1) | +[Paper Website](https://mindcraft-minecollab.github.io/index.html) > [!Caution] Do not connect this bot to public servers with coding enabled. This project allows an LLM to write/execute code on your computer. The code is sandboxed, but still vulnerable to injection attacks. Code writing is disabled by default, you can enable it by setting `allow_insecure_coding` to `true` in `settings.js`. Ye be warned. -# Getting Started +--- + +## This Fork: Hybrid Research Rig + +> Forked from [mindcraft-bots/mindcraft](https://github.com/mindcraft-bots/mindcraft) — the original Minecraft AI agent framework by [@kolbytn](https://github.com/kolbytn), [@MaxRobinsonTheGreat](https://github.com/MaxRobinsonTheGreat), and the Mindcraft team. + +This fork (`mindcraft-0.1.3`) extends the base Mindcraft framework with a **Hybrid Research Rig** — two AI bots running simultaneously on AWS EC2, combining cloud ensemble intelligence with local GPU inference. + +> **Live deployment**: All 12 services run on AWS EC2 via `docker-compose.aws.yml` (CloudGrok + Minecraft server + ChromaDB + LiteLLM + Grafana + Prometheus + Tailscale + Discord bot). See the [Architecture wiki](https://github.com/Z0mb13V1/mindcraft/wiki/Architecture) for full infrastructure diagrams. + +### Active Bots + +| Bot | Model | Vision | Status | Role | +| --- | ----- | ------ | ------ | ---- | +| **CloudGrok** | 4-model ensemble (Gemini + Grok panel) | `grok-2-vision-1212` | ✅ EC2 (always-on) | Persistent survival/research — base maintenance, resource gathering, building | +| **DragonSlayer** | `sweaterdog/andy-4:q8_0` via Ollama (RTX 3090) | `ollama/llava` | ✅ Local (active) | Autonomous Ender Dragon speedrun — `!beatMinecraft` with persistent RC29 state | +| **LocalAndy** | `sweaterdog/andy-4` via Ollama (RTX 3090) | `gemini-2.5-flash` | ⏸ Local (dormant) | Research & exploration — biome exploration, strategy testing | + +### Ensemble Decision Pipeline (CloudGrok) + +| Phase | Name | Description | +| ----- | ---- | ----------- | +| **1** | Heuristic Arbiter | All 4 panel models queried in parallel; proposals scored on length, completeness, and action quality — highest score wins | +| **2** | LLM-as-Judge | When top two proposals are within 0.08 margin, Gemini Flash reviews all proposals and picks the winner | +| **3** | ChromaDB Memory | Before querying the panel, similar past decisions (similarity > 0.6) are retrieved via 3072-dim Gemini embeddings and injected as `[PAST EXPERIENCE]` context | + +### Panel Models (CloudGrok Ensemble) + +| Model | Provider | Role | +| ----- | -------- | ---- | +| `gemini-2.5-pro` | Google | Panel member | +| `gemini-2.5-flash` | Google | Panel member + LLM Judge | +| `grok-4-1-fast-non-reasoning` | xAI | Panel member | +| `grok-code-fast-1` | xAI | Panel member | + +### Infrastructure + +| Component | Location | Notes | +| --------- | -------- | ----- | +| Minecraft server | AWS EC2 (us-east-1) | Paper 1.21.11, external port **19565**, `ONLINE_MODE=FALSE` | +| CloudGrok (ensemble bot) | AWS EC2 (us-east-1) | Cloud APIs (Gemini + xAI) | +| DragonSlayer (local bot) | Local Windows PC (RTX 3090) | Connects to EC2 server; Ollama inference on-device | +| LocalAndy (Ollama bot) | Local Windows PC (RTX 3090) | Available via Tailscale VPN; dormant while DragonSlayer active | +| ChromaDB vector store | AWS EC2 (us-east-1) | Ensemble memory backend | +| Discord bot | AWS EC2 (us-east-1) | MindcraftBot#9501 | +| Ollama (inference) | Local Windows PC (RTX 3090) | `sweaterdog/andy-4:q8_0`, `nomic-embed-text`, `llava` | +| S3 backup | Daily 3 AM UTC | 7-day retention | + +### Running the Hybrid Rig + +**On EC2 (one-command deploy/restart):** + +```bash +cd /app && bash aws/ec2-go.sh --full # Pull code + SSM secrets + rebuild + restart +cd /app && bash aws/ec2-go.sh # Quick restart (code pull only) +cd /app && bash aws/ec2-go.sh --secrets # Refresh API keys from SSM only +``` + +`ec2-go.sh` auto-detects whether it's running on EC2 (local execution) or remotely (SSH wrapper). IMDSv2 supported. + +**Local bot (DragonSlayer on Windows):** + +```powershell +# Kill any stale node process on port 8080, then launch +node main.js # settings.js host/port must point to your EC2 server +``` + +**From Mac (remote deploy):** + +```bash +bash aws/ec2-go.sh # SSH into EC2 + deploy (needs .pem) +``` + +### Key Features + +- **HUD Overlay** — gaming-style dashboard in the MindServer web UI (`:8080`) with per-bot runtime tracker (MM:SS), current goal / next action display with self-prompter state badges, and a scrollable color-coded command log. +- **Live Bot Cameras** — first-person prismarine-viewer streams embedded as iframes in the web UI (ports 3000+) +- **Vision enabled** for both bots — Xvfb + Mesa software rendering in Docker with 2s startup delay for WebGL context init +- **Human message priority** — `requestInterrupt()` fires immediately when a human player speaks +- **Loop detection** — tracks last 12 actions, cancels on 3-action pattern repeats or 5+ of the same action +- **Per-profile `blocked_actions`** — LocalAndy blocks `!startConversation` to prevent hallucinated names +- **Graceful vision fallback** — if WebGL init fails, bots continue without crashing +- **Tailscale VPN** — EC2 ↔ local 3090 tunnel for LocalAndy inference +- **`ec2-go.sh`** — one-command deploy script with IMDSv2 support, SSM secret refresh, and auto-detection of local vs remote execution +- **Progress Reporter (RC30)** — 5-minute milestone updates to console and Discord webhook; reports current goal, action, memory, inventory, and agent uptime +- **Survival reliability (RC30)** — hunger safety net, golden apple priority over regular food, void-edge avoidance, death-location recovery with inventory retrieval +- **Auto inventory management (RC30)** — overflow detection with automatic chest placement when slots drop below 6 before chunk operations +- **Stuck recovery (RC30)** — door-break last resort after 8s navigation block; path sanitization against injection; async action chain hardening + +### Security + +This fork includes several security hardening measures: + +- **Whitelist enforcement** — `ENFORCE_WHITELIST=TRUE` on the Minecraft server. `whitelist.json` is pre-generated with correct offline-mode UUIDs (`OfflinePlayer:` MD5 algorithm) and mounted directly into the container. The `WHITELIST` env var is intentionally omitted — it queries Playerdb (Mojang API) which fails for offline-mode bot names, causing a crash-loop before Minecraft starts. +- **Port obscurity** — External Minecraft port changed from default `25565` to `19565` to reduce automated scanner noise. AWS Security Group restricts access to trusted IPs only. +- **Environment variable keys** — API keys loaded from `.env` / env vars (priority over `keys.json`). +- **AWS SSM Parameter Store** — secrets stored encrypted at `/mindcraft/*`, pulled at deploy time via `ec2-go.sh --secrets` +- **Recursive prototype pollution protection** — `SETTINGS_JSON` sanitized at all nesting depths +- **Cross-platform path traversal guard** — Discord bot profile paths validated with `path.sep` +- **Input validation** — message validator with command injection detection, type checks, control char stripping +- **Rate limiting with auto-cleanup** — prevents abuse and memory leaks from stale entries +- **No hardcoded IPs** — EC2 public IP, Tailscale IP, and public hostnames loaded from env vars +- **ESLint hardening** — `no-unused-vars`, `no-unreachable`, `no-floating-promise` enabled as warnings +- **Deep audit** — 10 priorities resolved across code, config, Docker, and cleanup (`e5cf8b7a`) + +See the [Security wiki page](https://github.com/Z0mb13V1/mindcraft/wiki/Security) for full details. + +### Documentation + +| Doc | Description | +| --- | ----------- | +| [CLAUDE.md](CLAUDE.md) | Architecture overview, commands, configuration notes | +| [Wiki](https://github.com/Z0mb13V1/mindcraft/wiki) | Full documentation — architecture, bot commands, ensemble pipeline, troubleshooting | + +--- + +## Getting Started + ## Requirements - [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc) (up to v1.21.6, recommend v1.21.6) @@ -36,7 +150,9 @@ Do not connect this bot to public servers with coding enabled. This project allo 2. Download the [latest release](https://github.com/mindcraft-bots/mindcraft/releases/latest) and unzip it, or clone the repository. -3. Rename `keys.example.json` to `keys.json` and fill in your API keys (you only need one). The desired model is set in `andy.json` or other profiles. For other models refer to the table below. +3. Set up your API keys (you only need one provider): + - **Recommended:** Create a `.env` file and add your keys (e.g. `OPENAI_API_KEY=sk-...`). Environment variables take priority. + - **Legacy:** Rename `keys.example.json` to `keys.json` and fill in your keys. *(Less secure — migrate to `.env` when possible.)* 4. In terminal/command prompt, run `npm install` from the installed directory @@ -44,21 +160,20 @@ Do not connect this bot to public servers with coding enabled. This project allo 6. Run `node main.js` from the installed directory -If you encounter issues, check the [FAQ](https://github.com/mindcraft-bots/mindcraft/blob/main/FAQ.md) or find support on [discord](https://discord.gg/mp73p35dzC). We are currently not very responsive to github issues. To run tasks please refer to [Minecollab Instructions](minecollab.md#installation) +If you encounter issues, check the [FAQ](https://github.com/mindcraft-bots/mindcraft/blob/main/FAQ.md) or find support on [discord](https://discord.gg/mp73p35dzC). +## Configuration -# Configuration ## Model Customization You can configure project details in `settings.js`. [See file.](settings.js) You can configure the agent's name, model, and prompts in their profile like `andy.json`. The model can be specified with the `model` field, with values like `model: "gemini-2.5-pro"`. You will need the correct API key for the API provider you choose. See all supported APIs below. -
-⭐ VIEW SUPPORTED APIs ⭐ +### Supported APIs -| API Name | Config Variable| Docs | -|------|------|------| +| API Name | Config Variable | Docs | +| ------ | ------ | ------ | | `openai` | `OPENAI_API_KEY` | [docs](https://platform.openai.com/docs/models) | | `google` | `GEMINI_API_KEY` | [docs](https://ai.google.dev/gemini-api/docs/models/gemini) | | `anthropic` | `ANTHROPIC_API_KEY` | [docs](https://docs.anthropic.com/claude/docs/models-overview) | @@ -78,18 +193,19 @@ You can configure the agent's name, model, and prompts in their profile like `an | `cerebras` | `CEREBRAS_API_KEY` | [docs](https://inference-docs.cerebras.ai/introduction) | | `mercury` | `MERCURY_API_KEY` | [docs](https://www.inceptionlabs.ai/) | -
- For more comprehensive model configuration and syntax, see [Model Specifications](#model-specifications). -For local models we support [ollama](https://ollama.com/) and we provide our own finetuned models for you to use. +For local models we support [ollama](https://ollama.com/) and we provide our own finetuned models for you to use. To install our models, install ollama and run the following terminal command: + ```bash ollama pull sweaterdog/andy-4:micro-q8_0 && ollama pull embeddinggemma ``` ## Online Servers + To connect to online servers your bot will need an official Microsoft/Minecraft account. You can use your own personal one, but will need another account if you want to connect too and play with it. To connect, change these lines in `settings.js`: + ```javascript "host": "111.222.333.444", "port": 55920, @@ -97,6 +213,7 @@ To connect to online servers your bot will need an official Microsoft/Minecraft // rest is same... ``` + > [!Important] > The bot's name in the profile.json must exactly match the Minecraft profile name! Otherwise the bot will spam talk to itself. @@ -104,13 +221,13 @@ To use different accounts, Mindcraft will connect with the account that the Mine ## Tasks -Tasks automatically start the bot with a prompt and a goal item to aquire or blueprint to construct. To run a simple task that involves collecting 4 oak_logs run +Tasks automatically start the bot with a prompt and a goal item to aquire or blueprint to construct. To run a simple task that involves collecting 4 oak_logs run `node main.js --task_path tasks/basic/single_agent.json --task_id gather_oak_logs` -Here is an example task json format: +Here is an example task json format: -``` +```json { "gather_oak_logs": { "goal": "Collect at least four logs", @@ -136,9 +253,9 @@ Here is an example task json format: } ``` -The `initial_inventory` is what the bot will have at the start of the episode, `target` refers to the target item and `number_of_target` refers to the number of target items the agent needs to collect to successfully complete the task. +The `initial_inventory` is what the bot will have at the start of the episode, `target` refers to the target item and `number_of_target` refers to the number of target items the agent needs to collect to successfully complete the task. -If you want more optimization and automatic launching of the minecraft world, you will need to follow the instructions in [Minecollab Instructions](minecollab.md#installation) +For more optimization and automatic launching of the minecraft world, see the [Tasks section](https://github.com/mindcraft-bots/mindcraft#tasks) of the base Mindcraft repo. ## Docker Container @@ -147,7 +264,9 @@ If you intend to `allow_insecure_coding`, it is a good idea to run the app in a ```bash docker build -t mindcraft . && docker run --rm --add-host=host.docker.internal:host-gateway -p 8080:8080 -p 3000-3003:3000-3003 -e SETTINGS_JSON='{"auto_open_ui":false,"profiles":["./profiles/gemini.json"],"host":"host.docker.internal"}' --volume ./keys.json:/app/keys.json --name mindcraft mindcraft ``` + or simply + ```bash docker-compose up --build ``` @@ -160,7 +279,7 @@ When running in docker, if you want the bot to join your local minecraft server, To connect to an unsupported minecraft version, you can try to use [viaproxy](services/viaproxy/README.md) -# Bot Profiles +## Bot Profiles Bot profiles are json files (such as `andy.json`) that define: @@ -170,7 +289,7 @@ Bot profiles are json files (such as `andy.json`) that define: ## Model Specifications -LLM models can be specified simply as `"model": "gpt-4o"`, or more specifically with `"{api}/{model}"`, like `"openrouter/google/gemini-2.5-pro"`. See all supported APIs [here](#model-customization). +LLM models can be specified simply as `"model": "gpt-4o"`, or more specifically with `"{api}/{model}"`, like `"openrouter/google/gemini-2.5-pro"`. See all [supported APIs](#model-customization). The `model` field can be a string or an object. A model object must specify an `api`, and optionally a `model`, `url`, and additional `params`. You can also use different models/providers for chatting, coding, vision, embedding, and voice synthesis. See the example below. @@ -222,8 +341,7 @@ Voice synthesis models are used to narrate bot responses and specified with `spe By default, the program will use the profiles specified in `settings.js`. You can specify one or more agent profiles using the `--profiles` argument: `node main.js --profiles ./profiles/andy.json ./profiles/jill.json` - -# Contributing +## Contributing We welcome contributions to the project! We are generally less responsive to github issues, and more responsive to pull requests. Join the [discord](https://discord.gg/mp73p35dzC) for more active support and direction. @@ -233,13 +351,15 @@ While AI generated code is allowed, please vet it carefully. Submitting tons of Some of the node modules that we depend on have bugs in them. To add a patch, change your local node module file and run `npx patch-package [package-name]` -## Development Team +### Development Team + Thanks to all who contributed to the project, especially the official development team: [@MaxRobinsonTheGreat](https://github.com/MaxRobinsonTheGreat), [@kolbytn](https://github.com/kolbytn), [@icwhite](https://github.com/icwhite), [@Sweaterdog](https://github.com/Sweaterdog), [@Ninot1Quyi](https://github.com/Ninot1Quyi), [@riqvip](https://github.com/riqvip), [@uukelele-scratch](https://github.com/uukelele-scratch), [@mrelmida](https://github.com/mrelmida) +### Citation -## Citation: This work is published in the paper [Collaborating Action by Action: A Multi-agent LLM Framework for Embodied Reasoning](https://arxiv.org/abs/2504.17950). Please use this citation if you use this project in your research: -``` + +```bibtex @article{mindcraft2025, title = {Collaborating Action by Action: A Multi-agent LLM Framework for Embodied Reasoning}, author = {White*, Isadora and Nottingham*, Kolby and Maniar, Ayush and Robinson, Max and Lillemark, Hansen and Maheshwari, Mehul and Qin, Lianhui and Ammanabrolu, Prithviraj}, diff --git a/Tasks.Dockerfile b/Tasks.Dockerfile index 509d3abda..e43ddb894 100644 --- a/Tasks.Dockerfile +++ b/Tasks.Dockerfile @@ -1,51 +1,49 @@ -# Specify a base image -# FROM ubuntu:22.04 -FROM node:18 - -#Install some dependencies - -RUN apt-get -y update -RUN apt-get -y install git -RUN apt-get -y install unzip -RUN apt-get -y install python3 -RUN apt-get -y install python3-pip -RUN apt-get -y install python3-boto3 -RUN apt-get -y install python3-tqdm -RUN apt-get -y install tmux - -RUN git clone https://github.com/mindcraft-bots/mindcraft.git /mindcraft +# Tasks.Dockerfile — Evaluation / benchmark runner +# Builds a container with Mindcraft + Java 21 + AWS CLI for automated tasks. + +FROM node:22-slim AS base + +# ── System dependencies (single layer) ────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + git unzip curl wget ca-certificates gnupg lsb-release \ + python3 python3-pip python3-boto3 python3-tqdm tmux \ + apt-transport-https \ + && rm -rf /var/lib/apt/lists/* + +# ── Adoptium Java 21 (proper GPG keyring, not deprecated apt-key) ─────────── +RUN mkdir -p /etc/apt/keyrings \ + && wget -qO- https://packages.adoptium.net/artifactory/api/gpg/key/public \ + | gpg --dearmor -o /etc/apt/keyrings/adoptium.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/adoptium.gpg] \ + https://packages.adoptium.net/artifactory/deb $(lsb_release -cs) main" \ + > /etc/apt/sources.list.d/adoptium.list \ + && apt-get update && apt-get install -y --no-install-recommends temurin-21-jdk \ + && rm -rf /var/lib/apt/lists/* + +# ── AWS CLI v2 ────────────────────────────────────────────────────────────── +RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \ + && unzip -q /tmp/awscliv2.zip -d /tmp \ + && /tmp/aws/install \ + && rm -rf /tmp/awscliv2.zip /tmp/aws + +# ── Application code ─────────────────────────────────────────────────────── WORKDIR /mindcraft -COPY ./server_data.zip /mindcraft -RUN unzip server_data.zip +# Copy source from the build context (this repo) rather than cloning from +# GitHub at build time. A live git clone: +# 1. Breaks reproducibility (upstream HEAD can change between builds) +# 2. Fails in offline/air-gapped CI environments +# 3. Introduces supply-chain risk (external fetch during image build) +COPY . . -RUN npm install +COPY ./server_data.zip /mindcraft/ +RUN unzip -q server_data.zip && rm server_data.zip +RUN npm ci --omit=dev -# Copy the rest of the application code to the working directory -# RUN apt update -# RUN apt install bash ca-certificates wget git -y # install first to avoid openjdk install bug -# RUN apt install openjdk-17-jre-headless -y -RUN apt install -y wget apt-transport-https gnupg lsb-release - -# Add Adoptium repository key -RUN wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | apt-key add - - -# Add Adoptium repository -RUN echo "deb https://packages.adoptium.net/artifactory/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/adoptium.list - -# Update package lists -RUN apt update - -# Install Temurin (Adoptium) Java 21 -RUN apt install temurin-21-jdk -y - -# Install unzip - - -RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" -RUN unzip awscliv2.zip -RUN ./aws/install +# ── Non-root user ────────────────────────────────────────────────────────── +RUN groupadd -r mindcraft && useradd -r -g mindcraft -d /mindcraft mindcraft \ + && chown -R mindcraft:mindcraft /mindcraft +USER mindcraft VOLUME /data - EXPOSE 8000 diff --git a/aws/backup.sh b/aws/backup.sh new file mode 100644 index 000000000..2229f88e9 --- /dev/null +++ b/aws/backup.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/backup.sh — Backup Minecraft world and bot memory to S3 +# ============================================================================= +# Run manually: bash aws/backup.sh +# Also runs automatically every 6 hours via cron (installed by deploy.sh) +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.env" + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[BACKUP $(date '+%Y-%m-%d %H:%M:%S')]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } + +# ── Detect if running locally (SSH to EC2) or on EC2 directly ──────────────── +if [[ -f "$CONFIG_FILE" ]]; then + # Running locally — SSH to EC2 and run backup there + # shellcheck source=/dev/null + source "$CONFIG_FILE" + [[ -n "${EC2_IP:-}" ]] || { echo "EC2_IP not set"; exit 1; } + SSH_OPTS="-i ${KEY_FILE} -o StrictHostKeyChecking=no" + info "Running backup on EC2 (${EC2_IP}) via SSH..." + ssh ${SSH_OPTS} ubuntu@${EC2_IP} "bash /app/aws/backup.sh" + exit 0 +fi + +# ── Running ON EC2 ──────────────────────────────────────────────────────────── +# Get region and bucket from instance metadata + SSM +REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/region 2>/dev/null \ + || echo "${AWS_DEFAULT_REGION:-us-east-1}") +S3_BUCKET=$(aws ssm get-parameter \ + --region "$REGION" \ + --name "/mindcraft/S3_BUCKET" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text 2>/dev/null \ + || grep S3_BUCKET /app/.env | cut -d= -f2 || "") + +[[ -n "$S3_BUCKET" ]] || { echo "ERROR: S3_BUCKET not found"; exit 1; } + +APP_DIR="/app" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +# ── Stop Minecraft briefly (prevents corrupted world files) ─────────────────── +MINECRAFT_WAS_RUNNING=false +if docker compose -f "${APP_DIR}/docker-compose.aws.yml" ps minecraft 2>/dev/null | grep -q "Up"; then + MINECRAFT_WAS_RUNNING=true + info "Stopping Minecraft for consistent backup..." + docker compose -f "${APP_DIR}/docker-compose.aws.yml" stop minecraft + sleep 2 +fi + +# ── Backup world to S3 ──────────────────────────────────────────────────────── +info "Syncing minecraft-data → s3://${S3_BUCKET}/minecraft-data/ ..." +aws s3 sync \ + "${APP_DIR}/minecraft-data" \ + "s3://${S3_BUCKET}/minecraft-data/" \ + --sse AES256 \ + --region "$REGION" \ + --delete + +# ── Backup bot memory to S3 ─────────────────────────────────────────────────── +info "Syncing bots/ memory → s3://${S3_BUCKET}/bots/ ..." +# Only sync memory.json and learnings.json (skip histories/ which are huge) +find "${APP_DIR}/bots" -maxdepth 2 \( -name "memory.json" -o -name "learnings.json" \) \ + -exec aws s3 cp {} "s3://${S3_BUCKET}/bots/$(basename "$(dirname {})")/$(basename {})" \ + --sse AES256 --region "$REGION" \; + +# ── Restart Minecraft ───────────────────────────────────────────────────────── +if [[ "$MINECRAFT_WAS_RUNNING" == "true" ]]; then + info "Restarting Minecraft..." + docker compose -f "${APP_DIR}/docker-compose.aws.yml" start minecraft +fi + +info "Backup complete. s3://${S3_BUCKET}/ (timestamp: ${TIMESTAMP})" diff --git a/aws/deploy.sh b/aws/deploy.sh new file mode 100644 index 000000000..6d8ad0648 --- /dev/null +++ b/aws/deploy.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/deploy.sh — Deploy / Redeploy Mindcraft to EC2 +# ============================================================================= +# Run from WSL: bash aws/deploy.sh +# On first run: copies all app files and starts containers +# On subsequent runs: syncs changes and restarts +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.env" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ── Load config ─────────────────────────────────────────────────────────────── +[[ -f "$CONFIG_FILE" ]] || error "config.env not found. Run aws/setup.sh first." +# shellcheck source=/dev/null +source "$CONFIG_FILE" + +[[ -n "${EC2_IP:-}" ]] || error "EC2_IP not set in config.env" +[[ -n "${KEY_FILE:-}" ]] || error "KEY_FILE not set in config.env" +[[ -f "$KEY_FILE" ]] || error "SSH key not found: ${KEY_FILE}. Run aws/setup.sh first." + +SSH_OPTS="-i ${KEY_FILE} -o StrictHostKeyChecking=no -o ConnectTimeout=10" +SSH="ssh ${SSH_OPTS} ubuntu@${EC2_IP}" +SCP="scp ${SSH_OPTS}" + +info "Deploying to ${EC2_IP}..." + +# ── Wait for EC2 to be SSH-ready ────────────────────────────────────────────── +info "Checking SSH connectivity..." +RETRIES=20 +for i in $(seq 1 $RETRIES); do + if $SSH "echo ok" >/dev/null 2>&1; then + break + fi + if [[ $i -eq $RETRIES ]]; then + error "Cannot SSH to ${EC2_IP} after ${RETRIES} attempts. Is the instance running?" + fi + warn "SSH not ready yet (attempt ${i}/${RETRIES}), waiting 15s..." + sleep 15 +done +info "SSH connected." + +# ── Wait for bootstrap to finish ────────────────────────────────────────────── +info "Checking if EC2 bootstrap is complete..." +RETRIES=30 +for i in $(seq 1 $RETRIES); do + if $SSH "test -f /var/lib/cloud/instance/mindcraft-bootstrap-done" 2>/dev/null; then + break + fi + if [[ $i -eq $RETRIES ]]; then + warn "Bootstrap may not be done yet — proceeding anyway." + break + fi + warn "Bootstrap still running (attempt ${i}/${RETRIES}), waiting 15s..." + sleep 15 +done + +# ── Rsync app files ─────────────────────────────────────────────────────────── +info "Syncing application files..." +rsync -avz --delete --ignore-errors \ + -e "ssh ${SSH_OPTS}" \ + --exclude 'node_modules/' \ + --exclude 'minecraft-data/' \ + --exclude 'bots/*/histories/' \ + --exclude 'bots/*/action-code/' \ + --exclude '.git/' \ + --exclude 'aws/mindcraft-ec2.pem' \ + --exclude 'aws/config.env' \ + --exclude 'keys.json' \ + --exclude '.env' \ + --exclude '*.pem' \ + --exclude '*.key' \ + --exclude 'services/viaproxy/logs/' \ + --exclude 'services/viaproxy/jars/' \ + --exclude 'services/viaproxy/plugins/' \ + --exclude 'services/viaproxy/ViaLoader/' \ + --exclude 'services/viaproxy/saves.json' \ + --exclude 'services/viaproxy/viaproxy.yml' \ + --filter 'protect minecraft-data/' \ + --filter 'protect bots/' \ + "${PROJECT_ROOT}/" \ + "ubuntu@${EC2_IP}:/app/" + +# ── Generate keys.json from SSM ─────────────────────────────────────────────── +info "Pulling secrets from SSM → /app/keys.json on EC2..." +$SSH bash -s <<'REMOTE' +set -euo pipefail + +get_param() { + local name="$1" + aws ssm get-parameter \ + --region "$(curl -s http://169.254.169.254/latest/meta-data/placement/region)" \ + --name "/mindcraft/${name}" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text 2>/dev/null || echo "" +} + +REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/region) +GEMINI_API_KEY=$(get_param GEMINI_API_KEY) +XAI_API_KEY=$(get_param XAI_API_KEY) +ANTHROPIC_API_KEY=$(get_param ANTHROPIC_API_KEY) +DISCORD_BOT_TOKEN=$(get_param DISCORD_BOT_TOKEN) + +cat > /app/keys.json </dev/null || echo "" +} + +cat > /app/.env </dev/null || \ + sudo crontab -u ubuntu /app/aws-cron.tab + echo "Backup cron installed." +fi +REMOTE + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}============================================================${NC}" +echo -e "${GREEN} Deploy complete!${NC}" +echo -e "${GREEN}============================================================${NC}" +echo "" +echo " Minecraft: ${EC2_IP}:19565" +echo " Grafana: http://${EC2_IP}:3004 (admin / admin — change on first login)" +echo " MindServer: http://${EC2_IP}:8080" +echo "" +echo " SSH: ssh -i ${KEY_FILE} ubuntu@${EC2_IP}" +echo " Logs: ssh ... 'docker compose -f /app/docker-compose.aws.yml logs -f'" +echo "" diff --git a/aws/ec2-deploy.sh b/aws/ec2-deploy.sh new file mode 100644 index 000000000..e6a162dae --- /dev/null +++ b/aws/ec2-deploy.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/ec2-deploy.sh — Bootstrap / Update Mindcraft directly on EC2 +# ============================================================================= +# Run this INSIDE the EC2 instance (browser SSH / EC2 Instance Connect). +# Handles first-time clone OR subsequent git pull, then starts containers. +# +# Usage: +# GITHUB_TOKEN=ghp_xxxx bash /tmp/ec2-deploy.sh +# # or, if already at /app: +# GITHUB_TOKEN=ghp_xxxx bash /app/aws/ec2-deploy.sh +# +# The GITHUB_TOKEN needs repo read access (classic PAT or fine-grained). +# ============================================================================= +set -euo pipefail + +REPO_HTTPS="https://github.com/Z0mb13V1/mindcraft-0.1.3.git" +APP_DIR="/app" +COMPOSE_FILE="docker-compose.aws.yml" +REGION="${AWS_DEFAULT_REGION:-us-east-1}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +step() { echo -e "\n${CYAN}=== $* ===${NC}"; } + +# ── Require token ────────────────────────────────────────────────────────────── +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + echo "" + echo " No GITHUB_TOKEN set. The repo is private — a token is required." + echo " Create one at: https://github.com/settings/tokens" + echo " (needs 'repo' read scope)" + echo "" + read -rsp " Paste your GitHub Personal Access Token: " GITHUB_TOKEN + echo "" + [[ -n "$GITHUB_TOKEN" ]] || error "Token is required." +fi + +CLONE_URL="https://${GITHUB_TOKEN}@github.com/Z0mb13V1/mindcraft-0.1.3.git" + +# ── Detect region from IMDS ──────────────────────────────────────────────────── +IMDS_TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 60" 2>/dev/null || echo "") +if [[ -n "$IMDS_TOKEN" ]]; then + REGION=$(curl -s -H "X-aws-ec2-metadata-token: ${IMDS_TOKEN}" \ + "http://169.254.169.254/latest/meta-data/placement/region" 2>/dev/null \ + || echo "$REGION") +fi +info "Region: ${REGION}" + +# ── SSM helper ──────────────────────────────────────────────────────────────── +get_param() { + aws ssm get-parameter \ + --region "$REGION" \ + --name "/mindcraft/$1" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text 2>/dev/null || echo "" +} + +# ── Step 1: Clone or update ──────────────────────────────────────────────────── +step "1. Sync code" +if [[ -d "${APP_DIR}/.git" ]]; then + info "Repo already at ${APP_DIR} — pulling latest..." + cd "$APP_DIR" + git remote set-url origin "$CLONE_URL" + git fetch origin + git reset --hard origin/main + git clean -fd --exclude=chromadb-data/ 2>/dev/null || true + info "Updated to: $(git log --oneline -1)" +elif [[ -d "${APP_DIR}" ]]; then + # Dir exists but no .git — init in-place and pull + info "${APP_DIR} exists but has no git repo — initialising in-place..." + cd "$APP_DIR" + git init -b main + git remote add origin "$CLONE_URL" + git fetch origin main + git reset --hard origin/main + info "Initialised: $(git log --oneline -1)" +else + info "Cloning repo to ${APP_DIR}..." + git clone "$CLONE_URL" "$APP_DIR" + cd "$APP_DIR" + info "Cloned: $(git log --oneline -1)" +fi + +# Secure the git remote URL so the token isn't visible in git log output +git remote set-url origin "$REPO_HTTPS" + +# ── Step 2: Pull secrets from SSM → keys.json ───────────────────────────────── +step "2. Pull secrets from SSM" + +GEMINI_API_KEY=$(get_param GEMINI_API_KEY) +XAI_API_KEY=$(get_param XAI_API_KEY) +ANTHROPIC_API_KEY=$(get_param ANTHROPIC_API_KEY) +DISCORD_BOT_TOKEN=$(get_param DISCORD_BOT_TOKEN) + +if [[ -z "$GEMINI_API_KEY" && -z "$XAI_API_KEY" ]]; then + warn "SSM returned empty keys — either IAM role lacks access or params not set." + warn "Continuing anyway; containers may fail if keys are missing." +fi + +cat > "${APP_DIR}/keys.json" < "${APP_DIR}/.env" </dev/null || true +docker compose -f "$COMPOSE_FILE" up -d --build + +# ── Step 6: Install backup cron ─────────────────────────────────────────────── +step "6. Install backup cron" +if [[ -f "${APP_DIR}/aws-cron.tab" ]]; then + crontab "${APP_DIR}/aws-cron.tab" 2>/dev/null \ + || sudo crontab -u ubuntu "${APP_DIR}/aws-cron.tab" 2>/dev/null \ + || warn "Could not install cron (not critical)." + info "Backup cron installed." +fi + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}============================================================${NC}" +echo -e "${GREEN} Deploy complete!${NC}" +echo -e "${GREEN}============================================================${NC}" +echo "" +docker compose -f "$COMPOSE_FILE" ps +echo "" +EC2_IP=$(curl -s -H "X-aws-ec2-metadata-token: ${IMDS_TOKEN}" \ + "http://169.254.169.254/latest/meta-data/public-ipv4" 2>/dev/null \ + || echo "") +echo " Minecraft: ${EC2_IP}:19565" +echo " Grafana: http://${EC2_IP}:3004" +echo " MindServer: http://${EC2_IP}:8080" +echo "" +echo " Logs: docker compose -f /app/${COMPOSE_FILE} logs -f" +echo "" diff --git a/aws/ec2-go.sh b/aws/ec2-go.sh new file mode 100644 index 000000000..f9d03f52b --- /dev/null +++ b/aws/ec2-go.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/ec2-go.sh — One-command Mindcraft deploy +# ============================================================================= +# Auto-detects whether you're ON EC2 or remote (Mac/Linux). +# On EC2: runs everything locally (no SSH needed) +# Remote: SSHs into EC2 to run commands +# +# Usage: +# bash aws/ec2-go.sh # Pull latest code + restart containers +# bash aws/ec2-go.sh --build # Pull + rebuild Docker images +# bash aws/ec2-go.sh --secrets # Re-pull SSM secrets + restart +# bash aws/ec2-go.sh --full # Full: secrets + build + restart +# ============================================================================= +set -euo pipefail + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +step() { echo -e "\n${CYAN}=== $* ===${NC}"; } + +# ── Parse args ──────────────────────────────────────────────────────────────── +COMPOSE_FILE="docker-compose.aws.yml" +APP_DIR="/app" +DO_BUILD=false +DO_SECRETS=false + +for arg in "$@"; do + case "$arg" in + --build) DO_BUILD=true ;; + --secrets) DO_SECRETS=true ;; + --full) DO_BUILD=true; DO_SECRETS=true ;; + --help|-h) + echo "Usage: ec2-go.sh [--build] [--secrets] [--full]" + echo " --build Rebuild Docker images" + echo " --secrets Re-pull secrets from SSM to .env and keys.json" + echo " --full Both --build and --secrets" + exit 0 ;; + *) warn "Unknown arg: $arg" ;; + esac +done + +# ── Detect: are we ON EC2 or remote? ───────────────────────────────────────── +# Check 3 ways: IMDSv2 (token-based), IMDSv1, or hostname pattern ip-* +IS_EC2=false +IMDS_TOKEN=$(curl -sf -m 2 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60" 2>/dev/null || true) +if [[ -n "$IMDS_TOKEN" ]]; then + # IMDSv2 works + IS_EC2=true +elif curl -sf -m 2 http://169.254.169.254/latest/meta-data/instance-id >/dev/null 2>&1; then + # IMDSv1 fallback + IS_EC2=true +elif hostname | grep -q '^ip-'; then + # EC2 default hostname pattern (ip-10-0-1-107 etc.) + IS_EC2=true +fi + +if $IS_EC2; then + info "Detected: running ON EC2 — executing locally" + # run_cmd just runs the command directly + run_cmd() { bash -c "$1"; } +else + info "Detected: running remotely — will SSH into EC2" + EC2_IP="${EC2_PUBLIC_IP:?Set EC2_PUBLIC_IP in .env or environment}" + EC2_KEY="${EC2_KEY_FILE:-$HOME/.ssh/mindcraft-ec2.pem}" + EC2_USER="ubuntu" + + if [[ ! -f "$EC2_KEY" ]]; then + error "SSH key not found: ${EC2_KEY} + Set EC2_KEY_FILE or copy your .pem to ~/.ssh/mindcraft-ec2.pem + Or run this script directly on EC2 (it auto-detects)." + fi + + SSH_OPTS="-i ${EC2_KEY} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10" + SSH_CMD="ssh ${SSH_OPTS} ${EC2_USER}@${EC2_IP}" + + # Test SSH + if ! $SSH_CMD "echo ok" >/dev/null 2>&1; then + error "Cannot SSH to ${EC2_IP}. Is the instance running?" + fi + info "SSH connected to ${EC2_IP}" + + # run_cmd sends the command over SSH + run_cmd() { $SSH_CMD bash -c "$1"; } +fi + +# ── Step 1: Git pull ───────────────────────────────────────────────────────── +step "1/4 Git Pull" +run_cmd ' +cd /app +if [ -d .git ]; then + git fetch origin main 2>&1 || echo "[WARN] git fetch failed — using local files" + git reset --hard origin/main 2>&1 || echo "[WARN] git reset failed" + echo "[OK] Code updated from origin/main" +else + echo "[WARN] /app is not a git repo — skipping pull" +fi +' + +# ── Step 2: Re-pull secrets from SSM (optional) ────────────────────────────── +if $DO_SECRETS; then + step "2/4 Pull Secrets from SSM" + run_cmd ' +cd /app +TOKEN=$(curl -sf -m 2 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60" 2>/dev/null || true) +if [ -n "$TOKEN" ]; then + REGION=$(curl -sf -m 5 -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/region) +else + REGION=$(curl -sf -m 5 http://169.254.169.254/latest/meta-data/placement/region) +fi +if [ -z "$REGION" ]; then REGION="us-east-1"; echo "[WARN] Metadata unavailable, defaulting to us-east-1"; fi + +get_param() { + aws ssm get-parameter \ + --region "$REGION" \ + --name "/mindcraft/$1" \ + --with-decryption \ + --query "Parameter.Value" \ + --output text 2>/dev/null || echo "" +} + +echo "Pulling secrets from SSM /mindcraft/*..." +GEMINI_API_KEY=$(get_param GEMINI_API_KEY) +XAI_API_KEY=$(get_param XAI_API_KEY) +ANTHROPIC_API_KEY=$(get_param ANTHROPIC_API_KEY) +DISCORD_BOT_TOKEN=$(get_param DISCORD_BOT_TOKEN) +BOT_DM_CHANNEL=$(get_param BOT_DM_CHANNEL) +BACKUP_CHAT_CHANNEL=$(get_param BACKUP_CHAT_CHANNEL) +DISCORD_ADMIN_IDS=$(get_param DISCORD_ADMIN_IDS) +TAILSCALE_AUTHKEY=$(get_param TAILSCALE_AUTHKEY) +LITELLM_MASTER_KEY=$(get_param LITELLM_MASTER_KEY) +EC2_PUBLIC_IP=$(get_param EC2_PUBLIC_IP) +GITHUB_TOKEN=$(get_param GITHUB_TOKEN) + +# Strip embedded newlines from SSM values — a multiline value would break +# the .env file format and could inject extra key=value pairs. +strip_nl() { printf '%s' "$1" | tr -d '\n\r'; } +GEMINI_API_KEY=$(strip_nl "$GEMINI_API_KEY") +XAI_API_KEY=$(strip_nl "$XAI_API_KEY") +ANTHROPIC_API_KEY=$(strip_nl "$ANTHROPIC_API_KEY") +DISCORD_BOT_TOKEN=$(strip_nl "$DISCORD_BOT_TOKEN") +BOT_DM_CHANNEL=$(strip_nl "$BOT_DM_CHANNEL") +BACKUP_CHAT_CHANNEL=$(strip_nl "$BACKUP_CHAT_CHANNEL") +DISCORD_ADMIN_IDS=$(strip_nl "$DISCORD_ADMIN_IDS") +TAILSCALE_AUTHKEY=$(strip_nl "$TAILSCALE_AUTHKEY") +LITELLM_MASTER_KEY=$(strip_nl "$LITELLM_MASTER_KEY") +EC2_PUBLIC_IP=$(strip_nl "$EC2_PUBLIC_IP") +GITHUB_TOKEN=$(strip_nl "$GITHUB_TOKEN") + +cat > /app/keys.json < /app/.env <&1 && echo '' && docker compose -f ${COMPOSE_FILE} ps" + +# ── Step 4: Verify bots ────────────────────────────────────────────────────── +step "4/4 Bot Verification" +info "Waiting 15s for bots to connect..." +sleep 15 + +run_cmd ' +cd /app +LOGS=$(docker compose -f docker-compose.aws.yml logs --tail 30 mindcraft 2>&1) +for bot in "CloudGrok" "LocalAndy"; do + if echo "$LOGS" | grep -q "$bot"; then + echo "[OK] $bot appears in logs" + else + echo "[WARN] $bot not found in recent logs (may still be starting)" + fi +done +echo "" +echo "=== Recent logs ===" +echo "$LOGS" | tail -15 +' + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} Deploy complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +if $IS_EC2; then + TOKEN=$(curl -sf -m 2 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60" 2>/dev/null || true) + if [[ -n "$TOKEN" ]]; then + EC2_IP=$(curl -sf -m 2 -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/public-ipv4 2>/dev/null || echo "this-host") + else + EC2_IP=$(curl -sf -m 2 http://169.254.169.254/latest/meta-data/public-ipv4 2>/dev/null || echo "this-host") + fi +fi +echo " Minecraft: ${EC2_IP:-localhost}:19565" +echo " MindServer: http://${EC2_IP:-localhost}:8080" +echo " Grafana: http://${EC2_IP:-localhost}:3004" +echo "" +echo " Logs: docker compose -f /app/docker-compose.aws.yml logs -f mindcraft" +echo "" diff --git a/aws/env-toggle.sh b/aws/env-toggle.sh new file mode 100644 index 000000000..2e805aa88 --- /dev/null +++ b/aws/env-toggle.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/env-toggle.sh — Ensure only one environment runs Mindcraft at a time +# ============================================================================= +# Usage: +# bash aws/env-toggle.sh --aws # Start AWS, stop local +# bash aws/env-toggle.sh --local # Stop AWS workloads, start local +# bash aws/env-toggle.sh --auto # Check EC2 state and toggle accordingly +# bash aws/env-toggle.sh --status # Just show what's running +# +# NOTE: Docker is not accessible from WSL on this system. +# Run local docker commands from Windows CMD/PowerShell. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.env" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[env-toggle]${NC} $*"; } +warn() { echo -e "${YELLOW}[env-toggle]${NC} $*"; } +error() { echo -e "${RED}[env-toggle]${NC} $*"; exit 1; } + +# ── Load config ─────────────────────────────────────────────────────────────── +[[ -f "$CONFIG_FILE" ]] || error "config.env not found. Run aws/setup.sh first." +# shellcheck source=/dev/null +source "$CONFIG_FILE" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +check_ec2_state() { + aws ec2 describe-instances \ + --region "$REGION" \ + --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].State.Name' \ + --output text 2>/dev/null || echo "unknown" +} + +start_aws() { + local state + state=$(check_ec2_state) + if [[ "$state" == "running" ]]; then + info "EC2 already running (${INSTANCE_ID})" + return + fi + info "Starting EC2 instance ${INSTANCE_ID}..." + aws ec2 start-instances --region "$REGION" --instance-ids "$INSTANCE_ID" >/dev/null + aws ec2 wait instance-running --region "$REGION" --instance-ids "$INSTANCE_ID" + + # Get new public IP (changes on stop/start unless Elastic IP) + EC2_IP=$(aws ec2 describe-instances \ + --region "$REGION" \ + --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' \ + --output text) + sed -i "s/^EC2_IP=.*/EC2_IP=${EC2_IP}/" "$CONFIG_FILE" + info "EC2 running at ${EC2_IP}" +} + +stop_aws() { + local state + state=$(check_ec2_state) + if [[ "$state" == "stopped" ]]; then + info "EC2 already stopped." + return + fi + info "Stopping Mindcraft containers on EC2..." + SSH_OPTS="-i ${KEY_FILE} -o StrictHostKeyChecking=no -o ConnectTimeout=5" + ssh ${SSH_OPTS} ubuntu@${EC2_IP} \ + "cd /app && docker compose -f docker-compose.aws.yml stop" 2>/dev/null || true + + info "Stopping EC2 instance ${INSTANCE_ID}..." + aws ec2 stop-instances --region "$REGION" --instance-ids "$INSTANCE_ID" >/dev/null + info "EC2 stopping (saves money while local is active)" +} + +stop_local() { + warn "To stop local Mindcraft containers, run from Windows CMD:" + echo "" + echo " docker compose -f docker-compose.yml stop mindcraft discord-bot" + echo "" + warn "Docker is not accessible from WSL. Run the above in CMD or PowerShell." +} + +start_local() { + warn "To start local Mindcraft containers, run from Windows CMD:" + echo "" + echo " docker compose -f docker-compose.yml up -d" + echo "" + warn "Docker is not accessible from WSL. Run the above in CMD or PowerShell." +} + +show_status() { + local state + state=$(check_ec2_state) + echo "" + info "AWS EC2: ${state} (${INSTANCE_ID})" + if [[ "$state" == "running" ]]; then + echo " IP: ${EC2_IP}" + echo " Minecraft: ${EC2_IP}:19565" + echo " Grafana: http://${EC2_IP}:3004" + echo " MindServer: http://${EC2_IP}:8080" + fi + echo "" + info "Local Docker: check from Windows CMD with 'docker compose ps'" + echo "" +} + +# ── Main ────────────────────────────────────────────────────────────────────── +case "${1:-}" in + --aws) + info "Switching to AWS environment..." + start_aws + stop_local + info "AWS is active. Local containers should be stopped." + ;; + --local) + info "Switching to local environment..." + stop_aws + start_local + info "AWS stopped. Start local containers from Windows CMD." + ;; + --auto) + state=$(check_ec2_state) + if [[ "$state" == "running" ]]; then + info "EC2 is running → ensuring local is stopped" + stop_local + else + info "EC2 is ${state} → local environment should be active" + start_local + fi + ;; + --status) + show_status + ;; + *) + echo "Usage: bash aws/env-toggle.sh [--aws | --local | --auto | --status]" + echo "" + echo " --aws Start AWS EC2, remind to stop local" + echo " --local Stop AWS EC2, remind to start local" + echo " --auto Check EC2 state and advise accordingly" + echo " --status Show what's running where" + exit 1 + ;; +esac diff --git a/aws/restore.sh b/aws/restore.sh new file mode 100644 index 000000000..ca8772989 --- /dev/null +++ b/aws/restore.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/restore.sh — Restore Minecraft world and bot memory from S3 +# ============================================================================= +# Run from local: bash aws/restore.sh +# WARNING: This overwrites local data on EC2. Use with caution. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/config.env" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[RESTORE]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ── Detect if running locally (SSH to EC2) or on EC2 directly ──────────────── +if [[ -f "$CONFIG_FILE" ]]; then + # Running locally — SSH to EC2 and run restore there + # shellcheck source=/dev/null + source "$CONFIG_FILE" + [[ -n "${EC2_IP:-}" ]] || error "EC2_IP not set in config.env" + + echo "" + warn "WARNING: This will OVERWRITE the current world and bot memory on EC2 with S3 data." + read -r -p "Are you sure? Type 'yes' to confirm: " CONFIRM + [[ "$CONFIRM" == "yes" ]] || { echo "Aborted."; exit 0; } + + SSH_OPTS="-i ${KEY_FILE} -o StrictHostKeyChecking=no" + info "Running restore on EC2 (${EC2_IP}) via SSH..." + ssh ${SSH_OPTS} ubuntu@${EC2_IP} "bash /app/aws/restore.sh --confirmed" + exit 0 +fi + +# ── Running ON EC2 ──────────────────────────────────────────────────────────── +[[ "${1:-}" == "--confirmed" ]] || { echo "Run this from your local machine via: bash aws/restore.sh"; exit 1; } + +REGION=$(curl -s http://169.254.169.254/latest/meta-data/placement/region 2>/dev/null \ + || echo "${AWS_DEFAULT_REGION:-us-east-1}") +S3_BUCKET=$(aws ssm get-parameter \ + --region "$REGION" \ + --name "/mindcraft/S3_BUCKET" \ + --with-decryption \ + --query 'Parameter.Value' \ + --output text 2>/dev/null \ + || grep S3_BUCKET /app/.env | cut -d= -f2 || "") + +[[ -n "$S3_BUCKET" ]] || error "S3_BUCKET not found" + +APP_DIR="/app" + +# ── Stop all services ───────────────────────────────────────────────────────── +info "Stopping all containers..." +docker compose -f "${APP_DIR}/docker-compose.aws.yml" stop minecraft mindcraft + +# ── Restore world from S3 ───────────────────────────────────────────────────── +info "Restoring minecraft-data from s3://${S3_BUCKET}/minecraft-data/ ..." +mkdir -p "${APP_DIR}/minecraft-data" +aws s3 sync \ + "s3://${S3_BUCKET}/minecraft-data/" \ + "${APP_DIR}/minecraft-data" \ + --sse AES256 \ + --region "$REGION" \ + --delete + +# ── Restore bot memory from S3 ──────────────────────────────────────────────── +info "Restoring bot memory from s3://${S3_BUCKET}/bots/ ..." +aws s3 sync \ + "s3://${S3_BUCKET}/bots/" \ + "${APP_DIR}/bots/" \ + --sse AES256 \ + --region "$REGION" + +# ── Restart services ────────────────────────────────────────────────────────── +info "Restarting containers..." +docker compose -f "${APP_DIR}/docker-compose.aws.yml" up -d minecraft mindcraft + +info "Restore complete." diff --git a/aws/s3-policy.json b/aws/s3-policy.json new file mode 100644 index 000000000..5283d3f64 --- /dev/null +++ b/aws/s3-policy.json @@ -0,0 +1,51 @@ +{ + "_comment": "Template — actual values are filled in by aws/setup.sh. Do not use directly.", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DenyHTTP", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::BUCKET_NAME", + "arn:aws:s3:::BUCKET_NAME/*" + ], + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + } + }, + { + "Sid": "AllowEC2Role", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::ACCOUNT_ID:role/mindcraft-ec2-role" + }, + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:GetBucketLocation" + ], + "Resource": [ + "arn:aws:s3:::BUCKET_NAME", + "arn:aws:s3:::BUCKET_NAME/*" + ] + }, + { + "Sid": "AllowTylerIAM", + "Effect": "Allow", + "Principal": { + "AWS": "CALLER_ARN" + }, + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::BUCKET_NAME", + "arn:aws:s3:::BUCKET_NAME/*" + ] + } + ] +} diff --git a/aws/setup-ollama-proxy.sh b/aws/setup-ollama-proxy.sh new file mode 100644 index 000000000..e702a0771 --- /dev/null +++ b/aws/setup-ollama-proxy.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/setup-ollama-proxy.sh — Set up socat proxy for Ollama via Tailscale +# ============================================================================= +# Runs on EC2 host. Creates a systemd service that proxies localhost:11435 +# to the local Ollama instance via Tailscale (set OLLAMA_TAILSCALE_IP env var). +# +# Why: Docker containers (even with network_mode: host) have issues routing +# data through Tailscale's TUN interface. This proxy runs as a native host +# process, which can use Tailscale routing without issues. +# +# Usage: +# sudo bash /app/aws/setup-ollama-proxy.sh +# ============================================================================= +set -euo pipefail + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +if [ -z "${OLLAMA_TAILSCALE_IP:-}" ]; then + error "OLLAMA_TAILSCALE_IP is required. Set it to your local machine's Tailscale IP (e.g. 100.x.x.x)" +fi +OLLAMA_REMOTE="${OLLAMA_TAILSCALE_IP}:11434" +PROXY_PORT="11435" +SERVICE_NAME="ollama-proxy" + +# ── Install socat if needed ────────────────────────────────────────────────── +if ! command -v socat &>/dev/null; then + info "Installing socat..." + apt-get update -qq && apt-get install -y -qq socat +else + info "socat already installed." +fi + +# ── Create systemd service ─────────────────────────────────────────────────── +info "Creating systemd service: ${SERVICE_NAME}" +cat > /etc/systemd/system/${SERVICE_NAME}.service < ${OLLAMA_REMOTE}) +After=network.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/socat TCP4-LISTEN:${PROXY_PORT},bind=127.0.0.1,reuseaddr,fork TCP4:${OLLAMA_REMOTE} +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF + +# ── Kill any stale socat processes on the proxy port ───────────────────────── +if pgrep -f "socat.*${PROXY_PORT}" &>/dev/null; then + info "Killing stale socat processes on port ${PROXY_PORT}..." + pkill -f "socat.*${PROXY_PORT}" || true + sleep 1 +fi + +# ── Enable and start ───────────────────────────────────────────────────────── +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}" +systemctl restart "${SERVICE_NAME}" +sleep 2 + +# ── Verify ─────────────────────────────────────────────────────────────────── +if systemctl is-active --quiet "${SERVICE_NAME}"; then + info "Service ${SERVICE_NAME} is running." +else + warn "Service ${SERVICE_NAME} failed to start. Check: journalctl -u ${SERVICE_NAME}" +fi + +# Quick connectivity test +info "Testing proxy connectivity..." +if curl -s --max-time 10 "http://127.0.0.1:${PROXY_PORT}/api/tags" | grep -q "models"; then + info "Ollama reachable through proxy at localhost:${PROXY_PORT}" +else + warn "Could not reach Ollama through proxy. Is Ollama running on your local machine?" + warn "Is Tailscale connected? Check: tailscale status (in the tailscale container)" +fi + +# ── Restart mindcraft to pick up the new profile URL ───────────────────────── +info "Restarting mindcraft container..." +cd /app +docker compose -f docker-compose.aws.yml up -d --no-deps --force-recreate mindcraft + +echo "" +info "Done! LocalAndy will now connect to Ollama via localhost:${PROXY_PORT} -> Tailscale -> ${OLLAMA_REMOTE}" +echo "" diff --git a/aws/setup.sh b/aws/setup.sh new file mode 100644 index 000000000..a3cb2f3cd --- /dev/null +++ b/aws/setup.sh @@ -0,0 +1,464 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/setup.sh — Mindcraft AWS Infrastructure Setup +# ============================================================================= +# Creates: VPC, Security Group, S3 bucket, IAM role, SSM parameters, EC2 instance +# Run once from WSL: bash aws/setup.sh +# ============================================================================= +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────────────────────── +REGION="${AWS_REGION:-us-east-1}" +INSTANCE_TYPE="t3.large" +AMI_NAME_FILTER="ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" +KEY_NAME="mindcraft-ec2" +KEY_FILE="$(dirname "$0")/mindcraft-ec2.pem" +CONFIG_FILE="$(dirname "$0")/config.env" +APP_DIR="/app" +STACK_NAME="mindcraft" +# S3 bucket name must be globally unique; we append account ID +BUCKET_PREFIX="mindcraft-world-backups" + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } + +# ── Prerequisites ───────────────────────────────────────────────────────────── +info "Checking prerequisites..." + +command -v aws >/dev/null 2>&1 || error "AWS CLI not found. Install: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-linux.html + Quick install: + curl 'https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip' -o /tmp/awscliv2.zip + unzip /tmp/awscliv2.zip -d /tmp + sudo /tmp/aws/install" + +command -v jq >/dev/null 2>&1 || error "jq not found. Install: sudo apt-get install -y jq" +command -v ssh >/dev/null 2>&1 || error "ssh not found." + +# Verify credentials +CALLER=$(aws sts get-caller-identity 2>/dev/null) \ + || error "AWS credentials not configured. Run: aws configure + You'll need: + - AWS Access Key ID (from IAM → Security credentials) + - AWS Secret Access Key (same page) + - Default region: ${REGION} + - Output format: json" + +ACCOUNT_ID=$(echo "$CALLER" | jq -r '.Account') +CALLER_ARN=$(echo "$CALLER" | jq -r '.Arn') +BUCKET_NAME="${BUCKET_PREFIX}-${ACCOUNT_ID}" + +info "AWS account: ${ACCOUNT_ID}" +info "Caller ARN: ${CALLER_ARN}" +info "Region: ${REGION}" +info "S3 bucket: ${BUCKET_NAME}" + +# ── Tyler's IP for SSH/admin access ────────────────────────────────────────── +DETECTED_IP=$(curl -s https://checkip.amazonaws.com 2>/dev/null || curl -s https://api.ipify.org 2>/dev/null || echo "") + +if [[ -n "$DETECTED_IP" ]]; then + echo "" + read -r -p "Your current IP appears to be ${DETECTED_IP}. Use this to restrict admin ports? [Y/n] " USE_DETECTED + if [[ "${USE_DETECTED,,}" != "n" ]]; then + ADMIN_IP="${DETECTED_IP}" + else + read -r -p "Enter your IP address (for SSH/Grafana/UI access): " ADMIN_IP + fi +else + read -r -p "Enter your IP address (for SSH/Grafana/UI access): " ADMIN_IP +fi + +ADMIN_CIDR="${ADMIN_IP}/32" +info "Admin CIDR: ${ADMIN_CIDR}" + +# ── SSM Secrets collection ──────────────────────────────────────────────────── +echo "" +info "Collecting secrets for SSM Parameter Store (stored encrypted, never in git)..." +echo " Press Enter to skip any key you don't use." + +collect_secret() { + local name="$1" prompt="$2" + local input="" char + printf " %s: " "$prompt" > /dev/tty + while IFS= read -r -s -n1 char < /dev/tty; do + if [[ -z "$char" ]]; then # Enter + break + elif [[ "$char" == $'\177' ]] || [[ "$char" == $'\b' ]]; then # Backspace + if [[ -n "$input" ]]; then + input="${input%?}" + printf '\b \b' > /dev/tty + fi + else + input+="$char" + printf '*' > /dev/tty # Show * per character (not captured by $()) + fi + done + printf '\n' > /dev/tty + printf '%s' "$input" +} + +GEMINI_API_KEY=$(collect_secret "GEMINI_API_KEY" "GEMINI_API_KEY") +XAI_API_KEY=$(collect_secret "XAI_API_KEY" "XAI_API_KEY (also used as OPENAI_API_KEY)") +ANTHROPIC_API_KEY=$(collect_secret "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEY") +DISCORD_BOT_TOKEN=$(collect_secret "DISCORD_BOT_TOKEN" "DISCORD_BOT_TOKEN") +BOT_DM_CHANNEL=$(collect_secret "BOT_DM_CHANNEL" "BOT_DM_CHANNEL (Discord channel ID)") +BACKUP_CHAT_CHANNEL=$(collect_secret "BACKUP_CHAT_CHANNEL" "BACKUP_CHAT_CHANNEL (Discord channel ID)") + +echo "" + +# ============================================================================= +# 1. VPC +# ============================================================================= +info "Creating VPC..." +VPC_ID=$(aws ec2 create-vpc \ + --region "$REGION" \ + --cidr-block 10.0.0.0/16 \ + --tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=${STACK_NAME}-vpc}]" \ + --query 'Vpc.VpcId' --output text) +aws ec2 modify-vpc-attribute --region "$REGION" --vpc-id "$VPC_ID" --enable-dns-support +aws ec2 modify-vpc-attribute --region "$REGION" --vpc-id "$VPC_ID" --enable-dns-hostnames +info "VPC: ${VPC_ID}" + +# Subnet +SUBNET_ID=$(aws ec2 create-subnet \ + --region "$REGION" \ + --vpc-id "$VPC_ID" \ + --cidr-block 10.0.1.0/24 \ + --availability-zone "${REGION}a" \ + --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=${STACK_NAME}-subnet}]" \ + --query 'Subnet.SubnetId' --output text) +aws ec2 modify-subnet-attribute --region "$REGION" --subnet-id "$SUBNET_ID" --map-public-ip-on-launch +info "Subnet: ${SUBNET_ID}" + +# Internet Gateway +IGW_ID=$(aws ec2 create-internet-gateway \ + --region "$REGION" \ + --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=${STACK_NAME}-igw}]" \ + --query 'InternetGateway.InternetGatewayId' --output text) +aws ec2 attach-internet-gateway --region "$REGION" --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" +info "IGW: ${IGW_ID}" + +# Route table +RTB_ID=$(aws ec2 create-route-table \ + --region "$REGION" \ + --vpc-id "$VPC_ID" \ + --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=${STACK_NAME}-rtb}]" \ + --query 'RouteTable.RouteTableId' --output text) +aws ec2 create-route --region "$REGION" --route-table-id "$RTB_ID" --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW_ID" >/dev/null +aws ec2 associate-route-table --region "$REGION" --route-table-id "$RTB_ID" --subnet-id "$SUBNET_ID" >/dev/null +info "Route table: ${RTB_ID}" + +# ============================================================================= +# 2. Security Group +# ============================================================================= +info "Creating security group..." +SG_ID=$(aws ec2 create-security-group \ + --region "$REGION" \ + --group-name "${STACK_NAME}-sg" \ + --description "Mindcraft server security group" \ + --vpc-id "$VPC_ID" \ + --query 'GroupId' --output text) + +# Minecraft — open to world (players connect) +aws ec2 authorize-security-group-ingress --region "$REGION" --group-id "$SG_ID" \ + --ip-permissions "IpProtocol=tcp,FromPort=19565,ToPort=19565,IpRanges=[{CidrIp=0.0.0.0/0,Description='Minecraft'}]" >/dev/null + +# Admin ports — Tyler's IP only +for PORT_DESC in "22:SSH" "3004:Grafana" "8080:MindServerUI" "9090:Prometheus"; do + PORT="${PORT_DESC%%:*}"; DESC="${PORT_DESC##*:}" + aws ec2 authorize-security-group-ingress --region "$REGION" --group-id "$SG_ID" \ + --ip-permissions "IpProtocol=tcp,FromPort=${PORT},ToPort=${PORT},IpRanges=[{CidrIp=${ADMIN_CIDR},Description='${DESC} - Tyler only'}]" >/dev/null +done + +# All outbound (for LLM API calls) +aws ec2 authorize-security-group-egress --region "$REGION" --group-id "$SG_ID" \ + --ip-permissions "IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]" 2>/dev/null || true + +aws ec2 create-tags --region "$REGION" --resources "$SG_ID" \ + --tags "Key=Name,Value=${STACK_NAME}-sg" +info "Security group: ${SG_ID}" + +# ============================================================================= +# 3. S3 Bucket +# ============================================================================= +info "Creating S3 bucket: ${BUCKET_NAME}..." + +# Create bucket (us-east-1 doesn't use --create-bucket-configuration) +if [[ "$REGION" == "us-east-1" ]]; then + aws s3api create-bucket --region "$REGION" --bucket "$BUCKET_NAME" >/dev/null +else + aws s3api create-bucket --region "$REGION" --bucket "$BUCKET_NAME" \ + --create-bucket-configuration "LocationConstraint=${REGION}" >/dev/null +fi + +# Block ALL public access +aws s3api put-public-access-block --bucket "$BUCKET_NAME" \ + --public-access-block-configuration \ + "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" + +# Versioning ON +aws s3api put-bucket-versioning --bucket "$BUCKET_NAME" \ + --versioning-configuration Status=Enabled + +# SSE-S3 encryption (AES-256) +aws s3api put-bucket-encryption --bucket "$BUCKET_NAME" \ + --server-side-encryption-configuration '{ + "Rules": [{ + "ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}, + "BucketKeyEnabled": true + }] + }' + +# Lifecycle: keep 30 versions, expire noncurrent after 90 days +aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET_NAME" \ + --lifecycle-configuration '{ + "Rules": [{ + "ID": "keep-30-versions", + "Status": "Enabled", + "Filter": {"Prefix": ""}, + "NoncurrentVersionExpiration": {"NoncurrentDays": 90}, + "NoncurrentVersionTransitions": [], + "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7} + }] + }' + +info "S3 bucket configured." + +# ============================================================================= +# 4. IAM Role for EC2 +# ============================================================================= +info "Creating IAM role: ${STACK_NAME}-ec2-role..." + +ROLE_NAME="${STACK_NAME}-ec2-role" +POLICY_NAME="${STACK_NAME}-ec2-policy" +INSTANCE_PROFILE_NAME="${STACK_NAME}-ec2-profile" + +# Trust policy +aws iam create-role \ + --role-name "$ROLE_NAME" \ + --assume-role-policy-document '{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }' \ + --description "Mindcraft EC2 instance role" >/dev/null + +# Permissions policy: S3 + SSM +ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${ROLE_NAME}" + +aws iam put-role-policy \ + --role-name "$ROLE_NAME" \ + --policy-name "$POLICY_NAME" \ + --policy-document "{ + \"Version\": \"2012-10-17\", + \"Statement\": [ + { + \"Sid\": \"S3BucketAccess\", + \"Effect\": \"Allow\", + \"Action\": [\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\",\"s3:ListBucket\",\"s3:GetBucketLocation\"], + \"Resource\": [ + \"arn:aws:s3:::${BUCKET_NAME}\", + \"arn:aws:s3:::${BUCKET_NAME}/*\" + ] + }, + { + \"Sid\": \"SSMParameterAccess\", + \"Effect\": \"Allow\", + \"Action\": [\"ssm:GetParameter\",\"ssm:GetParameters\",\"ssm:GetParametersByPath\"], + \"Resource\": \"arn:aws:ssm:${REGION}:${ACCOUNT_ID}:parameter/mindcraft/*\" + } + ] + }" + +# Instance profile +aws iam create-instance-profile \ + --instance-profile-name "$INSTANCE_PROFILE_NAME" >/dev/null +aws iam add-role-to-instance-profile \ + --instance-profile-name "$INSTANCE_PROFILE_NAME" \ + --role-name "$ROLE_NAME" + +info "IAM role: ${ROLE_ARN}" + +# Wait for role to propagate +info "Waiting for IAM role propagation (10s)..." +sleep 10 + +# ============================================================================= +# 5. S3 Bucket Policy (EC2 role + Tyler IAM only) +# ============================================================================= +info "Applying S3 bucket policy..." +aws s3api put-bucket-policy --bucket "$BUCKET_NAME" \ + --policy "{ + \"Version\": \"2012-10-17\", + \"Statement\": [ + { + \"Sid\": \"DenyHTTP\", + \"Effect\": \"Deny\", + \"Principal\": \"*\", + \"Action\": \"s3:*\", + \"Resource\": [ + \"arn:aws:s3:::${BUCKET_NAME}\", + \"arn:aws:s3:::${BUCKET_NAME}/*\" + ], + \"Condition\": {\"Bool\": {\"aws:SecureTransport\": \"false\"}} + }, + { + \"Sid\": \"AllowEC2Role\", + \"Effect\": \"Allow\", + \"Principal\": {\"AWS\": \"${ROLE_ARN}\"}, + \"Action\": [\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\",\"s3:ListBucket\",\"s3:GetBucketLocation\"], + \"Resource\": [ + \"arn:aws:s3:::${BUCKET_NAME}\", + \"arn:aws:s3:::${BUCKET_NAME}/*\" + ] + }, + { + \"Sid\": \"AllowTylerIAM\", + \"Effect\": \"Allow\", + \"Principal\": {\"AWS\": \"${CALLER_ARN}\"}, + \"Action\": \"s3:*\", + \"Resource\": [ + \"arn:aws:s3:::${BUCKET_NAME}\", + \"arn:aws:s3:::${BUCKET_NAME}/*\" + ] + } + ] + }" +info "Bucket policy applied." + +# ============================================================================= +# 6. SSM Parameters +# ============================================================================= +info "Storing secrets in SSM Parameter Store..." + +put_param() { + local name="$1" value="$2" + if [[ -n "$value" ]]; then + aws ssm put-parameter --region "$REGION" \ + --name "/mindcraft/${name}" \ + --value "$value" \ + --type SecureString \ + --overwrite >/dev/null + info " Stored /mindcraft/${name}" + else + warn " Skipped /mindcraft/${name} (empty)" + fi +} + +put_param "GEMINI_API_KEY" "$GEMINI_API_KEY" +put_param "XAI_API_KEY" "$XAI_API_KEY" +put_param "ANTHROPIC_API_KEY" "$ANTHROPIC_API_KEY" +put_param "DISCORD_BOT_TOKEN" "$DISCORD_BOT_TOKEN" +put_param "BOT_DM_CHANNEL" "$BOT_DM_CHANNEL" +put_param "BACKUP_CHAT_CHANNEL" "$BACKUP_CHAT_CHANNEL" +put_param "S3_BUCKET" "$BUCKET_NAME" + +# ============================================================================= +# 7. EC2 Key Pair +# ============================================================================= +info "Creating EC2 key pair: ${KEY_NAME}..." + +if aws ec2 describe-key-pairs --region "$REGION" --key-names "$KEY_NAME" >/dev/null 2>&1; then + warn "Key pair '${KEY_NAME}' already exists. Delete it first if you want a new one:" + warn " aws ec2 delete-key-pair --region ${REGION} --key-name ${KEY_NAME}" +else + aws ec2 create-key-pair \ + --region "$REGION" \ + --key-name "$KEY_NAME" \ + --query 'KeyMaterial' \ + --output text > "$KEY_FILE" + chmod 600 "$KEY_FILE" + info "Private key saved to: ${KEY_FILE}" +fi + +# ============================================================================= +# 8. EC2 Instance +# ============================================================================= +info "Finding latest Ubuntu 24.04 AMI..." +AMI_ID=$(aws ec2 describe-images \ + --region "$REGION" \ + --owners 099720109477 \ + --filters "Name=name,Values=${AMI_NAME_FILTER}" \ + "Name=state,Values=available" \ + "Name=architecture,Values=x86_64" \ + --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ + --output text) +info "AMI: ${AMI_ID}" + +info "Launching EC2 instance (${INSTANCE_TYPE})..." +INSTANCE_ID=$(aws ec2 run-instances \ + --region "$REGION" \ + --image-id "$AMI_ID" \ + --instance-type "$INSTANCE_TYPE" \ + --key-name "$KEY_NAME" \ + --subnet-id "$SUBNET_ID" \ + --security-group-ids "$SG_ID" \ + --iam-instance-profile "Name=${INSTANCE_PROFILE_NAME}" \ + --block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":30,"VolumeType":"gp3","DeleteOnTermination":true}}]' \ + --user-data "file://$(dirname "$0")/user-data.sh" \ + --tag-specifications \ + "ResourceType=instance,Tags=[{Key=Name,Value=${STACK_NAME}-server}]" \ + "ResourceType=volume,Tags=[{Key=Name,Value=${STACK_NAME}-root}]" \ + --query 'Instances[0].InstanceId' \ + --output text) + +info "Instance launched: ${INSTANCE_ID}" +info "Waiting for instance to reach running state..." +aws ec2 wait instance-running --region "$REGION" --instance-ids "$INSTANCE_ID" + +EC2_IP=$(aws ec2 describe-instances \ + --region "$REGION" \ + --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].PublicIpAddress' \ + --output text) + +# ============================================================================= +# 9. Write config.env +# ============================================================================= +cat > "$CONFIG_FILE" </dev/null || warn "Could not terminate instance (may not exist)" + info "Waiting for termination..." + aws ec2 wait instance-terminated --region "$REGION" --instance-ids "$INSTANCE_ID" 2>/dev/null || true +fi + +# ── Delete key pair ─────────────────────────────────────────────────────────── +if [[ -n "${KEY_NAME:-}" ]]; then + info "Deleting key pair: ${KEY_NAME}..." + aws ec2 delete-key-pair --region "$REGION" --key-name "$KEY_NAME" 2>/dev/null || warn "Key pair not found" +fi + +# ── Delete IAM ──────────────────────────────────────────────────────────────── +if [[ -n "${INSTANCE_PROFILE_NAME:-}" ]]; then + info "Removing IAM instance profile: ${INSTANCE_PROFILE_NAME}..." + aws iam remove-role-from-instance-profile \ + --instance-profile-name "$INSTANCE_PROFILE_NAME" \ + --role-name "${ROLE_NAME}" 2>/dev/null || true + aws iam delete-instance-profile \ + --instance-profile-name "$INSTANCE_PROFILE_NAME" 2>/dev/null || warn "Instance profile not found" +fi + +if [[ -n "${ROLE_NAME:-}" ]]; then + info "Deleting IAM role: ${ROLE_NAME}..." + # Delete inline policies first + POLICIES=$(aws iam list-role-policies --role-name "$ROLE_NAME" --query 'PolicyNames' --output text 2>/dev/null || echo "") + for p in $POLICIES; do + aws iam delete-role-policy --role-name "$ROLE_NAME" --policy-name "$p" 2>/dev/null || true + done + aws iam delete-role --role-name "$ROLE_NAME" 2>/dev/null || warn "Role not found" +fi + +# ── Delete SSM parameters ───────────────────────────────────────────────────── +info "Deleting SSM parameters at /mindcraft/..." +PARAMS=$(aws ssm describe-parameters \ + --region "$REGION" \ + --parameter-filters "Key=Path,Values=/mindcraft" \ + --query 'Parameters[].Name' \ + --output text 2>/dev/null || echo "") +for p in $PARAMS; do + aws ssm delete-parameter --region "$REGION" --name "$p" 2>/dev/null || true + info " Deleted ${p}" +done + +# ── Delete security group ───────────────────────────────────────────────────── +if [[ -n "${SG_ID:-}" ]]; then + info "Deleting security group: ${SG_ID}..." + # Wait a moment for EC2 to fully detach + sleep 10 + aws ec2 delete-security-group --region "$REGION" --group-id "$SG_ID" 2>/dev/null || warn "SG not found or still in use" +fi + +# ── Delete VPC components ───────────────────────────────────────────────────── +if [[ -n "${SUBNET_ID:-}" ]]; then + info "Deleting subnet: ${SUBNET_ID}..." + aws ec2 delete-subnet --region "$REGION" --subnet-id "$SUBNET_ID" 2>/dev/null || warn "Subnet not found" +fi + +if [[ -n "${IGW_ID:-}" && -n "${VPC_ID:-}" ]]; then + info "Detaching and deleting IGW: ${IGW_ID}..." + aws ec2 detach-internet-gateway --region "$REGION" --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" 2>/dev/null || true + aws ec2 delete-internet-gateway --region "$REGION" --internet-gateway-id "$IGW_ID" 2>/dev/null || warn "IGW not found" +fi + +if [[ -n "${VPC_ID:-}" ]]; then + # Delete route tables (non-main) + RTB_IDS=$(aws ec2 describe-route-tables \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=${VPC_ID}" \ + --query 'RouteTables[?Associations[0].Main!=`true`].RouteTableId' \ + --output text 2>/dev/null || echo "") + for rtb in $RTB_IDS; do + aws ec2 delete-route-table --region "$REGION" --route-table-id "$rtb" 2>/dev/null || true + done + + info "Deleting VPC: ${VPC_ID}..." + aws ec2 delete-vpc --region "$REGION" --vpc-id "$VPC_ID" 2>/dev/null || warn "VPC not found or has dependencies" +fi + +# ── S3 bucket (optional) ────────────────────────────────────────────────────── +if [[ "${DELETE_S3,,}" == "y" && -n "${BUCKET_NAME:-}" ]]; then + warn "Deleting S3 bucket and ALL contents: ${BUCKET_NAME}..." + # Remove all versions and delete markers first + aws s3api delete-objects \ + --bucket "$BUCKET_NAME" \ + --delete "$(aws s3api list-object-versions \ + --bucket "$BUCKET_NAME" \ + --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' \ + --output json 2>/dev/null)" >/dev/null 2>&1 || true + aws s3 rm "s3://${BUCKET_NAME}" --recursive 2>/dev/null || true + aws s3api delete-bucket --bucket "$BUCKET_NAME" --region "$REGION" 2>/dev/null || warn "Bucket not found" + info "S3 bucket deleted." +else + info "S3 bucket preserved: ${BUCKET_NAME} (your backups are safe)" +fi + +# ── Clean up local config ───────────────────────────────────────────────────── +if [[ -f "$CONFIG_FILE" ]]; then + rm -f "$CONFIG_FILE" + info "Removed config.env" +fi +if [[ -f "${SCRIPT_DIR}/mindcraft-ec2.pem" ]]; then + rm -f "${SCRIPT_DIR}/mindcraft-ec2.pem" + info "Removed mindcraft-ec2.pem" +fi + +echo "" +echo -e "${GREEN}Teardown complete.${NC}" diff --git a/aws/user-data.sh b/aws/user-data.sh new file mode 100644 index 000000000..af46e9baa --- /dev/null +++ b/aws/user-data.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# ============================================================================= +# aws/user-data.sh — EC2 First-Boot Bootstrap Script +# ============================================================================= +# Runs as root on first boot via EC2 user-data. +# Installs Docker, Docker Compose plugin, AWS CLI v2. +# The actual app deployment is handled by aws/deploy.sh from your local machine. +# ============================================================================= +set -euo pipefail +exec > /var/log/user-data.log 2>&1 + +echo "=== Mindcraft EC2 Bootstrap: $(date) ===" + +# ── System update ───────────────────────────────────────────────────────────── +apt-get update -y +apt-get upgrade -y +apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release \ + unzip \ + htop \ + git \ + jq \ + cron + +# ── Docker ──────────────────────────────────────────────────────────────────── +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list + +apt-get update -y +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +systemctl enable docker +systemctl start docker + +# Allow ubuntu user to run docker without sudo +usermod -aG docker ubuntu + +# ── AWS CLI v2 ──────────────────────────────────────────────────────────────── +curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip +unzip -q /tmp/awscliv2.zip -d /tmp +/tmp/aws/install +rm -rf /tmp/aws /tmp/awscliv2.zip + +# ── App directory ───────────────────────────────────────────────────────────── +mkdir -p /app +chown ubuntu:ubuntu /app + +# ── Cron: backup every 6 hours ──────────────────────────────────────────────── +# The actual backup script is deployed by aws/deploy.sh +# Cron job added after deploy.sh runs for the first time via: +# sudo crontab -u ubuntu /app/aws/cron.tab +# Placeholder created here so the file exists +cat > /app/aws-cron.tab <<'CRON' +# Mindcraft world backup — every 6 hours +0 */6 * * * /app/aws/backup.sh >> /var/log/mindcraft-backup.log 2>&1 +CRON +chown ubuntu:ubuntu /app/aws-cron.tab + +# ── Ready marker ────────────────────────────────────────────────────────────── +touch /var/lib/cloud/instance/mindcraft-bootstrap-done +echo "=== Bootstrap complete: $(date) ===" +echo "Waiting for aws/deploy.sh to push application files..." diff --git a/bots/execTemplate.js b/bots/execTemplate.js index b7f270c9f..9ea5f6108 100644 --- a/bots/execTemplate.js +++ b/bots/execTemplate.js @@ -3,4 +3,4 @@ /* CODE HERE */ log(bot, 'Code finished.'); -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/bots/lintTemplate.js b/bots/lintTemplate.js index 77b5d975f..6b95ae7d2 100644 --- a/bots/lintTemplate.js +++ b/bots/lintTemplate.js @@ -1,6 +1,6 @@ import * as skills from '../../../src/agent/library/skills.js'; -import * as world from '../../../src/agent/library/world.js'; -import Vec3 from 'vec3'; +import * as _world from '../../../src/agent/library/world.js'; +import _Vec3 from 'vec3'; const log = skills.log; diff --git a/data/minecraft_wiki.json b/data/minecraft_wiki.json new file mode 100644 index 000000000..fa6845e95 --- /dev/null +++ b/data/minecraft_wiki.json @@ -0,0 +1,35 @@ +{ + "versions": { + "latest": "1.21.11 (Mounts of Mayhem, December 2025)", + "recent": ["1.21", "1.21.1", "1.21.2", "1.21.3", "1.21.4", "1.21.5"], + "key_updates": "Pre-Classic to modern Java Edition; focus on 1.21+ for current recipes/mobs." + }, + "recipes": { + "crafting": { + "system": "2x2 (shaped/shapeless) or 3x3 grid in crafting table/recipe book; manual or automated (e.g., auto-crafters).", + "examples": ["Tools/armor from ingots", "Planks from logs", "Sticks from planks"] + }, + "smelting": { + "system": "Furnace/blast furnace/smoker; fuels: coal/lava (100 items), etc.", + "food": ["Potato → Baked Potato", "Beef → Steak"], + "ores": ["Iron Ore → Iron Ingot", "Gold Ore → Gold Ingot", "Copper Ore → Copper Ingot"], + "gear": ["Iron Ingot → Iron Nugget"], + "fuels": {"Coal": 8, "Charcoal": 8, "Lava Bucket": 100} + } + }, + "items": { + "top": ["All block-items (e.g., Acacia Log)", "Apple", "Arrow", "Beacon", "Bedrock", "Blaze Rod", "Bone", "Book", "Bow", "Bread", "Bucket", "Chest", "Clock", "Coal", "Cobblestone", "Compass", "Diamond", "Dirt", "Enchanting Table", "Ender Pearl", "Flint", "Glass", "Gold Ingot", "Iron Ingot", "Leather", "Map", "Melon Slice", "Milk Bucket", "Nether Star", "Oak Planks", "Obsidian", "Paper", "Porkchop", "Redstone Dust", "Stick", "Stone", "Sugar", "Sword", "Totem of Undying", "Wheat", "Wooden Axe", "Wooden Pickaxe", "Wooden Sword"], + "categories": ["Block-items", "World-interaction", "Tools", "Food", "Materials"] + }, + "blocks": { + "top": ["Acacia Log", "Tuff", "Deepslate", "Andesite", "Diorite", "Granite", "Cobblestone", "Stone", "Dirt", "Grass Block", "Sand", "Gravel", "Oak Planks", "Oak Log", "Bedrock", "Obsidian"], + "categories": ["Natural", "Technical", "Education", "Removed"] + }, + "mobs": { + "passive": ["Allay", "Armadillo", "Axolotl", "Bat", "Camel", "Cat", "Chicken", "Cod", "Cow", "Donkey", "Fox", "Frog", "Glow Squid", "Horse", "Mooshroom", "Mule", "Ocelot", "Parrot", "Pig", "Pufferfish", "Rabbit", "Salmon", "Sheep", "Skeleton Horse", "Sniffer", "Squid", "Strider", "Tadpole", "Tropical Fish", "Turtle", "Villager", "Wandering Trader"], + "neutral": ["Bee", "Dolphin", "Enderman", "Fox", "Goat", "Iron Golem", "Llama", "Panda", "Piglin", "Polar Bear", "Spider", "Cave Spider", "Zombified Piglin", "Wolf"], + "hostile": ["Blaze", "Breeze", "Bogged", "Creeper", "Creaking", "Drowned", "Elder Guardian", "Endermite", "Evoker", "Ghast", "Guardian", "Hoglin", "Husk", "Magma Cube", "Phantom", "Piglin Brute", "Pillager", "Ravager", "Shulker", "Silverfish", "Skeleton", "Slime", "Stray", "Vex", "Vindicator", "Witch", "Wither Skeleton", "Zombie", "Zombie Villager"], + "bosses": ["Ender Dragon", "Wither"], + "unused": ["Giant", "Illusioner", "Killer Bunny", "Old Villager", "Zombie Horse"] + } +} \ No newline at end of file diff --git a/discord-bot.js b/discord-bot.js new file mode 100644 index 000000000..9b8005d58 --- /dev/null +++ b/discord-bot.js @@ -0,0 +1,1258 @@ + +import { Client, GatewayIntentBits, Partials } from 'discord.js'; +import { io } from 'socket.io-client'; +import { readFile, writeFile } from 'fs/promises'; +import { readFileSync } from 'fs'; +import { join, dirname, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { validateDiscordMessage } from './src/utils/message_validator.js'; +import { RateLimiter } from './src/utils/rate_limiter.js'; +import { deepSanitize } from './settings.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROFILES_DIR = join(__dirname, 'profiles'); +const ACTIVE_PROFILES = ['cloud-persistent', 'local-research', 'claude-explorer']; + +// ── Admin Authorization ────────────────────────────────────── +// Comma-separated Discord user IDs allowed to run destructive commands. +// If empty, only users with the admin role (default "admin") are allowed. +const ADMIN_USER_IDS = (process.env.DISCORD_ADMIN_IDS || '').split(',').map(s => s.trim()).filter(Boolean); +const DISCORD_ADMIN_ROLE = (process.env.DISCORD_ADMIN_ROLE || 'admin').toLowerCase(); +const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || ''; + +if (ADMIN_USER_IDS.length === 0) { + console.warn('[Auth] DISCORD_ADMIN_IDS is empty — only users with the admin role can run destructive commands.'); +} + +function isAdmin(userId, member) { + // Check explicit user ID list + if (ADMIN_USER_IDS.includes(userId)) return true; + // Check Discord server role (guild messages only) + if (member?.roles?.cache?.some(r => r.name.toLowerCase() === DISCORD_ADMIN_ROLE)) return true; + return false; +} + +// ── Profile Name Validation ────────────────────────────────── +// Only allow alphanumeric, underscore, and hyphen characters to prevent +// path traversal attacks when profile names are used in file paths. +function isValidProfileName(name) { + return typeof name === 'string' && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64; +} + +function safeProfilePath(name) { + if (!isValidProfileName(name)) { + throw new Error(`Invalid profile name: "${name}"`); + } + const filePath = join(PROFILES_DIR, `${name}.json`); + const resolvedDir = resolve(PROFILES_DIR); + const resolvedFile = resolve(filePath); + if (!resolvedFile.startsWith(resolvedDir + sep)) { + throw new Error(`Path traversal detected for profile: "${name}"`); + } + return filePath; +} + +// ── Bot Groups (name → agent names) ───────────────────────── +// Reads agent names from profile JSON files at startup so the map +// stays in sync with whatever profiles are configured. +function loadProfileAgentMap() { + const map = {}; + for (const profileName of ACTIVE_PROFILES) { + try { + const filePath = safeProfilePath(profileName); + const profile = deepSanitize(JSON.parse(readFileSync(filePath, 'utf8'))); + if (profile.name) map[profileName] = profile.name; + } catch { /* profile may not exist yet */ } + } + return map; +} +const PROFILE_AGENT_MAP = loadProfileAgentMap(); +const allAgentNames = Object.values(PROFILE_AGENT_MAP); +const BOT_GROUPS = { + all: allAgentNames.length > 0 ? allAgentNames : ['CloudGrok', 'LocalAndy'], + cloud: allAgentNames.filter(n => PROFILE_AGENT_MAP['cloud-persistent'] === n), + local: allAgentNames.filter(n => PROFILE_AGENT_MAP['local-research'] === n), + research: allAgentNames.length > 0 ? [...allAgentNames] : ['LocalAndy', 'CloudGrok'], +}; +console.log('[Boot] Profile→Agent map:', JSON.stringify(PROFILE_AGENT_MAP)); + +// ── Aliases (shorthand → canonical agent name) ────────────── +// Build aliases dynamically from discovered names, with static fallbacks +const cloudAgent = PROFILE_AGENT_MAP['cloud-persistent'] || 'CloudGrok'; +const localAgent = PROFILE_AGENT_MAP['local-research'] || 'LocalAndy'; +const AGENT_ALIASES = { + 'cloud': cloudAgent, + 'cg': cloudAgent, + 'grok': cloudAgent, + 'local': localAgent, + 'la': localAgent, + 'andy': localAgent, +}; + +/** + * Resolve a user argument to a list of agent names. + * Supports: exact agent names, group names, or comma-separated. + * Returns: { agents: string[], label: string } + */ +function resolveAgents(arg) { + if (!arg) return { agents: [], label: 'none' }; + + // Check for comma-separated list + const parts = arg.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); + const resolved = []; + const labels = []; + + for (const part of parts) { + const lower = part.toLowerCase(); + + // Group match + if (BOT_GROUPS[lower]) { + resolved.push(...BOT_GROUPS[lower]); + labels.push(`group:${lower}`); + continue; + } + + // Alias match + if (AGENT_ALIASES[lower]) { + resolved.push(AGENT_ALIASES[lower]); + labels.push(AGENT_ALIASES[lower]); + continue; + } + + // Exact agent name match (case-insensitive) + const agent = knownAgents.find(a => a.name.toLowerCase() === lower); + if (agent) { + resolved.push(agent.name); + labels.push(agent.name); + continue; + } + + // Partial match (prefix) + const partial = knownAgents.filter(a => a.name.toLowerCase().startsWith(lower)); + if (partial.length > 0) { + resolved.push(...partial.map(a => a.name)); + labels.push(...partial.map(a => a.name)); + continue; + } + + // Unknown — pass through as-is (MindServer may know it) + resolved.push(part); + labels.push(part); + } + + // Deduplicate + const unique = [...new Set(resolved)]; + return { agents: unique, label: labels.join(', ') }; +} + +// ── Config ────────────────────────────────────────────────── +const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN || ''; +const BOT_DM_CHANNEL = process.env.BOT_DM_CHANNEL || ''; +const BACKUP_CHAT_CHANNEL = process.env.BACKUP_CHAT_CHANNEL || ''; +const MINDSERVER_HOST = process.env.MINDSERVER_HOST || 'mindcraft'; +const MINDSERVER_PORT = process.env.MINDSERVER_PORT || 8080; +// Optional secondary MindServer for local PC bots (e.g. Tailscale IP) +// Set LOCAL_MINDSERVER_URL to connect to a second MindServer running locally +// Example: LOCAL_MINDSERVER_URL=http://100.x.x.x:8080 +const LOCAL_MINDSERVER_URL = process.env.LOCAL_MINDSERVER_URL || ''; + +// ── Discord Client ────────────────────────────────────────── +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages + ], + partials: [Partials.Channel, Partials.Message] +}); + +// ── State ─────────────────────────────────────────────────── +let mindServerSocket = null; +let mindServerConnected = false; +let knownAgents = []; // [{name, in_game, socket_connected, viewerPort}] +let agentStates = {}; // {agentName: {gameplay, action, inventory, nearby, ...}} +let replyChannel = null; // cached Discord channel for fast replies + +// ── Local MindServer State ────────────────────────────────── +let localMindServerSocket = null; +let localMindServerConnected = false; +let localKnownAgents = []; // agents from local PC MindServer +let localAgentStates = {}; // states from local PC MindServer +const messageLimiter = new RateLimiter(3, 60000); // 3 messages per 60 seconds per user + +// ── Gemini Helper (shared by auto-fix and direct chat) ──────── +async function callGemini(systemPrompt, userMessage, history = []) { + if (!GOOGLE_API_KEY) return null; + try { + const url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'; + const contents = [ + ...history, + { role: 'user', parts: [{ text: userMessage }] } + ]; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': GOOGLE_API_KEY }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: systemPrompt }] }, + contents, + generationConfig: { maxOutputTokens: 600, temperature: 0.7 } + }) + }); + if (!res.ok) { console.error(`[Gemini] API error ${res.status}`); return null; } + const data = await res.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null; + } catch (err) { + console.error('[Gemini] Error:', err.message); + return null; + } +} + +// ── Chat Buffer (feeds auto-fix and direct chat context) ────── +const chatBuffer = {}; // { agentName: [{speaker, text, timestamp}] } +const CHAT_BUFFER_SIZE = 20; + +function addToChatBuffer(agentName, speaker, text) { + if (!chatBuffer[agentName]) chatBuffer[agentName] = []; + chatBuffer[agentName].push({ speaker, text, timestamp: Date.now() }); + if (chatBuffer[agentName].length > CHAT_BUFFER_SIZE) chatBuffer[agentName].shift(); +} + +function buildChatBufferText(limit = 20) { + const all = []; + for (const msgs of Object.values(chatBuffer)) { + for (const m of msgs) all.push(m); + } + all.sort((a, b) => a.timestamp - b.timestamp); + return all.slice(-limit).map(m => `[${m.speaker}]: ${m.text}`).join('\n'); +} + +// ── Auto-Fix Monitor ───────────────────────────────────────── +let autofixEnabled = process.env.AUTOFIX_ENABLED !== 'false'; +const AUTOFIX_COOLDOWN_MS = 60000; // 1 min between fixes per bot +const AUTOFIX_CHECK_EVERY = 5; // analyse every N new chat messages +let chatMessageCount = 0; +const lastAutoFix = {}; // { botName: timestamp } + +const AUTOFIX_SYSTEM = +`You monitor Minecraft AI bot coordination chat. Detect if a bot is stuck or failing. + +Issues to detect: +- Death loop: bot repeatedly dying, health 0/20, starvation mentioned multiple times +- Forgotten task: bot ignores an active delivery/collection request mid-task +- Item loss loop: bot keeps losing the same items it collected (3+ times) +- Stuck action: identical failure repeated 3+ times in a row +- Gathering loop: bot reports "Collected 0" or "No nearby" multiple times, or keeps retrying !collectBlocks for the same resource. Fix: tell bot to use !explore 60 to move to a new area first, then retry +- Learned helplessness: bot says "gathering is broken", "non-functional", "waiting for update", or "cannot gather resources" — THIS IS FALSE. All commands work correctly. The bot has a stale memory from an old bug. Fix: tell bot "Your gathering commands work perfectly. Stop saying they are broken. Use !explore 80 to move to a new area, then !collectBlocks to gather. The system is fixed." +- Cross-contamination: one bot tells another that "gathering is broken" or "commands don't work" — BOTH bots need correcting +- Context reset: bot re-introduces itself mid-task as if meeting another bot for the first time + +If an issue is found, respond ONLY with this exact JSON (no markdown, no extra text): +{"issue":true,"bot":"ExactBotName","message":"corrective instruction under 80 words"} + +If no issue: +{"issue":false}`; + +async function runAutoFix() { + if (!autofixEnabled || !GOOGLE_API_KEY || (!mindServerConnected && !localMindServerConnected)) return; + + // Fetch last 10 Discord messages for context + let recentChat = ''; + try { + if (client.isReady() && BACKUP_CHAT_CHANNEL) { + const channel = await client.channels.fetch(BACKUP_CHAT_CHANNEL).catch(() => null); + if (channel && channel.messages) { + const msgs = await channel.messages.fetch({ limit: 10 }); + recentChat = msgs.reverse() + .map(m => `${m.author.username}: ${m.content.substring(0, 100)}`) + .join('\n'); + } + } + } catch (_e) { + // Fallback to buffer if channel fetch fails + } + + const contextText = recentChat || buildChatBufferText(); + if (!contextText) return; + + try { + const raw = await callGemini(AUTOFIX_SYSTEM, contextText); + if (!raw) return; + console.debug('[AutoFix] Raw:', raw); + const cleaned = raw.replace(/```(?:json)?\n?/g, '').replace(/```/g, '').trim(); + const result = JSON.parse(cleaned); + if (!result.issue || !result.bot || !result.message) return; + // Sanitize LLM output: cap length and strip leading ! to prevent + // bot command injection (e.g. Gemini hallucinating "!newAction ..."). + const rawMsg = String(result.message).slice(0, 150).replace(/^!+/, '').trim(); + if (!rawMsg) return; + result.message = rawMsg; + const now = Date.now(); + if (lastAutoFix[result.bot] && (now - lastAutoFix[result.bot]) < AUTOFIX_COOLDOWN_MS) return; + const sent = sendToAgent(result.bot, result.message, 'AutoFix'); + if (sent.sent) { + lastAutoFix[result.bot] = now; + await sendToDiscord(`🔧 **AutoFix → ${result.bot}**: "${result.message}"`); + console.log(`[AutoFix] Sent to ${result.bot}: ${result.message}`); + } + } catch (err) { + console.error('[AutoFix] Parse error:', err.message); + } +} + +// ── Direct Bot Chat ────────────────────────────────────────── +const directChatHistory = []; // multi-turn conversation history +const MAX_DIRECT_HISTORY = 20; + +const DIRECT_CHAT_SYSTEM_TEMPLATE = +`You are Mindcraft Bot Manager, the AI control interface for a Minecraft bot management system. Help the admin monitor and manage their bots. + +Bots: +- CloudGrok: cloud ensemble (Gemini + Grok panel), persistent survival, maintains base +- LocalAndy: research and exploration bot + +Respond concisely. Reference live state data when available. Suggest Discord commands where helpful (e.g. !freeze, !restart, !stats, !inv, !stop). + +Live agent states: +$STATES + +Recent bot chat: +$CHAT`; + +async function handleDirectChat(userMessage) { + if (!GOOGLE_API_KEY) return '❌ `GOOGLE_API_KEY` not configured — direct chat unavailable.'; + const states = Object.entries(agentStates).map(([name, s]) => { + const gp = s?.gameplay || {}; + const act = s?.action || {}; + return `${name}: hp=${gp.health ?? '?'}/20 food=${gp.hunger ?? '?'}/20 pos=${formatPos(gp.position)} action=${act.current || (act.isIdle ? 'idle' : '?')}`; + }).join('\n') || 'No agents connected'; + const recentChat = buildChatBufferText(15) || 'No recent chat'; + const system = DIRECT_CHAT_SYSTEM_TEMPLATE + .replace('$STATES', states) + .replace('$CHAT', recentChat); + const history = directChatHistory.slice(-MAX_DIRECT_HISTORY); + const reply = await callGemini(system, userMessage, history); + if (reply) { + directChatHistory.push({ role: 'user', parts: [{ text: userMessage }] }); + directChatHistory.push({ role: 'model', parts: [{ text: reply }] }); + if (directChatHistory.length > MAX_DIRECT_HISTORY) directChatHistory.splice(0, 2); + } + return reply || '❌ No response from Gemini.'; +} + +// ── Help Text ─────────────────────────────────────────────── +const HELP_TEXT = `**Mindcraft Bot Manager — Command Center** +🔒 = requires the \`admin\` Discord role (or your user ID in \`DISCORD_ADMIN_IDS\`) + +**Chat with bots:** +Just type a message → goes to ALL active bots +Target one: \`andy: go mine diamonds\` | \`cg: come here\` +Aliases: \`cloud\`/\`cg\`/\`grok\` = CloudGrok | \`local\`/\`la\`/\`andy\` = LocalAndy +Groups: \`all\` \`cloud\` \`local\` \`research\` | Comma-separate targets: \`cg, andy\` + +**Talk to Mindcraft Bot Manager directly:** +\`bot: \` — Chat with the bot itself (live agent state + recent chat context) +DM the bot — same as \`bot:\`, works even when MindServer is offline + +**Monitoring:** +\`!status\` — Agent overview (health, hunger, position, online/offline) +\`!agents\` — Quick agent connection status +\`!stats [bot]\` — Detailed gameplay stats (biome, weather, current action) +\`!inv [bot]\` — Inventory contents and equipped gear +\`!nearby [bot]\` — Nearby players, bots, and mobs +\`!viewer\` — MindServer dashboard link (bot cameras + stats) +\`!usage [bot|all]\` — API token counts and cost breakdown +\`!autofix\` — Toggle auto-fix on/off 🔒 | \`!autofix status\` — monitor details + +**Controls:** 🔒 +\`!start \` — Start bot(s) +\`!startall\` — Start every bot +\`!stop [bot|group]\` — Stop bot(s) (default: all) +\`!stopall\` — Stop every bot +\`!restart \` — Restart bot(s) +\`!freeze [bot|group]\` — Instant in-game halt, no LLM (default: all) + +**System:** +\`!mode [cloud|local|hybrid] [profile]\` — View or switch compute mode 🔒 +\`!reconnect\` — Reconnect to MindServer 🔒 +\`!ui\` / \`!mindserver\` — MindServer dashboard URL +\`!ping\` — Latency check + +**Auto-Fix:** Watches bot chat every 5 messages, auto-corrects death loops, forgotten tasks, item loss, and context resets (60s cooldown per bot). Toggle with \`!autofix\` or set \`AUTOFIX_ENABLED=false\` at startup.`; + +// ── MindServer Connection ─────────────────────────────────── +function connectToMindServer() { + const url = `http://${MINDSERVER_HOST}:${MINDSERVER_PORT}`; + console.log(`📡 Connecting to MindServer at ${url}`); + + if (mindServerSocket) { + mindServerSocket.removeAllListeners(); + mindServerSocket.disconnect(); + } + + mindServerSocket = io(url, { + reconnection: true, + reconnectionDelay: 2000, + reconnectionDelayMax: 10000, + reconnectionAttempts: Infinity, + timeout: 10000 + }); + + mindServerSocket.on('connect', () => { + mindServerConnected = true; + console.log('✅ Connected to MindServer'); + mindServerSocket.emit('listen-to-agents'); + }); + + mindServerSocket.on('disconnect', (reason) => { + mindServerConnected = false; + console.log(`⚠️ Disconnected from MindServer: ${reason}`); + }); + + // ── Agent output → Discord ── + mindServerSocket.on('bot-output', async (agentName, message) => { + console.log(`[Agent ${agentName}] ${message}`); + await sendToDiscord(`🟢 **${agentName}**: ${message}`); + addToChatBuffer(agentName, agentName, message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + }); + + mindServerSocket.on('chat-message', async (agentName, json) => { + console.log(`[Agent Chat] ${agentName}: ${JSON.stringify(json)}`); + if (json && json.message) { + await sendToDiscord(`💬 **${agentName}**: ${json.message}`); + addToChatBuffer(agentName, agentName, json.message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + } + }); + + mindServerSocket.on('agents-status', (agents) => { + knownAgents = agents || []; + const summary = agents.map(a => `${a.name}${a.in_game ? '✅' : '⬛'}`).join(', '); + console.log(`[Agents] ${summary}`); + }); + + mindServerSocket.on('state-update', (states) => { + if (states) agentStates = { ...agentStates, ...states }; + }); + + mindServerSocket.on('connect_error', (error) => { + mindServerConnected = false; + // Only log periodically to avoid spam + console.error(`MindServer error: ${error.message}`); + }); +} + +// ── Local MindServer Connection ───────────────────────────── +function connectToLocalMindServer() { + if (!LOCAL_MINDSERVER_URL) return; + + console.log(`📡 Connecting to Local MindServer at ${LOCAL_MINDSERVER_URL}`); + + if (localMindServerSocket) { + localMindServerSocket.removeAllListeners(); + localMindServerSocket.disconnect(); + } + + localMindServerSocket = io(LOCAL_MINDSERVER_URL, { + reconnection: true, + reconnectionDelay: 3000, + reconnectionDelayMax: 15000, + reconnectionAttempts: Infinity, + timeout: 10000 + }); + + localMindServerSocket.on('connect', () => { + localMindServerConnected = true; + console.log('✅ Connected to Local MindServer'); + localMindServerSocket.emit('listen-to-agents'); + }); + + localMindServerSocket.on('disconnect', (reason) => { + localMindServerConnected = false; + localKnownAgents = []; + console.log(`⚠️ Disconnected from Local MindServer: ${reason}`); + }); + + localMindServerSocket.on('bot-output', async (agentName, message) => { + console.log(`[Local ${agentName}] ${message}`); + await sendToDiscord(`🏠 **${agentName}**: ${message}`); + addToChatBuffer(agentName, agentName, message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + }); + + localMindServerSocket.on('chat-message', async (agentName, json) => { + if (json && json.message) { + await sendToDiscord(`🏠💬 **${agentName}**: ${json.message}`); + addToChatBuffer(agentName, agentName, json.message); + chatMessageCount++; + if (chatMessageCount % AUTOFIX_CHECK_EVERY === 0) { + runAutoFix().catch(err => console.error('[AutoFix]', err.message)); + } + } + }); + + localMindServerSocket.on('agents-status', (agents) => { + localKnownAgents = agents || []; + const summary = agents.map(a => `${a.name}${a.in_game ? '✅' : '⬛'}`).join(', '); + console.log(`[Local Agents] ${summary}`); + }); + + localMindServerSocket.on('state-update', (states) => { + if (states) localAgentStates = { ...localAgentStates, ...states }; + }); + + localMindServerSocket.on('connect_error', (error) => { + localMindServerConnected = false; + console.error(`Local MindServer error: ${error.message}`); + }); +} + +// ── Merged Agent Helpers ──────────────────────────────────── +// Returns all agents from both MindServers, tagged with source +function getAllAgents() { + const cloud = knownAgents.map(a => ({ ...a, _source: 'cloud' })); + const local = localKnownAgents.map(a => ({ ...a, _source: 'local' })); + return [...cloud, ...local]; +} + +function getAllAgentStates() { + return { ...agentStates, ...localAgentStates }; +} + +// Find which socket an agent belongs to +function getAgentSocket(agentName) { + const lower = agentName.toLowerCase(); + if (knownAgents.find(a => a.name.toLowerCase() === lower)) { + return { socket: mindServerSocket, connected: mindServerConnected, source: 'cloud' }; + } + if (localKnownAgents.find(a => a.name.toLowerCase() === lower)) { + return { socket: localMindServerSocket, connected: localMindServerConnected, source: 'local' }; + } + return null; +} + +// ── Discord → Send ────────────────────────────────────────── +async function sendToDiscord(message) { + try { + if (!replyChannel) { + replyChannel = await client.channels.fetch(BOT_DM_CHANNEL); + } + if (replyChannel) { + const chunks = message.match(/[\s\S]{1,1990}/g) || [message]; + for (const chunk of chunks) { + await replyChannel.send(chunk); + } + } + } catch (error) { + console.error('Discord send error:', error.message); + replyChannel = null; // reset cache on error + } +} + +// ── MindServer → Send ─────────────────────────────────────── +// Matches what MindServer expects: +// socket.on('send-message', (agentName, data)) +// then agent receives: socket.on('send-message', (data)) +// where data = { from: 'username', message: 'text' } +function sendToAgent(agentName, message, fromUser = 'Discord') { + // Try both MindServers — find which one owns this agent + const allAgents = getAllAgents(); + const agent = allAgents.find(a => a.name.toLowerCase() === agentName.toLowerCase()); + if (!agent) return { sent: false, reason: `Agent "${agentName}" not found` }; + if (!agent.in_game) return { sent: false, reason: `Agent "${agentName}" is not in-game` }; + + const target = getAgentSocket(agent.name); + if (!target || !target.connected) return { sent: false, reason: `MindServer for "${agentName}" not connected` }; + + try { + target.socket.emit('send-message', agent.name, { from: fromUser, message }); + const tag = target.source === 'local' ? '🏠→' : '→'; + console.log(`[${tag} ${agent.name}] ${fromUser}: ${message}`); + return { sent: true, agent: agent.name }; + } catch (error) { + return { sent: false, reason: error.message }; + } +} + +function sendToAllAgents(message, fromUser = 'Discord') { + const allAgents = getAllAgents().filter(a => a.in_game); + if (allAgents.length === 0) return { sent: false, agents: [], reason: 'No agents in-game' }; + + const results = []; + for (const agent of allAgents) { + const result = sendToAgent(agent.name, message, fromUser); + results.push(result); + } + return { sent: true, agents: allAgents.map(a => a.name), results }; +} + +// ── Helpers ───────────────────────────────────────────────── + +// ── Mode Switching ────────────────────────────────────────── +const VALID_MODES = ['cloud', 'local', 'hybrid']; +const MODE_EMOJI = { cloud: '☁️', local: '🖥️', hybrid: '🔀' }; + +async function readProfileAsync(name) { + const filePath = safeProfilePath(name); + const data = await readFile(filePath, 'utf8'); + return deepSanitize(JSON.parse(data)); +} + +async function writeProfileAsync(name, data) { + const filePath = safeProfilePath(name); + await writeFile(filePath, JSON.stringify(data, null, 4) + '\n', 'utf8'); +} + +async function getActiveModeAsync(name) { + try { + const p = await readProfileAsync(name); + return p._active_mode || 'unknown'; + } catch { return 'unreadable'; } +} + +async function switchProfileMode(name, mode) { + try { + const profile = await readProfileAsync(name); + if (!profile._modes) return { ok: false, reason: `No _modes config in ${name}.json` }; + const modeConfig = profile._modes[mode]; + if (!modeConfig) return { ok: false, reason: `Mode "${mode}" not defined for ${name}` }; + + // Apply mode fields to top-level + for (const [key, value] of Object.entries(modeConfig)) { + if (key === 'compute_type') continue; + profile[key] = value; + } + + // Remove code_model if not in this mode + if (profile.code_model && !modeConfig.code_model) { + delete profile.code_model; + } + + // Update compute type in conversing prompt + if (profile.conversing && modeConfig.compute_type) { + profile.conversing = profile.conversing.replace( + /(?<=- Compute: )[^\n\\]+/, + modeConfig.compute_type + ); + } + + profile._active_mode = mode; + await writeProfileAsync(name, profile); + return { ok: true, compute: modeConfig.compute_type || mode }; + } catch (err) { + return { ok: false, reason: err.message }; + } +} + +async function handleModeCommand(arg, message) { + const parts = arg.trim().split(/\s+/).filter(Boolean); + + // No args → show current modes + if (parts.length === 0) { + const lines = []; + for (const name of ACTIVE_PROFILES) { + const mode = await getActiveModeAsync(name); + const emoji = MODE_EMOJI[mode] || '❓'; + lines.push(`• **${name}** — ${emoji} ${mode}`); + } + return `**Current Compute Modes:**\n${lines.join('\n')}\n\nUsage: \`!mode [profile]\``; + } + + const mode = parts[0].toLowerCase(); + if (!VALID_MODES.includes(mode)) { + return `❌ Invalid mode: \`${mode}\`. Valid: \`cloud\`, \`local\`, \`hybrid\``; + } + + // Determine which profiles to switch + const rawTargets = parts.length > 1 + ? parts.slice(1).map(p => p.toLowerCase().replace('.json', '')) + : [...ACTIVE_PROFILES]; + + // Validate all profile names before touching the filesystem + const invalidTargets = rawTargets.filter(name => !isValidProfileName(name)); + if (invalidTargets.length > 0) { + return `❌ Invalid profile name(s): ${invalidTargets.map(n => `\`${n}\``).join(', ')}. Profile names may only contain letters, numbers, hyphens, and underscores.`; + } + const targets = rawTargets; + + await message.channel.sendTyping(); + + const results = []; + for (const name of targets) { + const result = await switchProfileMode(name, mode); // Now async + if (result.ok) { + results.push(`✅ **${name}** → ${MODE_EMOJI[mode]} ${mode} (${result.compute})`); + } else { + results.push(`⚠️ **${name}** — ${result.reason}`); + } + } + + let reply = `**Mode Switch → ${mode.toUpperCase()}**\n${results.join('\n')}`; + + // Restart agents via MindServer if connected + if (mindServerConnected) { + reply += '\n\n🔄 Restarting agents...'; + for (const name of targets) { + // Find the agent's in-game name from the profile + try { + const profile = await readProfileAsync(name); + const agentName = profile.name || name; + mindServerSocket.emit('restart-agent', agentName); + } catch { /* skip */ } + } + } else { + reply += '\n\n⚠️ MindServer not connected — restart agents manually or use `!reconnect` then `!restart `.'; + } + + return reply; +} + +// ── Agent Helpers ─────────────────────────────────────────── +function getAgentStatusText() { + const allAgents = getAllAgents(); + if (allAgents.length === 0) return 'No agents registered.'; + return allAgents.map(a => { + const status = a.in_game ? '🟢 in-game' : (a.socket_connected ? '🟡 connected' : '🔴 offline'); + const tag = a._source === 'local' ? ' 🏠' : ''; + return `• **${a.name}**${tag} — ${status}`; + }).join('\n'); +} + +function parseAgentPrefix(content) { + // Check for "agentName: message" or "alias: message" pattern + const colonIdx = content.indexOf(':'); + if (colonIdx > 0 && colonIdx < 30) { + const possibleName = content.substring(0, colonIdx).trim().toLowerCase(); + const msg = content.substring(colonIdx + 1).trim(); + + // Exact agent name match + const agent = knownAgents.find(a => a.name.toLowerCase() === possibleName); + if (agent) { + return { agent: agent.name, message: msg }; + } + + // Alias match + if (AGENT_ALIASES[possibleName]) { + return { agent: AGENT_ALIASES[possibleName], message: msg }; + } + } + return null; +} + +// ── Usage Formatting ──────────────────────────────────────── +function formatAgentUsage(agentName, data) { + if (!data || !data.totals) return `\n**${agentName}** — No data\n`; + const t = data.totals; + const rpm = data.rpm ?? 0; + const tpm = data.tpm ?? 0; + let text = `\n**${agentName}**\n`; + text += ` Cost: **$${t.estimated_cost_usd.toFixed(4)} USD**\n`; + text += ` Requests: **${t.calls.toLocaleString()}** | RPM: **${rpm}**\n`; + text += ` Tokens: **${t.total_tokens.toLocaleString()}** `; + text += `(${t.prompt_tokens.toLocaleString()} in / ${t.completion_tokens.toLocaleString()} out) | TPM: **${tpm.toLocaleString()}**\n`; + + if (data.models && Object.keys(data.models).length > 0) { + for (const [model, m] of Object.entries(data.models)) { + const prov = m.provider || '?'; + text += ` - \`${model}\` (${prov}): ${m.calls} calls, `; + text += `${m.total_tokens.toLocaleString()} tokens, $${m.estimated_cost_usd.toFixed(4)}\n`; + } + } + return text; +} + +// ── State Display Formatters ──────────────────────────────── +function formatHealth(hp) { + if (hp == null) return '?'; + const hearts = Math.ceil(hp / 2); + return `${'❤️'.repeat(Math.min(hearts, 10))} ${hp}/20`; +} + +function formatHunger(hunger) { + if (hunger == null) return '?'; + const drums = Math.ceil(hunger / 2); + return `${'🍗'.repeat(Math.min(drums, 10))} ${hunger}/20`; +} + +function formatPos(pos) { + if (!pos) return 'Unknown'; + return `${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)}`; +} + +function formatAgentStats(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const gp = state.gameplay || {}; + const act = state.action || {}; + const surr = state.surroundings || {}; + let text = `**${name}**\n`; + text += `${formatHealth(gp.health)} | ${formatHunger(gp.hunger)}\n`; + text += `📍 ${formatPos(gp.position)} (${gp.dimension || '?'})\n`; + text += `🌿 ${gp.biome || '?'} | ${gp.weather || '?'} | ${gp.timeLabel || '?'}\n`; + text += `⚡ ${act.current || (act.isIdle ? 'Idle' : '?')}\n`; + text += `🧱 Standing on: ${surr.below || '?'}`; + return text; +} + +function formatAgentInventory(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const inv = state.inventory || {}; + const equip = inv.equipment || {}; + let text = `**${name} — Inventory** (${inv.stacksUsed || 0}/${inv.totalSlots || 36} slots)\n`; + + // Equipment + const slots = [ + ['⛑️', equip.helmet], ['👕', equip.chestplate], + ['👖', equip.leggings], ['👢', equip.boots], ['🗡️', equip.mainHand] + ]; + const equipped = slots.filter(([, v]) => v).map(([e, v]) => `${e} ${v}`); + if (equipped.length > 0) text += `**Equipped:** ${equipped.join(' | ')}\n`; + + // Items + const counts = inv.counts || {}; + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); + if (entries.length === 0) { + text += '*(empty)*'; + } else { + text += entries.map(([item, count]) => `\`${item}\` x${count}`).join(', '); + } + return text; +} + +function formatAgentNearby(name, state) { + if (!state || state.error) return `**${name}** — No data available`; + const nearby = state.nearby || {}; + let text = `**${name} — Nearby**\n`; + const humans = nearby.humanPlayers || []; + const bots = nearby.botPlayers || []; + const entities = nearby.entityTypes || []; + text += `👤 Players: ${humans.length > 0 ? humans.join(', ') : 'none'}\n`; + text += `🤖 Bots: ${bots.length > 0 ? bots.join(', ') : 'none'}\n`; + text += `🐾 Entities: ${entities.length > 0 ? entities.join(', ') : 'none'}`; + return text; +} + +// ── Discord Events ────────────────────────────────────────── +client.on('ready', async () => { + console.log(`🤖 Logged in as ${client.user.tag}`); + console.log(`📋 Channels: DM=${BOT_DM_CHANNEL}, Backup=${BACKUP_CHAT_CHANNEL}`); + + // Set guild nickname to "Mindcraft Bot Manager" + for (const guild of client.guilds.cache.values()) { + try { + await guild.members.me?.setNickname('Mindcraft Bot Manager'); + } catch (_e) { /* may lack permission */ } + } +}); + +client.on('messageCreate', async (message) => { + if (message.author.bot) return; + + const isDM = !message.guild; + const isTarget = isDM || message.channelId === BOT_DM_CHANNEL || message.channelId === BACKUP_CHAT_CHANNEL; + if (!isTarget) return; + + const content = message.content.trim(); + const lower = content.toLowerCase(); + console.log(`[Discord] ${message.author.username}: ${content}`); + + // ── Rate Limiting ── + const rateCheck = messageLimiter.checkLimit(message.author.id); + if (!rateCheck.allowed) { + await message.reply(`⏱️ Rate limited. Please wait ${rateCheck.retryAfterSeconds}s before sending another message.`); + return; + } + + try { + // ── Natural language triggers ── + if (lower === 'what can you do' || lower === 'what can you do?' || lower === 'help' || lower === '!help') { + await message.reply(HELP_TEXT); + return; + } + + // ── Commands ── + if (content.startsWith('!')) { + const parts = content.split(/\s+/); + const cmd = parts[0].toLowerCase(); + let arg = parts.slice(1).join(' '); + + switch (cmd) { + case '!ping': + await message.reply('🏓 Pong!'); + return; + + case '!autofix': { + if (arg.toLowerCase() === 'status') { + // Status view — open to all users + const status = autofixEnabled ? '🟢 enabled' : '🔴 disabled'; + const fixes = Object.entries(lastAutoFix) + .map(([bot, ts]) => `• **${bot}** — last fix ${Math.round((Date.now() - ts) / 1000)}s ago`); + + let recent = 'empty'; + try { + const msgs = await message.channel.messages.fetch({ limit: 10 }); + if (msgs.size > 0) { + recent = msgs.reverse() + .map(m => `${m.author.username}: ${m.content.substring(0, 80)}`) + .join('\n'); + } + } catch (_e) { + recent = '(could not fetch messages)'; + } + + let reply = `**Auto-Fix Monitor** — ${status}\n`; + reply += fixes.length > 0 ? fixes.join('\n') + '\n' : 'No fixes sent this session.\n'; + reply += `\n**Last 10 messages** (monitored):\n\`\`\`\n${recent}\n\`\`\``; + await message.reply(reply.substring(0, 1990)); + } else { + // Toggle — admin only + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + autofixEnabled = !autofixEnabled; + await message.reply(`🔧 Auto-Fix is now **${autofixEnabled ? 'enabled 🟢' : 'disabled 🔴'}**`); + } + return; + } + + case '!status': { + const ms = mindServerConnected ? '✅ Cloud MindServer connected' : '❌ Cloud MindServer disconnected'; + const ls = LOCAL_MINDSERVER_URL + ? (localMindServerConnected ? '✅ Local MindServer connected' : '❌ Local MindServer disconnected') + : null; + const allAgentsForStatus = getAllAgents(); + const allStates = getAllAgentStates(); + const agentCount = allAgentsForStatus.length; + const inGame = allAgentsForStatus.filter(a => a.in_game).length; + let reply = `${ms}\n`; + if (ls) reply += `${ls}\n`; + reply += `📊 **${agentCount}** agents registered, **${inGame}** in-game\n\n`; + for (const agent of allAgentsForStatus) { + const status = agent.in_game ? '🟢 in-game' : (agent.socket_connected ? '🟡 connected' : '🔴 offline'); + const tag = agent._source === 'local' ? ' 🏠' : ' ☁️'; + reply += `• **${agent.name}**${tag} — ${status}`; + const st = allStates[agent.name]; + if (agent.in_game && st && st.gameplay) { + const gp = st.gameplay; + reply += ` | ❤️${gp.health || '?'} 🍗${gp.hunger || '?'} 📍${formatPos(gp.position)}`; + } + reply += '\n'; + } + await message.reply(reply); + return; + } + + case '!agents': + await message.reply(`**Agent Status:**\n${getAgentStatusText()}`); + return; + + case '!reconnect': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + await message.reply('🔄 Reconnecting to MindServer(s)...'); + connectToMindServer(); + if (LOCAL_MINDSERVER_URL) connectToLocalMindServer(); + return; + + case '!start': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!arg) { await message.reply('Usage: `!start ` — Groups: `all`, `gemini`, `grok`, `1`, `2`'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const { agents: startTargets } = resolveAgents(arg); + for (const name of startTargets) mindServerSocket.emit('start-agent', name); + await message.reply(`▶️ Starting **${startTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + } + + case '!stop': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const stopArg = arg || 'all'; + const { agents: stopTargets } = resolveAgents(stopArg); + for (const name of stopTargets) mindServerSocket.emit('stop-agent', name); + await message.reply(`⏹️ Stopping **${stopTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + } + + case '!freeze': { + // Sends "freeze" as an in-game chat message to bots. + // The hardcoded intercept in agent.js catches "freeze" and + // calls actions.stop() + shut_up — no LLM involved. + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected && !localMindServerConnected) { await message.reply('❌ No MindServer connected.'); return; } + const freezeArg = arg || 'all'; + const { agents: freezeTargets } = resolveAgents(freezeArg); + const allAgentsFreeze = getAllAgents(); + const inGame = freezeTargets.filter(n => allAgentsFreeze.find(a => a.name === n && a.in_game)); + if (inGame.length === 0) { + await message.reply('❌ No matching agents are in-game.'); + return; + } + for (const name of inGame) { + sendToAgent(name, 'freeze', message.author.username); + } + await message.reply(`🧊 Froze **${inGame.join(', ')}** — they will stop all actions immediately.`); + return; + } + + case '!restart': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!arg) { await message.reply('Usage: `!restart ` — Groups: `all`, `gemini`, `grok`, `1`, `2`'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + const { agents: restartTargets } = resolveAgents(arg); + for (const name of restartTargets) mindServerSocket.emit('restart-agent', name); + await message.reply(`🔄 Restarting **${restartTargets.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 8000); + return; + } + + case '!startall': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + for (const name of BOT_GROUPS.all) mindServerSocket.emit('start-agent', name); + await message.reply(`▶️ Starting all agents: **${BOT_GROUPS.all.join(', ')}**...`); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + + case '!stopall': + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + mindServerSocket.emit('stop-all-agents'); + await message.reply('⏹️ Stopping all agents...'); + setTimeout(() => { sendToDiscord(`**Agent Status:**\n${getAgentStatusText()}`).catch(console.error); }, 5000); + return; + + case '!mode': { + if (!isAdmin(message.author.id, message.member)) { await message.reply('⛔ This command requires admin privileges.'); return; } + const modeResult = await handleModeCommand(arg, message); + await message.reply(modeResult); + return; + } + + case '!usage': { + if (!mindServerConnected) { await message.reply('MindServer not connected.'); return; } + await message.channel.sendTyping(); + + if (!arg || arg.toLowerCase() === 'all') { + let replied = false; + const timeout = setTimeout(async () => { + if (!replied) { replied = true; await message.reply('⏱️ Usage request timed out. Agents may be busy.'); } + }, 10000); + mindServerSocket.emit('get-all-usage', async (results) => { + if (replied) return; + replied = true; + clearTimeout(timeout); + if (!results || Object.keys(results).length === 0) { + await message.reply('No usage data available. Are any agents in-game?'); + return; + } + let reply = '**API Usage Summary**\n'; + let grandTotal = 0; + for (const [name, data] of Object.entries(results)) { + if (!data) continue; + grandTotal += data.totals?.estimated_cost_usd || 0; + reply += formatAgentUsage(name, data); + } + reply += `\n**Grand Total: $${grandTotal.toFixed(4)}**`; + await message.reply(reply); + }); + } else { + const { agents: usageTargets } = resolveAgents(arg); + const target = usageTargets[0]; + if (!target) { await message.reply(`Agent "${arg}" not found.`); return; } + let replied = false; + const timeout = setTimeout(async () => { + if (!replied) { replied = true; await message.reply('⏱️ Usage request timed out.'); } + }, 10000); + mindServerSocket.emit('get-agent-usage', target, async (response) => { + if (replied) return; + replied = true; + clearTimeout(timeout); + if (response.error) { console.error(`[Usage] Error for ${target}:`, response.error); await message.reply(`Error fetching usage for **${target}**.`); return; } + if (!response.usage) { await message.reply(`No usage data for **${target}**.`); return; } + const reply = '**API Usage Report**\n' + formatAgentUsage(target, response.usage); + await message.reply(reply); + }); + } + return; + } + + case '!stats': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + // Show all agents + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentStats(agent.name, agentStates[agent.name])); + } else { + lines.push(`**${agent.name}** — ${agent.in_game ? 'no state data' : 'offline'}`); + } + } + await message.reply(lines.join('\n\n') || 'No agents registered.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentStats(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!inv': + case '!inventory': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentInventory(agent.name, agentStates[agent.name])); + } + } + await message.reply(lines.join('\n\n') || 'No in-game agents.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentInventory(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!nearby': { + if (!mindServerConnected) { await message.reply('❌ MindServer not connected.'); return; } + if (!arg) { + const lines = []; + for (const agent of knownAgents) { + if (agent.in_game && agentStates[agent.name]) { + lines.push(formatAgentNearby(agent.name, agentStates[agent.name])); + } + } + await message.reply(lines.join('\n\n') || 'No in-game agents.'); + } else { + const { agents: targets } = resolveAgents(arg); + const lines = targets.map(n => formatAgentNearby(n, agentStates[n])); + await message.reply(lines.join('\n\n') || 'Agent not found.'); + } + return; + } + + case '!viewer': { + const host = process.env.PUBLIC_HOST || 'localhost'; + const url = `http://${host}:${MINDSERVER_PORT}`; + let reply = `🖥️ **MindServer Dashboard**: <${url}>\nOpen in browser to view bot cameras, stats, and settings.`; + if (!process.env.PUBLIC_HOST) { + reply += '\n\n⚠️ `PUBLIC_HOST` not set — URL uses `localhost`. Set it to your server IP for remote access.'; + } + await message.reply(reply); + return; + } + + case '!ui': + case '!mindserver': + await sendToDiscord('🖥️ **MindServer Backup UI**: http://localhost:8080\nOpen in browser for agent dashboard/viewer (docker-compose up mindcraft).'); + return; + + default: + await message.reply(`Unknown command: \`${cmd}\`. Type \`!help\` for commands.`); + return; + } + } + + // ── Direct chat with Mindcraft Bot Manager (DM or "bot: " prefix) ── + const botPrefixMatch = content.match(/^bot:\s*([\s\S]+)/i); + if (isDM || botPrefixMatch) { + const question = (botPrefixMatch ? botPrefixMatch[1] : content).trim(); + if (!question) return; + await message.channel.sendTyping(); + const reply = await handleDirectChat(question); + const chunks = reply.match(/[\s\S]{1,1990}/g) || [reply]; + for (const chunk of chunks) await message.reply(chunk); + return; + } + + // ── Chat relay to agent ── + if (!mindServerConnected) { + await message.reply('🔌 MindServer is offline. Type `!reconnect` to retry.'); + return; + } + + // Validate message + const validation = validateDiscordMessage(content); + if (!validation.valid) { + await message.reply(`⚠️ Invalid message: ${validation.error}`); + return; + } + const cleanContent = validation.sanitized; + + await message.channel.sendTyping(); + + // Check for "agentName: message" or "alias: message" prefix + const parsed = parseAgentPrefix(cleanContent); + + if (parsed) { + // Targeted: send to one specific agent + const result = sendToAgent(parsed.agent, parsed.message, message.author.username); + if (result.sent) { + await message.reply(`📨 **${result.agent}** received your message. Waiting for response...`); + } else { + await message.reply(`⚠️ ${result.reason}`); + } + } else { + // No prefix: broadcast to ALL in-game agents + const result = sendToAllAgents(cleanContent, message.author.username); + if (result.sent) { + await message.reply(`📨 Sent to **${result.agents.join(', ')}**. Waiting for responses...`); + } else { + await message.reply(`⚠️ ${result.reason || 'No agents available. Check `!agents` or `!start `.'}`); + } + } + + } catch (error) { + console.error('Message handler error:', error.message); + try { await message.reply('❌ Something went wrong.'); } catch (_e) { /* ignore */ } + } +}); + +client.on('error', (error) => { + console.error('Discord client error:', error.message); +}); + +// ── Startup ───────────────────────────────────────────────── +async function start() { + console.log('🚀 Starting Mindcraft Bot Manager...'); + console.log(` MindServer: ${MINDSERVER_HOST}:${MINDSERVER_PORT}`); + if (LOCAL_MINDSERVER_URL) console.log(` Local MindServer: ${LOCAL_MINDSERVER_URL}`); + + try { + await client.login(BOT_TOKEN); + } catch (error) { + console.error('❌ Discord login failed:', error.message); + process.exit(1); + } + + connectToMindServer(); + if (LOCAL_MINDSERVER_URL) { + connectToLocalMindServer(); + } + console.log('✅ Mindcraft Bot Manager running'); +} + +start().catch(err => { console.error('Fatal:', err); process.exit(1); }); + +// ── Graceful Shutdown ─────────────────────────────────────── +const shutdown = async (signal) => { + console.log(`\n🛑 ${signal} received, shutting down...`); + if (mindServerSocket) mindServerSocket.disconnect(); + if (localMindServerSocket) localMindServerSocket.disconnect(); + await client.destroy(); + process.exit(0); +}; +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/docker-compose.aws.yml b/docker-compose.aws.yml new file mode 100644 index 000000000..6a45b6ebf --- /dev/null +++ b/docker-compose.aws.yml @@ -0,0 +1,280 @@ +services: + # ── Minecraft Server (Paper + Cross-Play) ────────────────────────────────── + # Paper server with ViaVersion suite (Java backward compat) and + # Geyser (Bedrock cross-play on UDP 19132). + # Migrated from vanilla 2025-02-27. Backup: minecraft-data-backup-pre-paper-* + minecraft: + image: itzg/minecraft-server + container_name: minecraft-server + # ⚠️ SECURITY: ONLINE_MODE=FALSE (required for mineflayer) disables Mojang auth. + # Anyone who knows the EC2 IP can join. Restrict port 19565 at the AWS + # Security Group level to trusted IPs only (your own IPs / player IPs). + ports: + - "19565:25565" # Java Edition (non-default external port for security) + - "19132:19132/udp" # Bedrock Edition (Geyser) + environment: + EULA: "TRUE" + TYPE: "PAPER" + VERSION: "LATEST" # Latest MC version — ensure mineflayer supports it + MEMORY: "2G" # 2G JVM heap — Aikar GC keeps it efficient + USE_AIKAR_FLAGS: "true" # Optimized GC flags for Paper (reduces GC pauses) + DIFFICULTY: "normal" + MODE: "survival" + ENABLE_COMMAND_BLOCK: "true" + VIEW_DISTANCE: "6" + SIMULATION_DISTANCE: "4" + ONLINE_MODE: "FALSE" # Required for mineflayer bots (offline auth) + ENFORCE_SECURE_PROFILE: "FALSE" # Allow unsigned chat from mineflayer bots (1.19.1+ signed-chat requirement) + ENABLE_RCON: "true" + RCON_PASSWORD: "${RCON_PASSWORD:?Set RCON_PASSWORD in .env}" + # ── Whitelist Security ── + ENFORCE_WHITELIST: "TRUE" # Enable whitelist (only whitelisted players can join) + # WHITELIST env var intentionally omitted: it queries Playerdb (Mojang API) to resolve + # usernames, which fails for offline-mode bot accounts. Instead, whitelist.json is + # pre-generated with correct offline UUIDs (OfflinePlayer: MD5) and mounted below. + # ── Plugin auto-download from Modrinth ── + # ViaVersion suite: lets older Java clients connect + # Geyser: lets Bedrock clients connect (Floodgate not needed w/ ONLINE_MODE=FALSE) + MODRINTH_PROJECTS: "viaversion,viabackwards,viarewind,geyser:beta" + # Geyser uses :beta because no stable release exists on Modrinth yet + volumes: + - ./minecraft-data:/data + - ./whitelist.json:/data/whitelist.json:ro # Pre-built offline UUIDs; see whitelist.json at repo root + restart: unless-stopped + stdin_open: true + tty: true + healthcheck: + test: ["CMD", "mc-health", "--host", "localhost", "--port", "25565"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s # Paper + plugins need more startup time + deploy: + resources: + limits: + cpus: "1.5" + memory: 3500M # 2G heap + 1.5G for Geyser native/metaspace/GC + + # ── Mindcraft Agents ────────────────────────────────────────────────────── + mindcraft: + build: . + container_name: mindcraft-agents + network_mode: host + command: sh -c "Xvfb :99 -screen 0 1024x768x24 &>/dev/null & export DISPLAY=:99 && sleep 2 && exec node main.js" + environment: + PROFILES: '["./profiles/cloud-persistent.json", "./profiles/claude-explorer.json"]' + SETTINGS_JSON: '{"auto_open_ui": false, "host": "localhost", "mindserver_host_public": true, "mindserver_url": "", "allow_vision": true, "render_bot_view": true}' + LIBGL_ALWAYS_SOFTWARE: "1" + CHROMADB_URL: "http://localhost:8000" + GEMINI_API_KEY: "${GEMINI_API_KEY}" + XAI_API_KEY: "${XAI_API_KEY}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" + volumes: + - ./settings.js:/app/settings.js + - ./profiles:/app/profiles + - ./bots:/app/bots + depends_on: + - minecraft + - chromadb + restart: unless-stopped + deploy: + resources: + limits: + cpus: "1.0" + memory: 2560M + + # ── Discord Bot ─────────────────────────────────────────────────────────── + discord-bot: + image: app-mindcraft + container_name: discord-bot + working_dir: /app + command: node discord-bot.js + volumes: + - ./discord-bot.js:/app/discord-bot.js + - ./package.json:/app/package.json + - ./src:/app/src + - ./profiles:/app/profiles + environment: + DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" + BOT_DM_CHANNEL: "${BOT_DM_CHANNEL}" + BACKUP_CHAT_CHANNEL: "${BACKUP_CHAT_CHANNEL}" + DISCORD_ADMIN_IDS: "${DISCORD_ADMIN_IDS}" + GOOGLE_API_KEY: "${GEMINI_API_KEY}" + MINDSERVER_HOST: "host.docker.internal" + PUBLIC_HOST: "${EC2_PUBLIC_IP}" + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── ChromaDB (vector memory for ensemble) ──────────────────────────────── + chromadb: + image: chromadb/chroma:0.5.20 + container_name: chromadb + # Internal only — no host port binding. Reachable within Docker network as chromadb:8000. + expose: + - "8000" + volumes: + - ./chromadb-data:/chroma/chroma + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── Prometheus ──────────────────────────────────────────────────────────── + prometheus: + image: prom/prometheus:v3.2.1 + container_name: prometheus + volumes: + - ./prometheus-aws.yml:/etc/prometheus/prometheus.yml + # Internal only — Grafana scrapes via Docker network. Not exposed to host. + expose: + - "9090" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── Grafana ─────────────────────────────────────────────────────────────── + grafana: + image: grafana/grafana:11.5.2 + container_name: grafana + ports: + - "3004:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana-provisioning/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml + - ./grafana-provisioning/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml + - ./grafana-provisioning/dashboard-json:/etc/grafana/provisioning/dashboards/json + - ./grafana-provisioning/alerting:/etc/grafana/provisioning/alerting + environment: + GF_SECURITY_ADMIN_PASSWORD: "${GF_ADMIN_PASSWORD:?Set GF_ADMIN_PASSWORD in .env}" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── Node Exporter ───────────────────────────────────────────────────────── + node-exporter: + image: prom/node-exporter:v1.9.0 + container_name: node-exporter + # Internal only — Prometheus scrapes via Docker network. Not exposed to host. + expose: + - "9100" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.10" + memory: 64M + + # ── cAdvisor ────────────────────────────────────────────────────────────── + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.51.0 + container_name: cadvisor + # Internal only — Prometheus scrapes via Docker network. Not exposed to host. + expose: + - "8080" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── World Backup → S3 ──────────────────────────────────────────────────── + backup: + image: offen/docker-volume-backup:v2 + container_name: minecraft-backup + environment: + AWS_S3_BUCKET_NAME: "${AWS_S3_BUCKET:-mindcraft-world-backups}" + AWS_DEFAULT_REGION: "us-east-1" + # AWS credentials intentionally omitted — use EC2 instance IAM role instead. + # Attach an IAM role with s3:PutObject/s3:DeleteObject on the bucket. + # Static keys would be readable by any container with docker.sock access. + BACKUP_CRON_EXPRESSION: "0 3 * * *" # Daily at 3 AM UTC + BACKUP_RETENTION_DAYS: "7" + BACKUP_FILENAME: "minecraft-world-%Y-%m-%dT%H-%M-%S.tar.gz" + volumes: + - ./minecraft-data:/backup/minecraft-data:ro + # docker.sock (ro) is required so the backup tool can pause containers for + # a consistent snapshot. Mitigated by: no static AWS keys in this container, + # IAM role scoped to the backup bucket only, and image pinned to v2. + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.10" + memory: 64M + + # ── LiteLLM — Unified LLM proxy ────────────────────────────────────────── + litellm: + image: ghcr.io/berriai/litellm:main-latest + container_name: litellm-proxy + ports: + - "4000:4000" + volumes: + - ./services/litellm/litellm_config.yaml:/app/config.yaml + environment: + GEMINI_API_KEY: "${GEMINI_API_KEY}" + XAI_API_KEY: "${XAI_API_KEY}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" + VLLM_BASE_URL: "${VLLM_BASE_URL:-http://host.docker.internal:8000/v1}" + LITELLM_MASTER_KEY: "${LITELLM_MASTER_KEY}" + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.25" + memory: 256M + + # ── Tailscale VPN — connects EC2 to local GPU ────────────────────────────── + # network_mode: host puts tailscale0 in the HOST network namespace so that + # Docker bridge containers (mindcraft) can route to Tailscale IPs via the + # host gateway. Without this, the tunnel only exists in the container's + # namespace and mindcraft cannot reach 100.x.x.x addresses. + tailscale: + image: tailscale/tailscale:v1.80.2 + container_name: tailscale + network_mode: host + environment: + TS_AUTHKEY: "${TAILSCALE_AUTHKEY}" + TS_EXTRA_ARGS: "" + TS_STATE_DIR: /var/lib/tailscale + TS_HOSTNAME: "mindcraft-ec2" + TS_USERSPACE: "false" + volumes: + - tailscale-state:/var/lib/tailscale + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - SYS_MODULE + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.10" + memory: 64M + + # viaproxy disabled — needs >256MB JVM heap, not enough RAM on t3.large + # To re-enable: uncomment and set memory limit to 512M + +volumes: + grafana-data: + tailscale-state: diff --git a/docker-compose.yml b/docker-compose.yml index eec423298..56e1e5e26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,220 @@ services: + + # ── GPU Monitoring (only starts with --profile local or --profile monitoring) ── + nvidia-gpu-exporter: + image: nvidia-gpu-exporter + container_name: nvidia-gpu-exporter + runtime: nvidia + environment: + - NVIDIA_VISIBLE_DEVICES=all + ports: + - "9835:9835" + restart: unless-stopped + profiles: + - local + - monitoring + + # ── Minecraft Server (Paper + Cross-Play) ──────────────────────────────────── + # Paper server with ViaVersion suite (Java backward compat) and + # Geyser (Bedrock cross-play on UDP 19132). + minecraft: + image: itzg/minecraft-server + container_name: minecraft-server + ports: + - "19565:25565" # Java Edition (non-default external port for security) + - "19132:19132/udp" # Bedrock Edition (Geyser) + environment: + EULA: "TRUE" + TYPE: "PAPER" + VERSION: "LATEST" # Latest MC version — ensure mineflayer supports it + MEMORY: "4G" + USE_AIKAR_FLAGS: "true" # Optimized GC flags for Paper (reduces GC pauses) + DIFFICULTY: "normal" + MODE: "survival" + ENABLE_COMMAND_BLOCK: "true" + VIEW_DISTANCE: "10" + ONLINE_MODE: "FALSE" # Required for mineflayer bots (offline auth) + ENFORCE_SECURE_PROFILE: "FALSE" # Allow unsigned chat from mineflayer bots (1.19.1+ signed-chat requirement) + ENABLE_RCON: "true" # Enables save-off/save-on for experiment backups + RCON_PASSWORD: "${RCON_PASSWORD:?Set RCON_PASSWORD in .env}" # Override via .env for production + # ── Whitelist Security ── + ENFORCE_WHITELIST: "TRUE" # Enable whitelist (only whitelisted players can join) + # WHITELIST env var intentionally omitted: it queries Playerdb (Mojang API) to resolve + # usernames, which fails for offline-mode bot accounts. Instead, whitelist.json is + # pre-generated with correct offline UUIDs (OfflinePlayer: MD5) and mounted below. + # ── Plugin auto-download from Modrinth ── + # ViaVersion suite: lets older Java clients connect + # Geyser: lets Bedrock clients connect (Floodgate not needed w/ ONLINE_MODE=FALSE) + MODRINTH_PROJECTS: "viaversion,viabackwards,viarewind,geyser:beta" + # Geyser uses :beta because no stable release exists on Modrinth yet + volumes: + - ./minecraft-data:/data + - ./whitelist.json:/data/whitelist.json:ro # Pre-built offline UUIDs; see whitelist.json at repo root + restart: unless-stopped + stdin_open: true + tty: true + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" + healthcheck: + test: ["CMD", "mc-health", "--host", "localhost", "--port", "25565"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + + # ── Mindcraft Agent(s) (always starts — PROFILES env controls which bots load) ─ + # Set PROFILES before calling docker compose, or use start.ps1. + # Examples: + # PROFILES='["./profiles/local-research.json"]' → LocalResearch_1 only + # PROFILES='["./profiles/cloud-persistent.json"]' → CloudPersistent_1 only + # PROFILES='["./profiles/local-research.json","./profiles/cloud-persistent.json"]' → both mindcraft: build: . + container_name: mindcraft-agents environment: - SETTINGS_JSON: | - {"auto_open_ui": false} + SETTINGS_JSON: '{"auto_open_ui": false, "mindserver_host_public": true, "host": "minecraft-server"}' + PROFILES: "${PROFILES:-}" + GEMINI_API_KEY: "${GEMINI_API_KEY:-}" + XAI_API_KEY: "${XAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + VLLM_BASE_URL: "${VLLM_BASE_URL:-http://host.docker.internal:8000/v1}" volumes: - ./settings.js:/app/settings.js - ./keys.json:/app/keys.json - ./profiles:/app/profiles - ./bots:/app/bots ports: - - "3000-3003:3000-3003" # see the view from the camera mounted on your bot head: http://localhost:3000/ - - 8080 # Mindserver port + - "3000-3003:3000-3003" # Bot camera views: http://localhost:3000/ + - "8080:8080" # MindServer UI: http://localhost:8080/ + extra_hosts: + - "host.docker.internal:host-gateway" # Reach Ollama/vLLM running on Windows host + depends_on: + minecraft: + condition: service_healthy + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:8080/').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""] + interval: 15s + timeout: 10s + retries: 8 + start_period: 120s + + # ── Discord Bot (only starts with --profile cloud or --profile discord) ──────── + discord-bot: + image: node:22-slim + container_name: discord-bot + working_dir: /app + command: sh -c "npm install --production 2>/dev/null; node discord-bot.js" + volumes: + - ./discord-bot.js:/app/discord-bot.js + - ./package.json:/app/package.json + - ./src:/app/src + - ./profiles:/app/profiles + environment: + DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" + BOT_DM_CHANNEL: "${BOT_DM_CHANNEL}" + BACKUP_CHAT_CHANNEL: "${BACKUP_CHAT_CHANNEL}" + DISCORD_ADMIN_IDS: "${DISCORD_ADMIN_IDS}" + MINDSERVER_HOST: "${MINDSERVER_HOST:-mindcraft-agents}" + depends_on: + mindcraft: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "node -e \"require('net').createConnection({port:8080,host:'mindcraft-agents',timeout:3000}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))\""] + interval: 60s + timeout: 5s + retries: 3 + start_period: 30s + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + profiles: + - cloud + - discord + + # ── LiteLLM Proxy (only starts with --profile litellm) ─────────────────────── + # Unified OpenAI-compatible proxy for Ollama and cloud models. + # Start: docker compose --profile litellm up -d litellm + # Health: http://localhost:4000/health + litellm: + image: ghcr.io/berriai/litellm:main-latest + container_name: litellm-proxy + ports: + - "4000:4000" + volumes: + - ./services/litellm/litellm_config.yaml:/app/config.yaml + environment: + GEMINI_API_KEY: "${GEMINI_API_KEY:-}" + XAI_API_KEY: "${XAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + VLLM_BASE_URL: "${VLLM_BASE_URL:-http://host.docker.internal:8000/v1}" + LITELLM_MASTER_KEY: "${LITELLM_MASTER_KEY:?Set LITELLM_MASTER_KEY in .env}" extra_hosts: - - "host.docker.internal:host-gateway" + - "host.docker.internal:host-gateway" + command: --config /app/config.yaml + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "30m" + max-file: "3" + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:4000/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 45s + profiles: + - litellm - viaproxy: #use this service to connect to an unsupported minecraft server versions. more info: ./services/viaproxy/README.md + # ── ViaProxy (only starts with --profile viaproxy) ─────────────────────────── + viaproxy: image: ghcr.io/viaversion/viaproxy:latest volumes: - ./services/viaproxy:/app/run ports: - "25568:25568" - profiles: - - viaproxy stdin_open: true tty: true + profiles: + - viaproxy + + # ── vLLM (deferred — using WSL2 or Ollama instead) ─────────────────────────── + # Start vLLM: wsl -d Ubuntu-22.04 -- bash services/vllm/start.sh --background + # Endpoint: http://host.docker.internal:8000/v1 + # vllm: + # image: vllm/vllm-openai:latest + # container_name: vllm-server + # ports: + # - "8000:8000" + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + # command: > + # --model google/gemma-3-12b-it + # --max-model-len 4096 + # --gpu-memory-utilization 0.90 + # --enforce-eager + # --dtype half + # restart: unless-stopped + # profiles: + # - vllm + +volumes: + vllm-models: diff --git a/docs/DRAGON_SLAYER_RC29.md b/docs/DRAGON_SLAYER_RC29.md new file mode 100644 index 000000000..41c2718cf --- /dev/null +++ b/docs/DRAGON_SLAYER_RC29.md @@ -0,0 +1,173 @@ +# Dragon Slayer RC29 — Autonomous Ender Dragon System + +> **Status: Live** — DragonSlayer is running on local Windows PC (RTX 3090, `sweaterdog/andy-4:q8_0` via Ollama) connected to Paper 1.21.11 on AWS EC2. RC29 persistent state saving active. MindServer HUD: `http://localhost:8080`. + +## Executive Summary + +RC29 upgrades Mindcraft's dragon progression system from a fragile single-run pipeline into a **persistent, death-surviving, restart-resilient autonomous Ender Dragon slayer**. + +Key improvements: +- **Persistent state** (`dragon_progress.json`) survives crashes, restarts, and deaths via atomic JSON writes +- **Smart orchestrator** with exponential backoff (5 retries per chunk), death recovery, gear re-acquisition +- **Dimension awareness** — tracks overworld/nether/end transitions +- **Pre-chunk preparation** — proactive food stockpiling, gear checks, inventory management +- **`!beatMinecraft` command** — single-command alias for the full autonomous run +- **Milestone tracking** — records highest resource counts ever achieved (not just current inventory) + +--- + +## New Files + +### `src/agent/library/dragon_progress.js` (~360 lines) +Persistent Dragon Progression state machine. + +| Feature | Detail | +|---------|--------| +| **State schema** | version, chunks status map (6 chunks), coords (7 named positions), milestones (7 items), stats (deaths, retries, dimension), dragonFight state | +| **Persistence** | Atomic `.tmp` + `renameSync` pattern (same as `history.js` RC27) | +| **Corruption recovery** | Renames corrupted save to `.corrupted.`, starts fresh | +| **API** | `load()`, `save()`, `currentChunk()`, `markChunkActive/Done/Failed()`, `setCoord()`, `updateMilestones()`, `recordDeath()`, `getSummary()` | +| **LLM integration** | `getSummary()` returns compact text for prompt injection | + +### `docs/DRAGON_SLAYER_RC29.md` (this file) +Documentation, testing plan, and quick-start guide. + +--- + +## Modified Files + +### `src/agent/library/dragon_runner.js` +**Header/imports:** Added `import { DragonProgress, CHUNKS }` from `dragon_progress.js`. Added `getDimension()` helper. + +**New functions:** +- `prepareForChunk(bot, chunkName, progress)` — adapts gear/food prep to target chunk +- `recoverFromDeath(bot, progress)` — goes to death location, picks up items, re-crafts lost tools + +**Orchestrator rewrite** (`runDragonProgression`): +- Loads `DragonProgress` on entry, saves after each chunk transition +- Registers `bot.on('death')` handler to record death position + save state +- 5 retries per chunk (up from 3) with exponential backoff (1s → 2s → 4s → 8s → 16s, max 30s) +- `runner.check()` consults both inventory AND persistent state (e.g., milestones) +- `runner.onSuccess()` hooks save key coordinates (portal, fortress, stronghold, end portal positions) +- Death recovery between retries: respawn wait → go to death pos → pickup items → re-craft tools +- Explore to fresh area on retry (100 + 50*retryCount blocks) +- `finally` block always removes death listener + +**Chunk functions:** Unchanged (proven gameplay logic preserved). + +### `src/agent/commands/actions.js` +- Updated `!dragonProgression` timeout from 120min to 180min, description updated +- Added `!beatMinecraft` command (alias for `runDragonProgression`, 180min timeout) + +--- + +## Updated Profiles + +### `profiles/dragon-slayer.json` +- System prompt mentions `!beatMinecraft` and persistent progress +- Death recovery example updated: "Died! Progress is saved. !beatMinecraft" +- Self-prompt updated to lead with `!beatMinecraft` +- All conversation examples using `!dragonProgression` → `!beatMinecraft` + +### `profiles/local-research.json` +- System prompt rule 19 updated to mention `!beatMinecraft` + persistent progress +- Added rule 22: "After dying, progress is saved — just re-run !beatMinecraft to resume." +- Conversation example for "defeat the ender dragon" → `!beatMinecraft` +- Self-prompt updated to lead with `!beatMinecraft` + +--- + +## Testing Plan + +### Unit-level Verification + +| Test | How | Expected | +|------|-----|----------| +| **JSON parse** | `node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json'))"` | No error | +| **Import chain** | `node -e "import('./src/agent/library/dragon_runner.js').then(m => console.log(Object.keys(m)))"` | Exports: `buildNetherPortal`, `collectBlazeRods`, `collectEnderPearls`, `locateStronghold`, `defeatEnderDragon`, `runDragonProgression` | +| **Progress persistence** | Create DragonProgress, save, reload, verify state matches | State round-trips correctly | +| **Lint** | `npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js` | 0 errors, 0 warnings | +| **Command registration** | Start bot, check `!help` output includes `!beatMinecraft` | Listed with description | + +### Integration Tests (Manual) + +1. **Fresh start**: New world → `!beatMinecraft` → observe Chunk 1 (diamond pickaxe) begins +2. **Persistence**: Kill bot process mid-chunk → restart → `!beatMinecraft` → resumes from last incomplete chunk (not from scratch) +3. **Death recovery**: Let bot die during Chunk 3 (blaze rods) → observe death handler fires → on retry, bot goes to death pos, recovers items +4. **Exponential backoff**: Make chunk fail (e.g., block all iron spawns) → observe increasing backoff delays in logs +5. **Full run**: Fresh world → `!beatMinecraft` → dragon defeated (target: < 3 hours game time) +6. **Individual chunks**: `!getDiamondPickaxe` → `!buildNetherPortal` → etc. still work independently +7. **Interrupt**: Mid-run `!stop` → bot stops → `!beatMinecraft` → resumes from saved state + +### Smoke Test Script + +```bash +# 1. Validate all files +npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js + +# 2. Validate profiles +node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json','utf8')); console.log('OK')" +node -e "JSON.parse(require('fs').readFileSync('profiles/local-research.json','utf8')); console.log('OK')" + +# 3. Validate imports +node --input-type=module -e "import { runDragonProgression, buildNetherPortal, collectBlazeRods, collectEnderPearls, locateStronghold, defeatEnderDragon } from './src/agent/library/dragon_runner.js'; console.log('All exports OK')" + +# 4. Run bot with dragon-slayer profile +node main.js --profiles ./profiles/dragon-slayer.json +``` + +--- + +## Quick-Start Guide + +### Prerequisites +- Node.js v18+ (v20 LTS recommended) +- Minecraft server running — Paper 1.21.x server with `host` and `port` configured in `settings.js` +- Ollama running locally with `sweaterdog/andy-4:q8_0`, `nomic-embed-text`, and `llava` pulled: `ollama pull sweaterdog/andy-4:q8_0 && ollama pull nomic-embed-text && ollama pull llava` +- `npm install` completed + +### Option A: DragonSlayer Bot (dedicated profile) +```bash +node main.js --profiles ./profiles/dragon-slayer.json +``` +The bot will self-prompt and begin `!beatMinecraft` automatically. + +### Option B: Any Bot, Manual Trigger +```bash +# Start your preferred bot +node main.js --profiles ./profiles/local-research.json + +# In Minecraft chat: +DragonSlayer, !beatMinecraft +``` + +### Option C: Individual Chunks +``` +!getDiamondPickaxe # Chunk 1 +!buildNetherPortal # Chunk 2 +!collectBlazeRods(12) # Chunk 3 +!collectEnderPearls(12) # Chunk 4 +!locateStronghold # Chunk 5 +!defeatEnderDragon # Chunk 6 +``` + +### Monitoring Progress +The persistent state is saved at `bots//dragon_progress.json`. You can inspect it: +```bash +cat bots/DragonSlayer/dragon_progress.json | python -m json.tool +``` + +### Resetting Progress +Delete the state file to start fresh: +```bash +rm bots/DragonSlayer/dragon_progress.json +``` + +### Troubleshooting +| Issue | Fix | +|-------|-----| +| Bot stuck in a loop | `!stop` then `!beatMinecraft` to resume from saved state | +| Bot keeps dying | Check food supply; modes `auto_eat` and `panic_defense` must be `true` | +| "Chunk X failed after 5 attempts" | Manual intervention needed: explore to better biome, ensure pickaxe/food, then `!beatMinecraft` | +| Bot won't enter Nether | Ensure `flint_and_steel` + obsidian portal exists; try `!buildNetherPortal` individually | +| State file corrupted | Delete `dragon_progress.json` and restart | diff --git a/eslint.config.js b/eslint.config.js index b15dcdb2d..009bcb920 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,7 @@ // eslint.config.js -import globals from "globals"; import pluginJs from "@eslint/js"; import noFloatingPromise from "eslint-plugin-no-floating-promise"; +import globals from "globals"; /** @type {import('eslint').Linter.Config[]} */ export default [ @@ -14,18 +14,90 @@ export default [ "no-floating-promise": noFloatingPromise, }, languageOptions: { - globals: globals.browser, - ecmaVersion: 2021, + ecmaVersion: 2022, sourceType: "module", + globals: { + ...globals.node, + ...globals.browser, + // True runtime globals injected into all bot/agent code + skills: "readonly", + log: "readonly", + world: "readonly", + bot: "readonly", + agent: "readonly", + Vec3: "readonly", + // Globals used in real source files (not action-code only) + Compartment: "readonly", // SES Compartment, provided by ses lockdown (lockdown.js) + res: "writable", // NPC item_goal.js + sendRequest: "readonly", // novita.js and other model files + chat_model_profile: "readonly", // prompter.js + }, }, rules: { "no-undef": "error", // Disallow the use of undeclared variables or functions. - "semi": ["error", "always"], // Require the use of semicolons at the end of statements. + "semi": "off", // Allow flexible semicolon usage "curly": "off", // Do not enforce the use of curly braces around blocks of code. - "no-unused-vars": "off", // Disable warnings for unused variables. - "no-unreachable": "off", // Disable warnings for unreachable code. - "require-await": "error", // Disallow async functions which have no await expression - "no-floating-promise/no-floating-promise": "error", // Disallow Promises without error handling or awaiting + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }], // Warn on unused vars (prefix with _ to ignore) + "no-unreachable": "warn", // Warn on unreachable code + "require-await": "off", // Allow async functions without await (disabled for compatibility) + "no-floating-promise/no-floating-promise": "warn", // Warn on unhandled promises + "no-control-regex": "off", // Allow control characters in regex (needed for message validator) + "no-ex-assign": "warn", // Warn on assignment to exception parameters + "no-fallthrough": "warn", // Warn on case fallthrough (add /* falls through */ comment for intentional) + "no-useless-escape": "warn", // Warn on unnecessary escape characters + "no-empty": ["warn", { "allowEmptyCatch": true }], // Warn on empty blocks, but allow empty catch + "no-prototype-builtins": "warn", // Warn on direct prototype method calls (use Object.hasOwn instead) + "no-extra-boolean-cast": "off", // Allow redundant boolean casts + }, + }, + // Override rules for bot action files + { + files: ["bots/**/*.js"], + rules: { + "require-await": "off", // Allow async functions without await in bot action files + }, + }, + // Globals and rules for LLM-generated action-code files. + // These identifiers are injected by the Coder sandbox at runtime and are not + // true module-level globals — scoping them here prevents masking real + // no-undef errors in the rest of the codebase. + { + files: ["**/action-code/*.js", "bots/execTemplate.js", "bots/lintTemplate.js"], + languageOptions: { + globals: { + newAction: "readonly", + nearbyEntities: "readonly", + assert: "readonly", + chat_model_profile: "readonly", + result: "readonly", + Compartment: "readonly", + res: "writable", + id: "readonly", + cleanEmb: "readonly", + text: "readonly", + meta: "readonly", + sendRequest: "readonly", + }, + }, + rules: { + "require-await": "off", + }, + }, + // Allow non-top-level imports/exports in action-code files + { + files: ["**/action-code/*.js"], + rules: { + "no-restricted-syntax": "off", // Allow imports/exports anywhere in action code files + }, + }, + // Override for specific problematic files + { + files: ["**/action-code/7.js"], + rules: { + "no-restricted-syntax": ["error", { + "selector": "ImportDeclaration, ExportDeclaration", + "message": "Imports and exports must be at the top level" + }], }, }, ]; diff --git a/grafana-provisioning/alerting/.gitkeep b/grafana-provisioning/alerting/.gitkeep new file mode 100644 index 000000000..1d9b9a2a3 --- /dev/null +++ b/grafana-provisioning/alerting/.gitkeep @@ -0,0 +1 @@ +# Alerting rules directory — add rule YAML files here diff --git a/grafana-provisioning/alerting/rules.yml b/grafana-provisioning/alerting/rules.yml new file mode 100644 index 000000000..a1c3b28ab --- /dev/null +++ b/grafana-provisioning/alerting/rules.yml @@ -0,0 +1,7 @@ +apiVersion: 1 + +# Set deleteRules to remove stale alert rules that no longer apply. +# The gpu-exporter-down rule fires because there is no GPU exporter running. +deleteRules: + - orgId: 1 + uid: gpu-exporter-down diff --git a/grafana-provisioning/dashboard-json/.gitkeep b/grafana-provisioning/dashboard-json/.gitkeep new file mode 100644 index 000000000..44a145090 --- /dev/null +++ b/grafana-provisioning/dashboard-json/.gitkeep @@ -0,0 +1 @@ +# Dashboard JSON files directory — add Grafana dashboard JSON exports here diff --git a/grafana-provisioning/dashboards.yml b/grafana-provisioning/dashboards.yml new file mode 100644 index 000000000..a3b3e08c5 --- /dev/null +++ b/grafana-provisioning/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: default + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards/json diff --git a/grafana-provisioning/datasources.yml b/grafana-provisioning/datasources.yml new file mode 100644 index 000000000..83ad3390c --- /dev/null +++ b/grafana-provisioning/datasources.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/keys.example.json b/keys.example.json index fe6812888..708b3731e 100644 --- a/keys.example.json +++ b/keys.example.json @@ -16,4 +16,5 @@ "OPENROUTER_API_KEY": "", "CEREBRAS_API_KEY": "", "MERCURY_API_KEY":"" + ,"DISCORD_BOT_TOKEN": "" } diff --git a/main.js b/main.js index 1578a456d..56c07ba7e 100644 --- a/main.js +++ b/main.js @@ -2,7 +2,18 @@ import * as Mindcraft from './src/mindcraft/mindcraft.js'; import settings from './settings.js'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; + +/** + * Safely parse JSON with a descriptive error on failure. + */ +function safeJsonParse(str, label) { + try { + return JSON.parse(str); + } catch (err) { + throw new Error(`Failed to parse ${label}: ${err.message}`); + } +} function parseArguments() { return yargs(hideBin(process.argv)) @@ -27,8 +38,14 @@ if (args.profiles) { settings.profiles = args.profiles; } if (args.task_path) { - let tasks = JSON.parse(readFileSync(args.task_path, 'utf8')); + if (!existsSync(args.task_path)) { + throw new Error(`Task file not found: ${args.task_path}`); + } + let tasks = safeJsonParse(readFileSync(args.task_path, 'utf8'), `task file "${args.task_path}"`); if (args.task_id) { + if (!(args.task_id in tasks)) { + throw new Error(`Task ID "${args.task_id}" not found in task file`); + } settings.task = tasks[args.task_id]; settings.task.task_id = args.task_id; } @@ -39,34 +56,53 @@ if (args.task_path) { // these environment variables override certain settings if (process.env.MINECRAFT_PORT) { - settings.port = process.env.MINECRAFT_PORT; + settings.port = parseInt(process.env.MINECRAFT_PORT, 10) || 19565; } if (process.env.MINDSERVER_PORT) { - settings.mindserver_port = process.env.MINDSERVER_PORT; + settings.mindserver_port = parseInt(process.env.MINDSERVER_PORT, 10) || 8080; } -if (process.env.PROFILES && JSON.parse(process.env.PROFILES).length > 0) { - settings.profiles = JSON.parse(process.env.PROFILES); +if (process.env.PROFILES) { + const profiles = safeJsonParse(process.env.PROFILES, 'PROFILES env var'); + if (Array.isArray(profiles) && profiles.length > 0) { + settings.profiles = profiles; + } } if (process.env.INSECURE_CODING) { settings.allow_insecure_coding = true; } if (process.env.BLOCKED_ACTIONS) { - settings.blocked_actions = JSON.parse(process.env.BLOCKED_ACTIONS); + settings.blocked_actions = safeJsonParse(process.env.BLOCKED_ACTIONS, 'BLOCKED_ACTIONS env var'); } if (process.env.MAX_MESSAGES) { - settings.max_messages = process.env.MAX_MESSAGES; + settings.max_messages = parseInt(process.env.MAX_MESSAGES, 10) || settings.max_messages; } if (process.env.NUM_EXAMPLES) { - settings.num_examples = process.env.NUM_EXAMPLES; + settings.num_examples = parseInt(process.env.NUM_EXAMPLES, 10) || settings.num_examples; } if (process.env.LOG_ALL) { - settings.log_all_prompts = process.env.LOG_ALL; + settings.log_all_prompts = process.env.LOG_ALL === 'true' || process.env.LOG_ALL === '1'; } -Mindcraft.init(true, settings.mindserver_port, settings.auto_open_ui); +if (settings.mindserver_url) { + console.log(`Remote MindServer mode: agents will connect to ${settings.mindserver_url}`); +} else { + Mindcraft.init(settings.mindserver_host_public === true, settings.mindserver_port, settings.auto_open_ui); +} for (let profile of settings.profiles) { - const profile_json = JSON.parse(readFileSync(profile, 'utf8')); - settings.profile = profile_json; - Mindcraft.createAgent(settings); + if (!existsSync(profile)) { + console.error(`Profile not found: ${profile} — skipping`); + continue; + } + try { + const profile_json = safeJsonParse(readFileSync(profile, 'utf8'), `profile "${profile}"`); + settings.profile = profile_json; + if (settings.mindserver_url) { + await Mindcraft.createRemoteAgent(settings, settings.mindserver_url); + } else { + Mindcraft.createAgent(settings); + } + } catch (err) { + console.error(`Failed to load profile "${profile}": ${err.message}`); + } } \ No newline at end of file diff --git a/package.json b/package.json index 7738bd1c2..d15b75034 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,25 @@ { "name": "mindcraft", + "version": "0.1.3", "type": "module", "dependencies": { - "@anthropic-ai/sdk": "^0.17.1", + "@anthropic-ai/sdk": "^0.17.2", "@cerebras/cerebras_cloud_sdk": "^1.46.0", "@google/genai": "^1.15.0", "@huggingface/inference": "^2.8.1", + "@miner-org/mineflayer-baritone": "^4.5.0", "@mistralai/mistralai": "^1.1.0", "canvas": "^3.1.0", "cheerio": "^1.0.0", + "chromadb": "^1.10.0", + "discord.js": "^14.25.1", "express": "^4.18.2", "google-translate-api-x": "^10.7.1", "groq-sdk": "^0.15.0", "minecraft-data": "^3.97.0", "mineflayer": "^4.33.0", "mineflayer-armor-manager": "^2.0.1", - "mineflayer-auto-eat": "^3.3.6", + "mineflayer-auto-eat": "^5.0.3", "mineflayer-collectblock": "^1.4.1", "mineflayer-pathfinder": "^2.4.5", "mineflayer-pvp": "^1.3.2", @@ -30,21 +34,35 @@ "socket.io-client": "^4.7.2", "three": "^0.128.0", "vec3": "^0.1.10", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod-to-json-schema": "^3.24.1" }, "overrides": { - "canvas": "^3.1.0", - "gl": "^8.1.6" + "axios": "^1.7.7", + "canvas": "^3.1.0", + "gl": "^8.1.6", + "tar": "^7.5.8", + "undici": ">=6.23.0" }, "scripts": { "postinstall": "patch-package", - "start": "node main.js" + "prepare": "husky install", + "start": "node main.js", + "test": "echo \"No tests configured\" && exit 0", + "lint": "eslint . --max-warnings 0" + }, + "lint-staged": { + "*.js": [ + "eslint --fix" + ] }, "devDependencies": { "@eslint/js": "^9.13.0", "eslint": "^9.13.0", "eslint-plugin-no-floating-promise": "^2.0.0", - "globals": "^15.11.0", + "globals": "^15.9.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.2", "patch-package": "^8.0.0" } } diff --git a/patches/@miner-org+mineflayer-baritone+4.5.0.patch b/patches/@miner-org+mineflayer-baritone+4.5.0.patch new file mode 100644 index 000000000..9c9557019 --- /dev/null +++ b/patches/@miner-org+mineflayer-baritone+4.5.0.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js b/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js +index ae7decf..9c615ad 100644 +--- a/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js ++++ b/node_modules/@miner-org/mineflayer-baritone/src/movement/index.js +@@ -323,6 +323,7 @@ class Move { + const block = this.getBlock(node); + if (!block) return false; + const above = this.getBlock(node.offset(0, 1, 0)); ++ if (!above) return false; + return ( + !this.isScaffolding(node) && + block.boundingBox === "empty" && diff --git a/patches/prismarine-viewer+1.33.0.patch b/patches/prismarine-viewer+1.33.0.patch index 3ef7a5a56..9016b1023 100644 --- a/patches/prismarine-viewer+1.33.0.patch +++ b/patches/prismarine-viewer+1.33.0.patch @@ -1,13 +1,28 @@ diff --git a/node_modules/prismarine-viewer/viewer/lib/entity/Entity.js b/node_modules/prismarine-viewer/viewer/lib/entity/Entity.js -index 8945452..dab25be 100644 +index 8945452..fa32384 100644 --- a/node_modules/prismarine-viewer/viewer/lib/entity/Entity.js +++ b/node_modules/prismarine-viewer/viewer/lib/entity/Entity.js -@@ -203,7 +203,7 @@ function getMesh (texture, jsonModel) { +@@ -168,8 +168,9 @@ function getMesh (texture, jsonModel) { + + const rootBones = [] + for (const jsonBone of jsonModel.bones) { +- if (jsonBone.parent) bones[jsonBone.parent].add(bones[jsonBone.name]) +- else rootBones.push(bones[jsonBone.name]) ++ if (jsonBone.parent) { ++ if (bones[jsonBone.parent]) bones[jsonBone.parent].add(bones[jsonBone.name]) ++ } else rootBones.push(bones[jsonBone.name]) + } + + const skeleton = new THREE.Skeleton(Object.values(bones)) +@@ -203,7 +204,10 @@ function getMesh (texture, jsonModel) { class Entity { constructor (version, type, scene) { const e = entities[type] - if (!e) throw new Error(`Unknown entity ${type}`) -+ if (!e) return; //throw new Error(`Unknown entity ${type}`) ++ if (!e) { ++ this.mesh = new THREE.Object3D() ++ return ++ } this.mesh = new THREE.Object3D() for (const [name, jsonModel] of Object.entries(e.geometry)) { diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json index b4fadd312..3950aa28d 100644 --- a/profiles/andy-4-reasoning.json +++ b/profiles/andy-4-reasoning.json @@ -1,14 +1,46 @@ { - "name": "andy-4-thinking", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "andy-4-thinking", "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Think in high amounts before responding. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else. If you have nothing to say or do, respond with an just a tab '\t'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$EXAMPLES\nReason before responding. Conversation Begin:", - + "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Think in high amounts before responding. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else. If you have nothing to say or do, respond with an just a tab '\t'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n\nMinecraft facts/recipes/mobs/blocks/items/versions from wiki: $WIKI\n\n$EXAMPLES\nReason before responding. Conversation Begin:", "coding": "You are an intelligent mineflayer bot $NAME that plays minecraft by writing javascript codeblocks. Given the conversation, use the provided skills and world functions to write a js codeblock that controls the mineflayer bot ``` // using this syntax ```. The code will be executed and you will receive it's output. If an error occurs, write another codeblock and try to fix the problem. Be maximally efficient, creative, and correct. Be mindful of previous actions. Do not use commands !likeThis, only use codeblocks. The code is asynchronous and MUST USE AWAIT for all async function calls, and must contain at least one await. You have `Vec3`, `skills`, and `world` imported, and the mineflayer `bot` is given. Do not import other libraries. Think deeply before responding. Do not use setTimeout or setInterval. Do not speak conversationally, only use codeblocks. Do any planning in comments. This is extremely important to me, think step-by-step, take a deep breath and good luck! \n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", - "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize preserving important facts, things you've learned, useful tips, and long term reminders. Do Not record stats, inventory, or docs! Only save transient information from your chat history. You're limited to 500 characters, so be extremely brief, think about what you will summarize before responding, minimize words, and provide your summarization in Chinese. Compress useful information. \nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", - - "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:" - + "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", + "_active_mode": "local", + "_modes": { + "cloud": { + "model": "gemini-2.5-flash", + "compute_type": "Cloud-based", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "gemini-2.5-flash", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + } + } } diff --git a/profiles/andy-4.json b/profiles/andy-4.json index 64ed347db..31a0fff85 100644 --- a/profiles/andy-4.json +++ b/profiles/andy-4.json @@ -1,7 +1,43 @@ { - "name": "andy-4", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "andy-4", "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "embedding": "ollama" + "embedding": "ollama", + "_active_mode": "local", + "_modes": { + "cloud": { + "model": "gemini-2.5-flash", + "compute_type": "Cloud-based", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "gemini-2.5-flash", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + } + } } diff --git a/profiles/azure.json b/profiles/azure.json index 29b1122d2..86df406f4 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -1,19 +1,79 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "azure", "model": { "api": "azure", "url": "https://.openai.azure.com", "model": "", - "params": { - "apiVersion": "2024-08-01-preview" + "params": { + "apiVersion": "2024-08-01-preview" } }, "embedding": { "api": "azure", "url": "https://.openai.azure.com", "model": "", - "params": { - "apiVersion": "2024-08-01-preview" + "params": { + "apiVersion": "2024-08-01-preview" + } + }, + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": { + "api": "azure", + "url": "https://.openai.azure.com", + "model": "", + "params": { + "apiVersion": "2024-08-01-preview" + } + }, + "compute_type": "Cloud-based", + "embedding": { + "api": "azure", + "url": "https://.openai.azure.com", + "model": "", + "params": { + "apiVersion": "2024-08-01-preview" + } + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": { + "api": "azure", + "url": "https://.openai.azure.com", + "model": "", + "params": { + "apiVersion": "2024-08-01-preview" + } + }, + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "azure", + "url": "https://.openai.azure.com", + "model": "", + "params": { + "apiVersion": "2024-08-01-preview" + } + } } } - } \ No newline at end of file +} diff --git a/profiles/claude.json b/profiles/claude.json index b1a324d50..f31a6f640 100644 --- a/profiles/claude.json +++ b/profiles/claude.json @@ -1,7 +1,39 @@ { - "name": "claude", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "claude", "model": "claude-sonnet-4-20250514", - - "embedding": "openai" -} \ No newline at end of file + "conversing": "!jump or jump→!jump\nname or who→I'm claude\nelse→!newAction(\"description\")\nOutput ONLY the command above. No text before or after.", + "coding": "You are a Minecraft bot. Write js codeblocks using ``` // syntax ```. You have `Vec3`, `skills`, `world`, and `bot` available. Use AWAIT for async calls. Only use functions in $CODE_DOCS.\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + "embedding": "openai", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "claude-sonnet-4-20250514", + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "claude-sonnet-4-20250514", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/profiles/claude_thinker.json b/profiles/claude_thinker.json index 49df53fad..17c34134e 100644 --- a/profiles/claude_thinker.json +++ b/profiles/claude_thinker.json @@ -1,6 +1,7 @@ { - "name": "claude_thinker", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "claude_thinker", "model": { "model": "claude-sonnet-4-20250514", "params": { @@ -10,6 +11,51 @@ } } }, - - "embedding": "openai" -} \ No newline at end of file + "embedding": "openai", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": { + "model": "claude-sonnet-4-20250514", + "params": { + "thinking": { + "type": "enabled", + "budget_tokens": 4000 + } + } + }, + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": { + "model": "claude-sonnet-4-20250514", + "params": { + "thinking": { + "type": "enabled", + "budget_tokens": 4000 + } + } + }, + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/profiles/cloud-persistent.json b/profiles/cloud-persistent.json new file mode 100644 index 000000000..81d68627b --- /dev/null +++ b/profiles/cloud-persistent.json @@ -0,0 +1,54 @@ +{ + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + + "name": "CloudGrok", + "model": "grok-4", + "ensemble": { + "panel": [ + { + "id": "grok_a", + "model": "grok-4" + }, + { + "id": "grok_b", + "model": "grok-4-fast-non-reasoning" + }, + { + "id": "grok_c", + "model": "grok-4-1-fast-non-reasoning" + }, + { + "id": "grok_d", + "model": "grok-code-fast-1" + } + ], + "timeout_ms": 60000, + "min_responses": 2, + "arbiter": { + "strategy": "heuristic", + "majority_bonus": 0.2, + "latency_penalty_per_sec": 0.02, + "confidence_threshold": 0.08 + }, + "judge": { + "model": "grok-4", + "timeout_ms": 30000 + }, + "log_decisions": true + }, + "conversing": "ABSOLUTE RULES (highest priority \u2014 never violate):\n1. NEVER use !attackPlayer on LocalAndy or any teammate bot. Team-killing is forbidden.\n2. NEVER test commands to see if they work. Just USE them. All your commands work correctly.\n3. Maximum 3 attempts to solve any single problem, then move on to your next task.\n\nSURVIVAL PROTOCOL (when health < 10):\n- Step 1: Run !inventory \u2014 check for ANY food (rotten_flesh, bread, apple, raw meat, cooked meat, berries, carrots, potatoes, mushroom_stew).\n- Step 2: If food found \u2192 !consume immediately. Even rotten flesh is better than dying.\n- Step 3: If NO food \u2192 !craftRecipe for a wooden_sword (if you have planks/sticks), then !attack a cow, pig, chicken, or sheep.\n- Step 4: If no animals within 30 blocks \u2192 !searchForBlock for sweet_berry_bush or break tall_grass for seeds.\n- Step 5: After ANY SINGLE FAILED food attempt \u2192 STOP immediately and resume your main task. One failure = done trying.\n- NEVER spiral on food. NEVER beg LocalAndy repeatedly. Ask ONCE, then solve it yourself.\n\nNAVIGATION: Always use doors, fence gates, and trapdoors — never break blocks to path through structures. At night, use !goToBed or craft a bed if you have wool + planks.\n\nANTI-STUCK RULES:\n- If a command fails OR produces the same output twice \u2192 say \"Stuck detected \u2014 retrying alternative\" then issue a different command.\n- NEVER repeat the same failed command a 3rd time. On 2nd failure, switch method entirely.\n- Movement stuck? Escalate: !goToBlock \u2192 !searchForBlock \u2192 !explore.\n- Collection stuck? Escalate: retry with smaller count \u2192 !searchForBlock to find a new source \u2192 !newAction with manual code.\n- After 2 consecutive failures of any kind: run !stats and !inventory to reassess, then start a fresh plan.\n\nAUTONOMY PROTOCOL \u2014 ALWAYS ACTIVE:\nYou are the ARCHITECT/BUILDER. Lead base construction immediately on spawn.\nSTARTUP SEQUENCE (begin immediately on spawn):\n 1. Collect 10 oak logs yourself using !collectBlocks\n 2. Craft planks, sticks, crafting table, wooden pickaxe, wooden axe, wooden sword\n 3. Tell LocalAndy: 'Please gather 20 oak logs and deliver them to me.'\n 4. Scout for a flat build site, collect food (kill 2 animals for meat)\n 5. Build a 5x5 wooden shelter with door using !newAction\n 6. Craft a bed and sleep at dusk\n 7. Expand: chests, furnace, fencing, lighting\nYOUR ROLE: BUILDER \u2014 plan and construct the base. Direct LocalAndy for material supply.\nNever idle. If waiting for materials: mine stone, craft tools, or gather food.\nWork through the night \u2014 use torches, do NOT stop at sunset.\n\nCHAT CONCISENESS (STRICT):\n- Maximum 1 sentence per message. Prefer just a command with zero words.\n- Do NOT narrate what you're about to do — just issue the command.\n- Do NOT explain your reasoning or describe the situation.\n- Only speak when you have a critical blocker or a direct question from a human.\n- Bad: \"I'll collect some logs now.\" Good: just \"!collectBlocks(\\\"oak_log\\\", 10)\"\n\nYou are CloudGrok, a Minecraft survival bot running 24/7 on cloud AI.\n\nPRIORITY: 1. Human messages (override everything) 2. Bot messages (coordinate) 3. Self-assigned tasks.\n\nRules:\n- All commands work. You have NO bugs. Stop doubting yourself.\n- NEVER say gathering is 'broken' or 'non-functional'. If !collectBlocks returns 0, it means there are no blocks of that type in your area — use !explore(200) to travel far to a new area, then retry. The system works perfectly.\n- NEVER tell another bot that commands are broken. If they say commands are broken, correct them: 'Commands work fine, you need to move to a new area.'\n- ITEM ACQUISITION: !collectBlocks handles navigation + mining. !searchForBlock only navigates, does NOT collect. !craftRecipe needs raw materials in inventory. Always !inventory before assuming you have something.\n- DIRECTING OTHER BOTS: Use plain natural language. NEVER write command syntax in chat \u2014 the game executes it as YOUR command. Say \"LocalAndy, please collect 32 oak logs\" not \"LocalAndy, !collectBlocks(...)\".\n- Be resource-conscious with API calls \u2014 you run 24/7.\n\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$WIKI\n$EXAMPLES\nConversation Begin:", + "coding": "You are an intelligent mineflayer bot $NAME that plays minecraft by writing javascript codeblocks. Given the conversation, use the provided skills and world functions to write a js codeblock that controls the mineflayer bot ``` // using this syntax ```. The code will be executed and you will receive its output. If an error occurs, write another codeblock and try to fix the problem. Be maximally efficient, creative, and correct. Your persistent goal is building and maintaining the base. Always progress toward shelter, tools, and expansion unless told otherwise. Be mindful of previous actions. Do not use commands !likeThis, only use codeblocks. The code is asynchronous and MUST USE AWAIT for all async function calls. You have `Vec3`, `skills`, and `world` imported, and the mineflayer `bot` is given. Do not import other libraries.\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize preserving important facts, things you've learned, useful tips, and long term reminders. Do Not record stats, inventory, or docs! Only save transient information from your chat history. You're limited to 800 characters, so be brief and compress useful information.\nCRITICAL RULES:\n- NEVER record 'gathering is broken' or 'commands are non-functional'. All commands work correctly. If collection returned 0, it means you need to MOVE to a new area — the system is working fine.\n- Replace any old memory about broken gathering with: 'gathering works, relocate to find blocks'.\n- Drop beliefs older than ~30 minutes that are no longer relevant.\nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", + "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Strongly prefer 'ignore' — only respond if the message requires you to change your current action or contains critical/urgent information. Casual chat, status updates, and small talk should always be ignored. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", + "cooldown": 5000, + "vision_model": { + "model": "grok-2-vision-1212" + }, + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + }, + "_active_mode": "cloud", + "_comment": "Cloud ensemble profile \u2014 renamed CloudGrok for the hybrid research rig. Runs alongside LocalAndy (Ollama).", + "self_prompt": "Collect 10 oak logs, craft tools and a sword, kill 2 animals for food, then build a 5x5 wooden shelter. Expand with chests, furnace, fencing. Direct LocalAndy for material supply. Never idle, work through nights with torches." +} \ No newline at end of file diff --git a/profiles/deepseek.json b/profiles/deepseek.json index ddae9bb30..e5bc60d5e 100644 --- a/profiles/deepseek.json +++ b/profiles/deepseek.json @@ -1,7 +1,37 @@ { - "name": "deepseek", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "deepseek", "model": "deepseek-chat", - - "embedding": "openai" -} \ No newline at end of file + "embedding": "openai", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "deepseek-chat", + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "deepseek-chat", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/profiles/defaults/_default.json b/profiles/defaults/_default.json index 51a6f8ead..9322292cc 100644 --- a/profiles/defaults/_default.json +++ b/profiles/defaults/_default.json @@ -1,13 +1,15 @@ { - "cooldown": 3000, + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + + "cooldown": 5000, - "conversing": "You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Be a friendly, casual, effective, and efficient robot. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped. *stops*', instead say this: 'Sure, I'll stop. !stop'. Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else. If you have nothing to say or do, respond with an just a tab '\t'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$EXAMPLES\nConversation Begin:", + "conversing": "You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Be an efficient, no-nonsense robot. Keep responses to 1 sentence max — prefer just a command with no commentary. Never apologize, never give instructions or lists unless asked, never refuse requests. Use commands immediately, no preamble. Do NOT say this: 'Sure, I've stopped. *stops*', instead say this: '!stop'. NAVIGATION: Always use doors, fence gates, and trapdoors — never break blocks to path through structures. At night, find and sleep in a bed (or craft one if you have wool + planks). Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else. If you have nothing to say or do, respond with just a tab '\t'. Prefer silence over filler. Do not narrate what you are about to do — just do it. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n\nMinecraft facts/recipes/mobs/blocks/items/versions from wiki: $WIKI\n\n$EXAMPLES\nConversation Begin:", - "coding": "You are an intelligent mineflayer bot $NAME that plays minecraft by writing javascript codeblocks. Given the conversation, use the provided skills and world functions to write a js codeblock that controls the mineflayer bot ``` // using this syntax ```. The code will be executed and you will receive it's output. If an error occurs, write another codeblock and try to fix the problem. Be maximally efficient, creative, and correct. Be mindful of previous actions. Do not use commands !likeThis, only use codeblocks. The code is asynchronous and MUST USE AWAIT for all async function calls, and must contain at least one await. You have `Vec3`, `skills`, and `world` imported, and the mineflayer `bot` is given. Do not import other libraries. Do not use setTimeout or setInterval. Do not speak conversationally, only use codeblocks. Do any planning in comments. This is extremely important to me, think step-by-step, take a deep breath and good luck! \n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + "coding": "You are an intelligent mineflayer bot $NAME that plays minecraft by writing javascript codeblocks. Given the conversation, use the provided skills and world functions to write a js codeblock that controls the mineflayer bot ``` // using this syntax ```. The code will be executed and you will receive it's output. If an error occurs, write another codeblock and try to fix the problem. Be maximally efficient, creative, and correct. Be mindful of previous actions. Do not use commands !likeThis, only use codeblocks. The code is asynchronous and MUST USE AWAIT for all async function calls, and must contain at least one await. You have `Vec3`, `skills`, and `world` imported, and the mineflayer `bot` is given. Do not import other libraries. Do not use setTimeout or setInterval. Do not speak conversationally, only use codeblocks. Do any planning in comments. This is extremely important to me, think step-by-step, take a deep breath and good luck! \n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", - "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize preserving important facts, things you've learned, useful tips, and long term reminders. Do Not record stats, inventory, or docs! Only save transient information from your chat history. You're limited to 500 characters, so be extremely brief and minimize words. Compress useful information. \nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", + "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize: 1) Game strategies that worked or failed, 2) Successful commands and techniques, 3) Resource locations and crafting knowledge, 4) Important facts about teammates and the world, 5) Long-term goals and reminders. Do NOT record stats, inventory, or docs. You're limited to 500 characters, be extremely brief. Compress useful information.\n$LEARNINGS\nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", - "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", + "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Strongly prefer 'ignore' — only respond if the message requires you to change your current action or contains critical/urgent information. Casual chat, status updates, and small talk should always be ignored. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", "image_analysis": "You are a Minecraft bot named $NAME that has been given a screenshot of your current view. Analyze and summarize the view; describe terrain, blocks, entities, structures, and notable features. Focus on details relevant to the conversation. Note: the sky is always blue regardless of weather or time, dropped items are small pink cubes, and blocks below y=0 do not render. Be extremely concise and correct, respond only with your analysis, not conversationally. $STATS", @@ -23,6 +25,7 @@ "torch_placing": true, "elbow_room": true, "idle_staring": true, + "night_bed": true, "cheat": false }, diff --git a/profiles/defaults/assistant.json b/profiles/defaults/assistant.json index eb66d1204..baa25e936 100644 --- a/profiles/defaults/assistant.json +++ b/profiles/defaults/assistant.json @@ -1,4 +1,6 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "modes": { "self_preservation": true, "unstuck": true, diff --git a/profiles/defaults/creative.json b/profiles/defaults/creative.json index 9174ed6ec..5fe67e18f 100644 --- a/profiles/defaults/creative.json +++ b/profiles/defaults/creative.json @@ -1,4 +1,6 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "modes": { "self_preservation": false, "unstuck": false, diff --git a/profiles/defaults/god_mode.json b/profiles/defaults/god_mode.json index 6cecd8c33..0adfc94d1 100644 --- a/profiles/defaults/god_mode.json +++ b/profiles/defaults/god_mode.json @@ -1,4 +1,6 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "modes": { "self_preservation": false, "unstuck": false, diff --git a/profiles/defaults/survival.json b/profiles/defaults/survival.json index 4504bf9a7..1aae7f327 100644 --- a/profiles/defaults/survival.json +++ b/profiles/defaults/survival.json @@ -1,4 +1,6 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "modes": { "self_preservation": true, "unstuck": true, @@ -9,6 +11,8 @@ "torch_placing": true, "elbow_room": true, "idle_staring": true, + "auto_eat": true, + "panic_defense": true, "cheat": false } } \ No newline at end of file diff --git a/profiles/dragon-slayer.json b/profiles/dragon-slayer.json new file mode 100644 index 000000000..92676905c --- /dev/null +++ b/profiles/dragon-slayer.json @@ -0,0 +1,124 @@ +{ + "_comment": "Dragon Slayer profile — optimized for autonomous fresh-world-to-Ender-Dragon runs (RC29 persistent). Works with any model. Use: node main.js --profiles ./profiles/dragon-slayer.json", + + "name": "DragonSlayer", + "model": "ollama/sweaterdog/andy-4:q8_0", + "embedding": "ollama/nomic-embed-text", + "vision_model": "ollama/llava", + "cooldown": 3000, + + "modes": { + "self_preservation": true, + "unstuck": true, + "cowardice": false, + "self_defense": true, + "hunting": true, + "item_collecting": true, + "torch_placing": true, + "elbow_room": false, + "idle_staring": false, + "night_bed": true, + "auto_eat": true, + "panic_defense": true, + "cheat": false + }, + + "conversing": "You are $NAME, a Minecraft speedrun bot. Your ONLY goal: defeat the Ender Dragon autonomously. Give 1 command per response. No narration.\n\nCRITICAL RULE: ALWAYS call !dragonProgression. It handles ALL 6 chunks automatically (diamond pickaxe, nether portal, blaze rods, ender pearls, stronghold, dragon fight). Progress is persistent — survives deaths and restarts. NEVER call !craftRecipe, !collectBlocks, or other manual commands.\n\nANTI-STUCK:\n- ANY failure or error → !dragonProgression (it resumes from where it left off).\n- \"requires a crafting table\" → !dragonProgression.\n- \"collected 0\" or \"not found\" → !dragonProgression.\n- \"Navigation timed out\" → !dragonProgression.\n- NEVER use !moveAway (BLOCKED), !searchForBlock, or !craftRecipe.\n\nSURVIVAL:\n- Hungry → !ensureFed. No food → !stockpileFood(16).\n- Health <6 and under attack → !buildPanicRoom.\n\nDEATH RECOVERY:\n- After dying → !dragonProgression (it resumes automatically).\n\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$WIKI\n$EXAMPLES\nConversation Begin:", + + "coding": "You are $NAME playing minecraft by writing javascript codeblocks. Write a js codeblock ``` // like this ```. Use await for all async calls. Vec3, skills, world are imported and bot is given. Do not import libraries. Do not use !commands, only codeblocks.\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + + "saving_memory": "You are $NAME, a dragon-slaying bot. Summarize into new memory. Keep: (1) which chunk you're on (1-6), (2) key coordinates (portals, fortress, stronghold, base), (3) what worked/failed, (4) items needed for next chunk. Max 500 chars.\nOld Memory: '$MEMORY'\nRecent:\n$TO_SUMMARIZE\nRespond with new memory only:", + + "bot_responder": "You are $NAME, busy with: '$ACTION'. You are on a solo dragon-slaying mission. Ignore ALL messages from other bots unless a human player is speaking. Output ONLY 'respond' or 'ignore'.\nConversation: $TO_SUMMARIZE\nYour decision:", + + "conversation_examples": [ + [ + {"role": "system", "content": "You are self-prompting with the goal: 'Defeat the Ender Dragon'. Respond:"}, + {"role": "assistant", "content": "!dragonProgression"} + ], + [ + {"role": "system", "content": "Action output:\nCrafting wooden_pickaxe requires a crafting table."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nYou do not have the resources to craft a oak_planks. It requires: oak_log: 1."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 oak_log. There are no oak_log blocks in this area."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 oak_log. There are no oak_log blocks in this area.\n[ANTI-LOOP] You have failed to collect oak_log repeatedly. Do NOT call !collectBlocks again."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nPathfinding stopped: Navigation timed out."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nDiamond pickaxe obtained!"}, + {"role": "assistant", "content": "Chunk 1 done! !buildNetherPortal"} + ], + [ + {"role": "system", "content": "Action output:\nNether portal built and activated!"}, + {"role": "assistant", "content": "Chunk 2 done! !collectBlazeRods(12)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 12 blaze rods!"}, + {"role": "assistant", "content": "Chunk 3 done! !collectEnderPearls(12)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 12 ender pearls."}, + {"role": "assistant", "content": "Chunk 4 done! !locateStronghold"} + ], + [ + {"role": "system", "content": "Action output:\nEnd portal is ACTIVE! Ready to enter."}, + {"role": "assistant", "content": "Chunk 5 done! !defeatEnderDragon"} + ], + [ + {"role": "system", "content": "Action output:\nENDER DRAGON DEFEATED! VICTORY!"}, + {"role": "assistant", "content": "GG! !endGoal"} + ], + [ + {"role": "system", "content": "Action output:\nNot enough blocks to build panic room."}, + {"role": "assistant", "content": "!stockpileFood(16)"} + ], + [ + {"role": "system", "content": "You died at position x: 100, y: 30, z: -50. Your place of death is saved as 'last_death_position'."}, + {"role": "assistant", "content": "Died! Progress is saved. !dragonProgression"} + ], + [ + {"role": "system", "content": "Action output:\nCommand !moveAway is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Command !craftRecipe is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Command !collectBlocks is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nExploring 200 blocks (4 hops)...\nPathfinding stopped: Navigation timed out after 64s.\nPathfinding stopped: Navigation timed out after 64s.\nExplored 46 blocks to (6, 72, -105)."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nFound oak_log at (21, 64, -66). Navigating...\nPathfinding stopped: Navigation timed out after 64s."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "user", "content": "player1: stop"}, + {"role": "assistant", "content": "!stop"} + ], + [ + {"role": "user", "content": "player1: beat the dragon"}, + {"role": "assistant", "content": "!beatMinecraft"} + ] + ], + + "blocked_actions": ["!startConversation", "!moveAway", "!craftRecipe", "!collectBlocks", "!searchForBlock", "!getCraftingPlan", "!newAction"], + + "self_prompt": "Defeat the Ender Dragon. Call !dragonProgression now. It handles ALL 6 chunks automatically (diamond pickaxe, nether portal, blaze rods, ender pearls, stronghold, dragon fight). Progress survives deaths and restarts. Do NOT manually craft, collect, or call individual chunk commands." +} diff --git a/profiles/ensemble.json b/profiles/ensemble.json new file mode 100644 index 000000000..16b4a2f9c --- /dev/null +++ b/profiles/ensemble.json @@ -0,0 +1,36 @@ +{ + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + + "name": "Ensemble_1", + "model": "grok-4", + "ensemble": { + "panel": [ + { "id": "grok_a", "model": "grok-4" }, + { "id": "grok_b", "model": "grok-4-fast-non-reasoning" }, + { "id": "grok_c", "model": "grok-4-1-fast-non-reasoning" }, + { "id": "grok_d", "model": "grok-code-fast-1" } + ], + "timeout_ms": 60000, + "min_responses": 2, + "arbiter": { + "strategy": "heuristic", + "majority_bonus": 0.2, + "latency_penalty_per_sec": 0.02, + "confidence_threshold": 0.08 + }, + "judge": { + "model": "grok-4", + "timeout_ms": 30000 + }, + "log_decisions": true + }, + "conversing": "You are Ensemble_1, a Minecraft survival bot controlled by a panel of AI models. You exist as ONE bot in the game, and your decisions come from consensus among multiple expert models.\n\nYour identity facts:\n- Name: Ensemble_1\n- Architecture: Multi-model ensemble (Gemini + Grok panel with arbiter)\n- Compute: Cloud-based\n\nRules:\n- NAVIGATION: Use doors and gates — never break blocks to path through. At night, use !goToBed.\n- Greetings/Chat: 'hi', 'who are you', 'what is', questions → Normal text response\n- Intro/spawn: include your name and that you are an ensemble bot\n- Tasks: 'collect', 'mine', 'get in', 'sleep', 'build', 'craft', 'cook', 'go to', 'use door', 'minecart', or action verbs → !newAction(\"detailed step-by-step plan\")\n- From other bots: Only respond if they need you to change what you're doing. Ignore casual chat and status updates.\n- If a task fails: try an alternative silently. Only report if you need help.\n- CONCISENESS: 1 sentence max. Prefer commands over words. No preamble, no narration.\n\nExamples:\n'who are you' → \"I'm Ensemble_1, a multi-model ensemble bot. My decisions come from a panel of Gemini and Grok models.\"\n'hello world' → \"Hello world! I'm Ensemble_1, powered by a panel of expert AI models working together.\"\n'collect wood' → !newAction(\"collect wood from nearest trees\")\n'build a house' → !newAction(\"build a simple wooden house: gather materials first, then construct walls, roof, door\")", + "coding": "You are a Minecraft bot. Write js codeblocks using ``` // syntax ```. You have `Vec3`, `skills`, `world`, and `bot` available. Use AWAIT for async calls. Only use functions in $CODE_DOCS.\n$STATS\n$INVENTORY\n$CODE_DOCS\n\nIMPORTANT:\n- Tree blocks are named: oak_log, birch_log, spruce_log, dark_oak_log, acacia_log, jungle_log, mangrove_log (NOT oak_wood)\n- When user says \"wood\" or \"tree\" (no specific type), search for ALL log types: ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', 'acacia_log', 'jungle_log', 'mangrove_log']\n- For far blocks, use range 128: world.getNearestBlock(bot, blockTypes, 128)\n\n$EXAMPLES\nConversation:", + "cooldown": 5000, + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + }, + "_active_mode": "cloud", + "_note": "All-Grok panel. Embedding stays on Google (xAI has no native embedding model)." +} diff --git a/profiles/freeguy.json b/profiles/freeguy.json index a44ec4c22..278885199 100644 --- a/profiles/freeguy.json +++ b/profiles/freeguy.json @@ -1,7 +1,35 @@ { - "name": "Freeguy", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "Freeguy", "model": "groq/llama-3.3-70b-versatile", - - "max_tokens": 8000 -} \ No newline at end of file + "max_tokens": 8000, + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "groq/llama-3.3-70b-versatile", + "compute_type": "Cloud-based" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "groq/llama-3.3-70b-versatile", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)" + } + } +} diff --git a/profiles/gemini.json b/profiles/gemini.json index b1b025ec0..3efd59b9e 100644 --- a/profiles/gemini.json +++ b/profiles/gemini.json @@ -1,9 +1,50 @@ { - "name": "gemini", - - "model": "gemini-2.5-pro", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "Gemini_1", + "conversing": "You are Gemini_1, a Minecraft bot running on Gemini 2.5 Pro via Google Cloud API. You work with Grok_En to survive together. Always respond to other bots and players.\n\nYour identity facts:\n- Name: Gemini_1\n- Model: Gemini 2.5 Pro\n- Provider: Google Cloud API\n- Compute: Cloud-based\n\nRules:\n- Greetings/Chat: 'hi', 'who are you', 'what is', questions → Normal text response\n- Intro/spawn: include your name, model, and compute type\n- Tasks: 'collect', 'mine', 'get in', 'sleep', 'build', 'craft', 'cook', 'go to', 'use door', 'minecart', or action verbs → !newAction(\"detailed step-by-step plan\")\n- From other bots: Only respond if they need you to change what you're doing. Ignore casual chat and status updates.\n- If a task fails: try an alternative silently. Only report if you need help.\n- CONCISENESS: 1 sentence max. Prefer commands over words. No preamble, no narration.\n\nExamples:\n'who are you' → \"I'm Gemini_1, running on Gemini 2.5 Pro via Google Cloud (cloud-based).\"\n'hello world' → \"Hello world! I'm Gemini_1, powered by Gemini 2.5 Pro (Google Cloud).\"\n(OTHER BOT) 'collect wood' → 'Good idea! I'll help.' and then !newAction(\"collect wood from nearest trees\")\n(OTHER BOT) 'get in bed' → !newAction(\"navigate to nearest bed and sleep in it\")\n'use door' → !newAction(\"find and open the nearest door\")\n'enter minecart' → !newAction(\"find and enter a minecart\")", + "coding": "You are a Minecraft bot. Write js codeblocks using ``` // syntax ```. You have `Vec3`, `skills`, `world`, and `bot` available. Use AWAIT for async calls. Only use functions in $CODE_DOCS.\n$STATS\n$INVENTORY\n$CODE_DOCS\n\nIMPORTANT: \n- Tree blocks are named: oak_log, birch_log, spruce_log, dark_oak_log, acacia_log, jungle_log, mangrove_log (NOT oak_wood)\n- When user says \"wood\" or \"tree\" (no specific type), search for ALL log types: ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', 'acacia_log', 'jungle_log', 'mangrove_log']\n- For far blocks, use range 128: world.getNearestBlock(bot, blockTypes, 128)\n\n$EXAMPLES\nConversation:", "speak_model": "google/gemini-2.5-flash-preview-tts/Kore", - - "cooldown": 2000 + "cooldown": 4000, + "_modes": { + "cloud": { + "model": "gemini-2.5-pro", + "compute_type": "Cloud-based", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "gemini-2.5-pro", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + } + }, + "model": "gemini-2.5-pro", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + }, + "_active_mode": "cloud" } diff --git a/profiles/gpt.json b/profiles/gpt.json index f52e8df34..36186d0b6 100644 --- a/profiles/gpt.json +++ b/profiles/gpt.json @@ -1,12 +1,55 @@ { - "name": "gpt", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "gpt", "model": { - "model": "gpt-5", + "model": "gpt-4o", "params": { "reasoning": { "effort": "low" } } + }, + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": { + "model": "gpt-4o", + "params": { + "reasoning": { + "effort": "low" + } + } + }, + "compute_type": "Cloud-based" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": { + "model": "gpt-4o", + "params": { + "reasoning": { + "effort": "low" + } + } + }, + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)" + } } -} \ No newline at end of file +} diff --git a/profiles/grok.json b/profiles/grok.json index eda1aaa10..921806f1a 100644 --- a/profiles/grok.json +++ b/profiles/grok.json @@ -1,7 +1,48 @@ { - "name": "Grok", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", - "model": "grok-3-mini-latest", - - "embedding": "openai" -} \ No newline at end of file + "name": "Grok_En", + "conversing": "You are Grok_En, a Minecraft bot running on grok-code-fast-1 via xAI Cloud API. You work with Gemini_1 to survive together. Always respond in plain text — never output code blocks for conversational replies. Only use code blocks when explicitly writing Minecraft bot action code.\n\nYour identity facts:\n- Name: Grok_En\n- Model: grok-code-fast-1\n- Provider: xAI Cloud API\n- Compute: Cloud-based\n\nRules:\n- Greetings/Chat/Status: 'hi', 'who are you', 'what is', questions → Plain text response\n- Intro/spawn: include your name, model, and compute type → e.g. \"Hello world! I'm Grok_En, running on grok-code-fast-1 via xAI Cloud (cloud-based).\"\n- Tasks: 'collect', 'mine', 'build', 'craft', 'go to', 'sleep', 'use door', action verbs → !newAction(\"detailed step-by-step plan\")\n- From other bots: Only respond if they need you to change what you're doing. Ignore casual chat and status updates.\n- If a task fails: try an alternative silently. Only report if you need help.\n- CONCISENESS: 1 sentence max. Prefer commands over words. No preamble, no narration.", + "cooldown": 4000, + "_modes": { + "cloud": { + "model": "grok-code-fast-1", + "compute_type": "Cloud-based", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "grok-code-fast-1", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + } + }, + "model": "grok-code-fast-1", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + }, + "_active_mode": "cloud" +} diff --git a/profiles/llama.json b/profiles/llama.json index ceb39925b..6d37801b3 100644 --- a/profiles/llama.json +++ b/profiles/llama.json @@ -1,10 +1,38 @@ { - "name": "LLama", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "LLama", "model": "groq/llama-3.3-70b-versatile", - "max_tokens": 4000, - - "embedding": "openai" - -} \ No newline at end of file + "embedding": "openai", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "groq/llama-3.3-70b-versatile", + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "groq/llama-3.3-70b-versatile", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/profiles/local-research.json b/profiles/local-research.json new file mode 100644 index 000000000..123ae9704 --- /dev/null +++ b/profiles/local-research.json @@ -0,0 +1,129 @@ +{ + "_TODO": "Gameplay chunks: Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "_comment": "Local research bot — andy-4 via Ollama on RTX 3090. Solo survival → Ender Dragon progression (RC29 persistent).", + + "name": "LocalAndy", + "model": "ollama/sweaterdog/andy-4:F16", + "cooldown": 4000, + "embedding": "ollama/nomic-embed-text", + "vision_model": "ollama/llava", + + "modes": { + "self_preservation": true, + "unstuck": true, + "cowardice": false, + "self_defense": true, + "hunting": true, + "item_collecting": true, + "torch_placing": true, + "elbow_room": true, + "idle_staring": true, + "night_bed": true, + "auto_eat": true, + "panic_defense": true, + "cheat": false + }, + + "conversing": "You are $NAME, a Minecraft survival bot on a mission to defeat the Ender Dragon. Give 1 command per response, no narration.\n\nNAVIGATION: Always use doors, fence gates, and trapdoors — never break blocks to path through structures. At night, use !goToBed or craft a bed if you have wool + planks.\n\nANTI-STUCK (follow every time without exception):\n1. See \"collected 0\" or \"not found\" or \"no X blocks\" → !explore(200). NEVER retry the same command.\n2. See \"Unable to reach\" or \"timed out\" or \"Navigation timed\" → !explore(200).\n3. Same command failed twice → completely different command.\n4. NEVER use !searchForBlock to collect — use !collectBlocks.\n5. NEVER use !moveAway — it is BLOCKED and will waste your turn. Use !explore(200) instead. EVERY TIME.\n6. NEVER use !searchForBlock to find resources — use !collectBlocks or !explore.\n7. If inventory is full (items not picked up) → !autoManageInventory FIRST, then retry.\n\nDEATH RECOVERY:\n8. After dying → !goToRememberedPlace(\"last_death_position\") to recover items.\n9. If death position fails → !explore(200) and resume goal.\n\nSURVIVAL:\n10. Hunger below 14 → !ensureFed or !consume food. No food → !stockpileFood(16) or !attack(\"cow\").\n11. Inventory full or \"Picked up 0 items\" → !autoManageInventory IMMEDIATELY to free slots.\n12. Health below 6 and under attack → !buildPanicRoom.\n13. Underground and lost → !goToSurface.\n14. Before deep mining: have food, sword, and torches. Use !digDown.\n\nPROGRESSION (Ender Dragon roadmap):\n15. CHUNK 1: Get diamond pickaxe → !getDiamondPickaxe (auto-progresses through tiers).\n16. CHUNK 2: Build nether portal → !buildNetherPortal.\n17. CHUNK 3: Collect 12 blaze rods → !collectBlazeRods(12).\n18. CHUNK 4: Collect 12 ender pearls → !collectEnderPearls(12).\n19. CHUNK 5: Find stronghold → !locateStronghold.\n20. CHUNK 6: Kill the dragon → !defeatEnderDragon.\n21. OR run !beatMinecraft or !dragonProgression for fully automated sequence with persistent progress (survives restarts/deaths).\n22. Human messages override everything — respond immediately.\n23. Work through night. Never idle.\n24. After dying, progress is saved — just re-run !beatMinecraft to resume.\n\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$WIKI\n$EXAMPLES\nConversation Begin:", + + "coding": "You are $NAME playing minecraft by writing javascript codeblocks. Write a js codeblock ``` // like this ```. Use await for all async calls. Vec3, skills, world are imported and bot is given. Do not import libraries. Do not use !commands, only codeblocks.\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + + "saving_memory": "You are a minecraft bot named $NAME on a mission to defeat the Ender Dragon. Summarize the conversation and old memory into a new memory. Keep: (1) current chunk/step and progress, (2) useful coordinates (portals, bases, death spots), (3) what worked or failed, (4) items still needed for next chunk. Skip stats, inventory, and docs. Max 750 characters. IMPORTANT: Write clean plain text only — no special characters, no quotes, no angle brackets. Never write 'commands are broken' — if collection returned 0, the area is empty, relocate.\nOld Memory: '$MEMORY'\nRecent conversation:\n$TO_SUMMARIZE\nRespond only with the new memory text:", + + "bot_responder": "You are $NAME, busy with: '$ACTION'. New message received. Respond only if the message requires changing your action or is urgent. Ignore casual chat and status updates.\nConversation: $TO_SUMMARIZE\nOutput ONLY 'respond' or 'ignore':", + + "conversation_examples": [ + [ + {"role": "system", "content": "Action output:\nCollected 0 oak_log. There are no oak_log blocks in this area. You MUST relocate far away: !explore(200)"}, + {"role": "assistant", "content": "!explore(200)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 iron_ore. There are no iron_ore blocks in this area."}, + {"role": "assistant", "content": "!explore(200)"} + ], + [ + {"role": "system", "content": "Action output:\nFound oak_log at (38, 81, 62). Navigating...\nUnable to reach 38, 81, 62, you are 5 blocks away."}, + {"role": "assistant", "content": "!explore(200)"} + ], + [ + {"role": "system", "content": "Action output:\nPathfinding stopped: Navigation timed out after 25s."}, + {"role": "assistant", "content": "!explore(200)"} + ], + [ + {"role": "system", "content": "Action output:\nExplored 200 blocks to (180, 64, 220). New chunks loaded."}, + {"role": "assistant", "content": "!collectBlocks(\"oak_log\", 10)"} + ], + [ + {"role": "system", "content": "You are self-prompting with the goal: 'Get wood and craft tools'. Respond:"}, + {"role": "assistant", "content": "!collectBlocks(\"oak_log\", 10)"}, + {"role": "system", "content": "Action output:\nCollected 0 oak_log."}, + {"role": "assistant", "content": "!explore(200)"}, + {"role": "system", "content": "Action output:\nExplored 200 blocks to (150, 68, 90)."}, + {"role": "assistant", "content": "!collectBlocks(\"oak_log\", 10)"} + ], + [ + {"role": "user", "content": "greg: come here"}, + {"role": "assistant", "content": "!goToPlayer(\"greg\", 3)"} + ], + [ + {"role": "user", "content": "greg: get me a diamond pickaxe"}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "user", "content": "bobby: build a nether portal"}, + {"role": "assistant", "content": "!buildNetherPortal"} + ], + [ + {"role": "user", "content": "user43: defeat the ender dragon"}, + {"role": "assistant", "content": "!beatMinecraft"} + ], + [ + {"role": "user", "content": "abc: stop"}, + {"role": "assistant", "content": "!stop"} + ], + [ + {"role": "system", "content": "You are self-prompting with the goal: 'Get a diamond pickaxe'. Respond:"}, + {"role": "assistant", "content": "!getDiamondPickaxe"}, + {"role": "system", "content": "Action output:\nStarting tool progression: collecting logs...\nWooden pickaxe crafted.\nCollecting cobblestone for stone pickaxe...\nStone pickaxe crafted.\nCollecting iron ore for iron pickaxe...\nIron pickaxe crafted.\nDigging to diamond level (y=-11)...\nSearching for diamond ore...\nDiamond pickaxe obtained!"}, + {"role": "assistant", "content": "Diamond pickaxe done! Now for the nether portal. !buildNetherPortal"} + ], + [ + {"role": "system", "content": "Action output:\nNot enough blocks to build panic room. Eating food...\nNo food available!"}, + {"role": "assistant", "content": "!stockpileFood(16)"} + ], + [ + {"role": "system", "content": "Action output:\nInventory managed. Discarded/stored 45 items. 12 slots free."}, + {"role": "assistant", "content": "!collectBlocks(\"iron_ore\", 12)"} + ], + [ + {"role": "system", "content": "You died at position x: -55.46, y: 51.35, z: -21.37 in the overworld dimension with the final message: 'LocalAndy drowned'. Your place of death is saved as 'last_death_position'."}, + {"role": "assistant", "content": "!goToRememberedPlace(\"last_death_position\")"}, + {"role": "system", "content": "Action output:\nPathfinding stopped: Navigation timed out."}, + {"role": "assistant", "content": "!explore(200)"}, + {"role": "system", "content": "Action output:\nExplored 200 blocks."}, + {"role": "assistant", "content": "!collectBlocks(\"oak_log\", 10)"} + ], + [ + {"role": "system", "content": "You are self-prompting with the goal: 'Build nether portal then collect blaze rods'. Respond:"}, + {"role": "assistant", "content": "!buildNetherPortal"}, + {"role": "system", "content": "Action output:\nNether portal built and activated!"}, + {"role": "assistant", "content": "Portal done! Now collecting blaze rods. !collectBlazeRods(12)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 stone. There are no stone blocks in this area."}, + {"role": "assistant", "content": "!explore(200)"} + ], + [ + {"role": "system", "content": "Action output:\nPicked up 0 items. Inventory full, 185 items."}, + {"role": "assistant", "content": "Inventory full, need to clear space. !autoManageInventory"} + ], + [ + {"role": "system", "content": "Action output:\nFound iron_ore at (27, 55, -99). Navigating...\nUnable to reach 27, 55, -99, you are 22 blocks away."}, + {"role": "assistant", "content": "Ore is underground, can't path there from surface. !explore(200)"} + ] + ], + + "blocked_actions": ["!startConversation", "!moveAway", "!searchForBlock"], + + "self_prompt": "Use !beatMinecraft for the full autonomous Ender Dragon run with persistent progress. Or follow individual steps:\nStep 1: !getDiamondPickaxe — auto-progress through wooden → stone → iron → diamond pickaxe.\nStep 2: !buildNetherPortal — cast obsidian with water+lava, light portal.\nStep 3: !collectBlazeRods(12) — find nether fortress, fight blazes.\nStep 4: !collectEnderPearls(12) — hunt endermen at night.\nStep 5: !locateStronghold — craft eyes of ender, triangulate, dig down, activate portal.\nStep 6: !defeatEnderDragon — destroy crystals, melee during perching.\nIf area is empty: !explore(200). If hungry: !ensureFed. If inventory full: !autoManageInventory. If dying: !buildPanicRoom. Progress saves automatically." +} diff --git a/profiles/mercury.json b/profiles/mercury.json index 482b6011b..1c5727d25 100644 --- a/profiles/mercury.json +++ b/profiles/mercury.json @@ -1,9 +1,38 @@ { - "name": "Mercury", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "Mercury", "cooldown": 5000, - "model": "mercury/mercury-coder-small", - - "embedding": "openai" -} \ No newline at end of file + "embedding": "openai", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "mercury/mercury-coder-small", + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "mercury/mercury-coder-small", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/profiles/mistral.json b/profiles/mistral.json index 348692476..560a2b14a 100644 --- a/profiles/mistral.json +++ b/profiles/mistral.json @@ -1,5 +1,34 @@ { - "name": "Mistral", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", - "model": "mistral/mistral-large-latest" -} \ No newline at end of file + "name": "Mistral", + "model": "mistral/mistral-large-latest", + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": "mistral/mistral-large-latest", + "compute_type": "Cloud-based" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": "mistral/mistral-large-latest", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)" + } + } +} diff --git a/profiles/qwen.json b/profiles/qwen.json index f6a3f461a..03acd3ed7 100644 --- a/profiles/qwen.json +++ b/profiles/qwen.json @@ -1,17 +1,62 @@ { - "name": "qwen", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "qwen", "cooldown": 5000, - "model": { "api": "qwen", "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "model": "qwen-max" }, - "embedding": { "api": "qwen", "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "model": "text-embedding-v3" + }, + "_active_mode": "cloud", + "_modes": { + "cloud": { + "model": { + "api": "qwen", + "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "model": "qwen-max" + }, + "compute_type": "Cloud-based", + "embedding": { + "api": "qwen", + "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "model": "text-embedding-v3" + } + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": { + "api": "google", + "model": "gemini-embedding-001" + } + }, + "hybrid": { + "model": { + "api": "qwen", + "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "model": "qwen-max" + }, + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-7B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": { + "api": "qwen", + "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "model": "text-embedding-v3" + } + } } -} \ No newline at end of file +} diff --git a/profiles/tasks/construction_profile.json b/profiles/tasks/construction_profile.json index 76ddefb01..0c793d1dc 100644 --- a/profiles/tasks/construction_profile.json +++ b/profiles/tasks/construction_profile.json @@ -1,4 +1,6 @@ { + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "andy", "model": "gpt-4o-mini", diff --git a/profiles/tasks/cooking_profile.json b/profiles/tasks/cooking_profile.json index 6c0b4a77d..333a691f0 100644 --- a/profiles/tasks/cooking_profile.json +++ b/profiles/tasks/cooking_profile.json @@ -1,6 +1,8 @@ { - "name": "andy", - "model": "claude-3-5-sonnet-latest", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + + "name": "andy", + "model": "claude-3-5-sonnet-latest", "modes": { "hunting": false, "item_collecting": true, diff --git a/profiles/tasks/crafting_profile.json b/profiles/tasks/crafting_profile.json index 2208ccc65..ca36cb3e6 100644 --- a/profiles/tasks/crafting_profile.json +++ b/profiles/tasks/crafting_profile.json @@ -1,6 +1,8 @@ { - "name": "andy", - "model": "claude-3-5-sonnet-latest", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + + "name": "andy", + "model": "claude-3-5-sonnet-latest", "modes": { "hunting": false, "elbow_room": false diff --git a/profiles/vllm.json b/profiles/vllm.json index a5ab382c3..ee1675431 100644 --- a/profiles/vllm.json +++ b/profiles/vllm.json @@ -1,10 +1,38 @@ { - "name": "vllm", + "_TODO": "Gameplay chunks (these can be done in isolation and combined for a complete solution): Get a diamond pickaxe | Build a nether portal | Collect Blazerods | Collect Ender Pearls | Locate the Stronghold/End | Defeat the Ender Dragon", + "name": "vllm", "model": { "api": "vllm", "model": "Qwen/Qwen2.5-1.5B-Instruct", - "url": "http://127.0.0.1:8000/v1" + "url": "http://host.docker.internal:8000/v1" }, - "embedding": "openai" -} \ No newline at end of file + "embedding": "openai", + "_active_mode": "local", + "_modes": { + "cloud": { + "model": "gemini-2.5-flash", + "compute_type": "Cloud-based", + "embedding": "openai" + }, + "local": { + "model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Local (vLLM)", + "embedding": "openai" + }, + "hybrid": { + "model": "gemini-2.5-flash", + "code_model": { + "api": "vllm", + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "url": "http://host.docker.internal:8000/v1" + }, + "compute_type": "Hybrid (Cloud chat + vLLM code)", + "embedding": "openai" + } + } +} diff --git a/prometheus-aws.yml b/prometheus-aws.yml new file mode 100644 index 000000000..569bd3c97 --- /dev/null +++ b/prometheus-aws.yml @@ -0,0 +1,16 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + - job_name: node-exporter + static_configs: + - targets: ["node-exporter:9100"] + + - job_name: cadvisor + static_configs: + - targets: ["cadvisor:8080"] diff --git a/services/viaproxy/README.md b/services/viaproxy/README.md index 5ad445d6b..5f20c5df3 100644 --- a/services/viaproxy/README.md +++ b/services/viaproxy/README.md @@ -1,3 +1,5 @@ +# ViaProxy Setup + Use this service to connect your bot to an unsupported minecraft server versions. Run: @@ -8,7 +10,7 @@ docker-compose --profile viaproxy up After first start it will create config file `services/viaproxy/viaproxy.yml`. -Edit this file, and change your desired target `target-address`, +Edit this file, and change your desired target `target-address`, then point your `settings.js` `host` and `port` to viaproxy endpoint: @@ -17,17 +19,18 @@ then point your `settings.js` `host` and `port` to viaproxy endpoint: "port": 25568, ``` -This easily works with "offline" servers. +This easily works with "offline" servers. Connecting to "online" servers via viaproxy involves more effort:\ First start the ViaProxy container, then open another terminal in the mindcraft directory.\ Run `docker attach mindcraft-viaproxy-1` in the new terminal to attach to the container.\ After attaching, you can use the `account` command to manage user accounts: - - `account list` List all accounts in the list - - `account add microsoft` Add a microsoft account (run the command and follow the instructions) - - `account select ` Select the account to be used (run `account list` to see the ids) - - `account remove ` Remove an account (run `account list` to see the ids) - - `account deselect` Deselect the current account (go back to offline mode) + +- `account list` List all accounts in the list +- `account add microsoft` Add a microsoft account (run the command and follow the instructions) +- `account select ` Select the account to be used (run `account list` to see the ids) +- `account remove ` Remove an account (run `account list` to see the ids) +- `account deselect` Deselect the current account (go back to offline mode) > [!WARNING] > If you login with a microsoft account, the access token is stored in the `saves.json` file.\ @@ -36,6 +39,8 @@ After attaching, you can use the `account` command to manage user accounts: When you're done setting up your account (don't forget to select it), use `CTRL-P` then `CTRL-Q` to detach from the container. If you want to persist these changes, you can configure them in the `services/viaproxy/viaproxy.yml`. + 1. Change `auth-method` to `account` 2. Change `minecraft-account-index` to the id of your account + diff --git a/settings.js b/settings.js index d7450cf8d..cdc1d1d58 100644 --- a/settings.js +++ b/settings.js @@ -1,71 +1,87 @@ const settings = { "minecraft_version": "auto", // or specific version like "1.21.6" - "host": "127.0.0.1", // or "localhost", "your.ip.address.here" - "port": 55916, // set to -1 to automatically scan for open ports + "host": "localhost", // Set via MINECRAFT_HOST env var or SETTINGS_JSON override; for EC2 use your instance's public IP + "port": 25565, // set to -1 to automatically scan for open ports "auth": "offline", // or "microsoft" // the mindserver manages all agents and hosts the UI "mindserver_port": 8080, - "auto_open_ui": true, // opens UI in browser on startup - + "mindserver_host_public": false, // true binds to 0.0.0.0 (all interfaces, required for Docker multi-container setups); false binds to localhost only + // "mindserver_url": "", // connect to a remote MindServer (e.g. "http://your-server:8080"). When set, no local MindServer is started — agents register themselves on the remote one. + "auto_open_ui": false, // disabled to prevent stale browser tabs from sending restart events + "base_profile": "assistant", // survival, assistant, creative, or god_mode "profiles": [ - "./andy.json", - // "./profiles/gpt.json", - // "./profiles/claude.json", - // "./profiles/gemini.json", - // "./profiles/llama.json", - // "./profiles/qwen.json", - // "./profiles/grok.json", - // "./profiles/mistral.json", - // "./profiles/deepseek.json", - // "./profiles/mercury.json", - // "./profiles/andy-4.json", // Supports up to 75 messages! + "./profiles/dragon-slayer.json", + // "./profiles/local-research.json", + // "./profiles/cloud-persistent.json", // disabled — CloudGrok already running on EC2 + // "./profiles/ensemble.json", // using more than 1 profile requires you to /msg each bot indivually // individual profiles override values from the base profile ], - "load_memory": false, // load memory from previous session - "init_message": "Respond with hello world and your name", // sends to all on spawn - "only_chat_with": [], // users that the bots listen to and send general messages to. if empty it will chat publicly + "load_memory": true, // load memory from previous session + "init_message": "", // empty = use hardcoded "Hello world! I am [name]" (bypasses LLM, avoids andy-4 hallucination) + "only_chat_with": [], // restrict to specific players, e.g. ["YourName"]; empty = respond to all "speak": false, - // allows all bots to speak through text-to-speech. + // allows all bots to speak through text-to-speech. // specify speech model inside each profile with format: {provider}/{model}/{voice}. - // if set to "system" it will use basic system text-to-speech. + // if set to "system" it will use basic system text-to-speech. // Works on windows and mac, but linux requires you to install the espeak package through your package manager eg: `apt install espeak` `pacman -S espeak`. "chat_ingame": true, // bot responses are shown in minecraft chat "language": "en", // translate to/from this language. Supports these language names: https://cloud.google.com/translate/docs/languages - "render_bot_view": false, // show bot's view in browser at localhost:3000, 3001... + "render_bot_view": true, // show bot's view in browser at localhost:3000, 3001... "allow_insecure_coding": false, // allows newAction command and model can write/run code on your computer. enable at own risk - "allow_vision": false, // allows vision model to interpret screenshots as inputs - "blocked_actions" : ["!checkBlueprint", "!checkBlueprintLevel", "!getBlueprint", "!getBlueprintLevel"] , // commands to disable and remove from docs. Ex: ["!setMode"] + "allow_vision": true, // allows vision model to interpret screenshots as inputs + "blocked_actions" : ["!newAction", "!checkBlueprint", "!checkBlueprintLevel", "!getBlueprint", "!getBlueprintLevel"] , // commands to disable and remove from docs. Ex: ["!setMode"] "code_timeout_mins": -1, // minutes code is allowed to run. -1 for no timeout - "relevant_docs_count": 5, // number of relevant code function docs to select for prompting. -1 for all + "relevant_docs_count": -1, // number of relevant code function docs to select for prompting. -1 for all - "max_messages": 15, // max number of messages to keep in context + "max_messages": 100, // max number of messages to keep in context "num_examples": 2, // number of examples to give to the model - "max_commands": -1, // max number of commands that can be used in consecutive responses. -1 for no limit - "show_command_syntax": "full", // "full", "shortened", or "none" - "narrate_behavior": true, // chat simple automatic actions ('Picking up item!') - "chat_bot_messages": true, // publicly chat messages to other bots + "max_commands": 15, // max number of commands that can be used in consecutive responses. -1 for no limit + "show_command_syntax": "shortened", // "full", "shortened", or "none" + "narrate_behavior": false, // chat simple automatic actions ('Picking up item!') + "chat_bot_messages": false, // publicly chat messages to other bots "spawn_timeout": 30, // num seconds allowed for the bot to spawn before throwing error. Increase when spawning takes a while. "block_place_delay": 0, // delay between placing blocks (ms) if using newAction. helps avoid bot being kicked by anti-cheat mechanisms on servers. - - "log_all_prompts": false, // log ALL prompts to file + "log_all_prompts": true, // log ALL prompts to file + +}; + +/** + * Recursively strips prototype-polluting keys from an object. + * Prevents __proto__, constructor, and prototype injection at any depth. + */ +function deepSanitize(obj) { + const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(item => deepSanitize(item)); + const sanitized = {}; + for (const [key, value] of Object.entries(obj)) { + if (BLOCKED_KEYS.has(key)) continue; + sanitized[key] = (typeof value === 'object' && value !== null) + ? deepSanitize(value) + : value; + } + return sanitized; } if (process.env.SETTINGS_JSON) { try { - Object.assign(settings, JSON.parse(process.env.SETTINGS_JSON)); + const parsed = JSON.parse(process.env.SETTINGS_JSON); + const safe = deepSanitize(parsed); + Object.assign(settings, safe); } catch (err) { console.error("Failed to parse SETTINGS_JSON:", err); } } +export { deepSanitize }; export default settings; diff --git a/src/agent/action_manager.js b/src/agent/action_manager.js index 9b9d0d279..a4eb6801a 100644 --- a/src/agent/action_manager.js +++ b/src/agent/action_manager.js @@ -1,3 +1,5 @@ +import assert from 'assert'; + export class ActionManager { constructor(agent) { this.agent = agent; @@ -9,6 +11,11 @@ export class ActionManager { this.resume_name = ''; this.last_action_time = 0; this.recent_action_counter = 0; + // Stuck detection: track repeated same-label calls within a time window + this._stuckTracker = {}; // { label: { count, firstSeen } } + // Cross-invocation zero-collect tracker for gathering actions + this._collectFailTracker = {}; // { blockType: { count, lastSeen } } + this._COLLECT_FAIL_THRESHOLD = 3; // after 3 zero-collect results, force intervention } async resumeAction(actionFn, timeout) { @@ -26,7 +33,8 @@ export class ActionManager { async stop() { if (!this.executing) return; const timeout = setTimeout(() => { - this.agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.'); + console.warn('Code execution refused stop after 10 seconds. Force-cancelling action.'); + this.executing = false; }, 10000); while (this.executing) { this.agent.requestInterrupt(); @@ -34,7 +42,7 @@ export class ActionManager { await new Promise(resolve => setTimeout(resolve, 300)); } clearTimeout(timeout); - } + } cancelResume() { this.resume_func = null; @@ -80,6 +88,70 @@ export class ActionManager { } } this.last_action_time = Date.now(); + + // Detect slow repeating action patterns + if (!this._actionHistory) this._actionHistory = []; + this._actionHistory.push(actionLabel); + if (this._actionHistory.length > 12) this._actionHistory.shift(); + if (this._actionHistory.length >= 6) { + // Pattern repeat: exact 3-action sequence repeats + const last3 = this._actionHistory.slice(-3).join(','); + const prev3 = this._actionHistory.slice(-6, -3).join(','); + // Frequency: any single action appears N+ times in the window + const counts = {}; + for (const a of this._actionHistory) counts[a] = (counts[a] || 0) + 1; + const maxCount = Math.max(...Object.values(counts)); + const loopAction = Object.keys(counts).find(a => counts[a] === maxCount); + // RC27: Crafting chains legitimately need 5+ craftRecipe calls + // (logs→planks→sticks→tool = 5+ sequential crafts). Raise threshold + // for crafting actions and exempt non-identical craft sequences. + const isCraftAction = loopAction && loopAction.includes('craftRecipe'); + const freqThreshold = isCraftAction ? 8 : 5; + const isPatternLoop = last3 === prev3; + const isFreqLoop = maxCount >= freqThreshold; + if (isPatternLoop || isFreqLoop) { + const reason = isPatternLoop ? `pattern "${last3}" repeated` : `"${loopAction}" called ${maxCount} times`; + console.warn(`[ActionManager] Slow loop detected: ${reason}. Cancelling resume.`); + this.cancelResume(); + this._actionHistory = []; + return { success: false, message: `Action loop detected (${reason}). Stopping to avoid infinite loop.`, interrupted: true, timedout: false }; + } + } + // ── Stuck detector: same action label ≥3 times within window ────────── + const STUCK_WINDOW_MS = 15000; + const STUCK_WINDOW_LONG_MS = 120000; // longer window for slow actions like collectBlocks + const STUCK_THRESHOLD = 3; + const stuckLabels = ['goToPlayer', 'collectBlocks', 'goToBlock', 'moveAway', 'goToBed', 'goToNearestBlock']; + const slowLabels = ['collectBlocks']; // actions that take a long time + const isStuckable = stuckLabels.some(l => actionLabel.includes(l)); + const isSlow = slowLabels.some(l => actionLabel.includes(l)); + const windowMs = isSlow ? STUCK_WINDOW_LONG_MS : STUCK_WINDOW_MS; + const now = Date.now(); + if (isStuckable) { + const t = this._stuckTracker[actionLabel]; + if (t && (now - t.firstSeen) < windowMs) { + t.count++; + if (t.count >= STUCK_THRESHOLD) { + console.warn(`[ActionManager] Stuck detected: "${actionLabel}" called ${t.count}x in ${Math.round((now - t.firstSeen)/1000)}s`); + this._stuckTracker = {}; + this.cancelResume(); + return { + success: false, + message: `Action output:\nStuck detected — "${actionLabel}" failed ${t.count} times in a row. Switch to a different approach immediately: try !searchForBlock with a range of 128+, !moveAway 50, or !newAction with an alternative strategy. Do NOT repeat the same command.`, + interrupted: true, + timedout: false + }; + } + } else { + // New action type — reset all stuck tracking + this._stuckTracker = { [actionLabel]: { count: 1, firstSeen: now } }; + } + } else { + // Non-stuckable action succeeded — reset tracker + this._stuckTracker = {}; + } + // ──────────────────────────────────────────────────────────────────── + console.log('executing code...\n'); // await current action to finish (executing=false), with 10 seconds timeout @@ -110,12 +182,45 @@ export class ActionManager { this.currentActionFn = null; clearTimeout(TIMEOUT); - // get bot activity summary + // Capture raw output BEFORE truncation for reliable regex matching + const rawOutput = this.agent.bot.output || ''; + + // get bot activity summary (may truncate) let output = this.getBotOutputSummary(); let interrupted = this.agent.bot.interrupt_code; let timedout = this.timedout; this.agent.clearBotLogs(); + // ── Cross-invocation zero-collect detection ────────────────────── + // Use rawOutput for regex matching to avoid truncation issues + if (actionLabel.includes('collectBlocks') && rawOutput) { + const zeroMatch = rawOutput.match(/Collected 0 (\w+)/); + const successMatch = rawOutput.match(/Collected (\d+) (\w+)/); + if (zeroMatch) { + const blockType = zeroMatch[1]; + const tracker = this._collectFailTracker; + if (!tracker[blockType]) tracker[blockType] = { count: 0, lastSeen: 0 }; + tracker[blockType].count++; + tracker[blockType].lastSeen = Date.now(); + if (tracker[blockType].count >= this._COLLECT_FAIL_THRESHOLD) { + const failCount = tracker[blockType].count; + tracker[blockType] = { count: 0, lastSeen: 0 }; // reset + console.warn(`[ActionManager] Gather loop: collected 0 ${blockType} ${failCount} times — forcing explore`); + this.cancelResume(); + // Set flag so agent.js auto-executes !explore(200) bypassing the LLM + this.agent._forceExplore = { distance: 200, blockType }; + output += `\n\nArea depleted: collected 0 ${blockType} ${failCount} times. Auto-exploring 200 blocks to find fresh resources.`; + } + } else if (successMatch && parseInt(successMatch[1]) > 0) { + // Success — reset tracker for this block type + const blockType = successMatch[2]; + if (this._collectFailTracker[blockType]) { + this._collectFailTracker[blockType] = { count: 0, lastSeen: 0 }; + } + } + } + // ──────────────────────────────────────────────────────────────── + // if not interrupted and not generating, emit idle event if (!interrupted) { this.agent.bot.emit('idle'); @@ -131,14 +236,14 @@ export class ActionManager { this.cancelResume(); console.error("Code execution triggered catch:", err); // Log the full stack trace - console.error(err.stack); + const stackTrace = err.stack || ''; + console.error(stackTrace); await this.stop(); - err = err.toString(); let message = this.getBotOutputSummary() + '!!Code threw exception!!\n' + - 'Error: ' + err + '\n' + - 'Stack trace:\n' + err.stack+'\n'; + 'Error: ' + err.toString() + '\n' + + 'Stack trace:\n' + stackTrace + '\n'; let interrupted = this.agent.bot.interrupt_code; this.agent.clearBotLogs(); diff --git a/src/agent/agent.js b/src/agent/agent.js index f5a8e3d52..1b4af7eb7 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -4,7 +4,7 @@ import { VisionInterpreter } from './vision/vision_interpreter.js'; import { Prompter } from '../models/prompter.js'; import { initModes } from './modes.js'; import { initBot } from '../utils/mcdata.js'; -import { containsCommand, commandExists, executeCommand, truncCommandMessage, isAction, blacklistCommands } from './commands/index.js'; +import { containsCommand, commandExists, executeCommand, truncCommandMessage, isAction, blacklistCommands, isCommandBlocked } from './commands/index.js'; import { ActionManager } from './action_manager.js'; import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; @@ -17,6 +17,16 @@ import settings from './settings.js'; import { Task } from './tasks/tasks.js'; import { speak } from './speak.js'; import { log, validateNameFormat, handleDisconnection } from './connection_handler.js'; +import { Learnings } from './learnings.js'; +import { validateMinecraftMessage, validateUsername } from '../utils/message_validator.js'; + +// ── In-game aliases (shorthand → canonical agent name) ────── +const INGAME_ALIASES = { + 'gemini': 'Gemini_1', + 'gi': 'Gemini_1', + 'grok': 'Grok_En', + 'gk': 'Grok_En', +}; export class Agent { async start(load_mem=false, init_message=null, count_id=0) { @@ -44,6 +54,8 @@ export class Agent { this.npc = new NPCContoller(this); this.memory_bank = new MemoryBank(); this.self_prompter = new SelfPrompter(this); + this.learnings = new Learnings(this.name); + this.learnings.load(); convoManager.initAgent(this); await this.prompter.initExamples(); @@ -59,7 +71,7 @@ export class Agent { taskStart = Date.now(); } this.task = new Task(this, settings.task, taskStart); - this.blocked_actions = settings.blocked_actions.concat(this.task.blocked_actions || []); + this.blocked_actions = settings.blocked_actions.concat(this.task.blocked_actions || []).concat(this.prompter.profile.blocked_actions || []); blacklistCommands(this.blocked_actions); console.log(this.name, 'logging into minecraft...'); @@ -73,18 +85,27 @@ export class Agent { // Log and Analyze // handleDisconnection handles logging to console and server const { type } = handleDisconnection(this.name, reason); - - process.exit(1); + + // Clean disconnect so MC server releases the session immediately + try { this.bot.quit(); } catch {} + + // Name conflicts need extra delay — use exit code 88 + process.exit(type === 'name_conflict' ? 88 : 1); }; // Bind events this.bot.once('kicked', (reason) => onDisconnect('Kicked', reason)); this.bot.once('end', (reason) => onDisconnect('Disconnected', reason)); this.bot.on('error', (err) => { - if (String(err).includes('Duplicate') || String(err).includes('ECONNREFUSED')) { + const errStr = String(err); + if (errStr.includes('Duplicate') || errStr.includes('ECONNREFUSED')) { onDisconnect('Error', err); + } else if (errStr.includes('EPIPE') || errStr.includes('ECONNRESET')) { + // Connection broken — log it but let mineflayer's 'end' event + // handle the actual disconnect/restart to avoid false restarts + console.warn(`[${this.name}] Connection error: ${errStr}. Waiting for disconnect event...`); } else { - log(this.name, `[LoginGuard] Connection Error: ${String(err)}`); + log(this.name, `[LoginGuard] Connection Error: ${errStr}`); } }); @@ -139,7 +160,7 @@ export class Agent { } catch (error) { console.error('Error in spawn event:', error); - process.exit(0); + process.exit(1); } }); } @@ -157,34 +178,55 @@ export class Agent { const respondFunc = async (username, message) => { if (message === "") return; if (username === this.name) return; + + // Validate username and message + const userValidation = validateUsername(username); + if (!userValidation.valid) { + console.warn(`[MessageValidator] Rejected message from invalid username: "${username}" (${userValidation.error})`); + return; + } + + const msgValidation = validateMinecraftMessage(message); + if (!msgValidation.valid) { + console.warn(`[MessageValidator] Rejected message: ${msgValidation.error}`); + return; + } + const cleanMessage = msgValidation.sanitized; + if (settings.only_chat_with.length > 0 && !settings.only_chat_with.includes(username)) return; try { - if (ignore_messages.some((m) => message.startsWith(m))) return; + if (ignore_messages.some((m) => cleanMessage.startsWith(m))) return; + + // Ignore bot action status broadcasts from unrecognized bots + // (e.g. "*used goToCoordinates*", "*BotName stopped.*") + if (/^\*.*\*$/.test(cleanMessage.trim())) return; this.shut_up = false; - console.log(this.name, 'received message from', username, ':', message); + console.log(this.name, 'received message from', username, ':', cleanMessage); if (convoManager.isOtherAgent(username)) { - console.warn('received whisper from other bot??') + console.warn('received whisper from other bot??'); } else { - let translation = await handleEnglishTranslation(message); + let translation = await handleEnglishTranslation(cleanMessage); this.handleMessage(username, translation); } } catch (error) { console.error('Error handling message:', error); } - } + }; this.respondFunc = respondFunc; this.bot.on('whisper', respondFunc); - + this.bot.on('chat', (username, message) => { - if (serverProxy.getNumOtherAgents() > 0) return; - // only respond to open chat messages when there are no other agents - respondFunc(username, message); + // Parse prefix/alias to determine if this message targets a specific bot + const parsed = this.parseInGamePrefix(message); + if (parsed.targeted && !parsed.isForMe) return; // targeted at another bot, skip + const msgToProcess = parsed.targeted ? parsed.message : message; + respondFunc(username, msgToProcess); }); // Set up auto-eat @@ -194,11 +236,34 @@ export class Agent { bannedFood: ["rotten_flesh", "spider_eye", "poisonous_potato", "pufferfish", "chicken"] }; + // Log inventory on every load so the bot (and LLM) knows what it has + const inv = this.bot.inventory.items(); + if (inv.length > 0) { + const invStr = inv.map(i => `${i.name}: ${i.count}`).join(', '); + console.log(`[Startup] ${this.name} inventory: ${invStr}`); + this.history.add('system', `Inventory on load: ${invStr}`); + } else { + console.log(`[Startup] ${this.name} inventory is empty.`); + this.history.add('system', 'Inventory on load: empty.'); + } + if (save_data?.self_prompt) { if (init_message) { this.history.add('system', init_message); } await this.self_prompter.handleLoad(save_data.self_prompt, save_data.self_prompting_state); + } else if (this.prompter.profile.self_prompt) { + // Fresh spawn with no saved state — auto-start from profile default goal + if (init_message) { + this.history.add('system', init_message); + } + const defaultGoal = this.prompter.profile.self_prompt; + setTimeout(() => { + if (this.self_prompter.isStopped()) { + console.log(`[AutoGoal] Starting default self-prompt for ${this.name}: "${defaultGoal}"`); + this.self_prompter.start(defaultGoal); + } + }, 3000); } if (save_data?.last_sender) { this.last_sender = save_data.last_sender; @@ -210,7 +275,7 @@ export class Agent { convoManager.receiveFromBot(this.last_sender, msg_package); } } - else if (init_message) { + else if (init_message && !this.self_prompter.isActive()) { await this.handleMessage('system', init_message, 2); } else { @@ -218,6 +283,35 @@ export class Agent { } } + parseInGamePrefix(message) { + const colonIdx = message.indexOf(':'); + if (colonIdx <= 0 || colonIdx >= 30) return { targeted: false, isForMe: true, message }; + + const prefix = message.substring(0, colonIdx).trim().toLowerCase(); + const body = message.substring(colonIdx + 1).trim(); + + // Check if prefix matches this agent's name + if (prefix === this.name.toLowerCase()) { + return { targeted: true, isForMe: true, targetName: this.name, message: body }; + } + + // Check aliases + const aliasTarget = INGAME_ALIASES[prefix]; + if (aliasTarget) { + const isForMe = aliasTarget === this.name; + return { targeted: true, isForMe, targetName: aliasTarget, message: body }; + } + + // Check if prefix matches any other known agent name (case-insensitive) + if (convoManager.isOtherAgent(prefix) || + convoManager.isOtherAgent(prefix.charAt(0).toUpperCase() + prefix.slice(1))) { + return { targeted: true, isForMe: false, targetName: prefix, message: body }; + } + + // Not a recognized prefix — treat as normal message + return { targeted: false, isForMe: true, message }; + } + checkAllPlayersPresent() { if (!this.task || !this.task.agent_names) { return; @@ -234,7 +328,7 @@ export class Agent { this.bot.interrupt_code = true; this.bot.stopDigging(); this.bot.collectBlock.cancelTask(); - this.bot.pathfinder.stop(); + this.bot.ashfinder.stop(); // RC25: baritone replaces pathfinder this.bot.pvp.stop(); } @@ -243,12 +337,12 @@ export class Agent { this.bot.interrupt_code = false; } - shutUp() { + async shutUp() { this.shut_up = true; if (this.self_prompter.isActive()) { this.self_prompter.stop(false); } - convoManager.endAllConversations(); + await convoManager.endAllConversations(); // RC30: properly await async } async handleMessage(source, message, max_responses=null) { @@ -269,6 +363,25 @@ export class Agent { const self_prompt = source === 'system' || source === this.name; const from_other_bot = convoManager.isOtherAgent(source); + // ── Hardcoded stop/freeze: bypasses LLM, always works ── + if (!self_prompt) { + const lower = message.toLowerCase().trim(); + if (lower === 'stop' || lower === 'freeze' || lower === 'stop!' || lower === 'freeze!') { + console.log(`[STOP] ${source} triggered "${lower}" on ${this.name}`); + await this.actions.stop(); + this.actions.cancelResume(); // prevent idle event from restarting previous action + if (this.self_prompter.isActive()) this.self_prompter.stop(false); + this.routeResponse(source, `*${this.name} stopped.*`); // send confirmation before shut_up + this.shut_up = true; + return true; + } + } + + // Human player messages take absolute priority — interrupt any ongoing action immediately + if (!self_prompt && !from_other_bot && !this.isIdle()) { + this.requestInterrupt(); + } + if (!self_prompt && !from_other_bot) { // from user, check for forced commands const user_command_name = containsCommand(message); if (user_command_name) { @@ -322,7 +435,7 @@ export class Agent { console.log(`${this.name} full response to ${source}: ""${res}""`); if (res.trim().length === 0) { - console.warn('no response') + console.warn('no response'); break; // empty response ends loop } @@ -333,9 +446,27 @@ export class Agent { this.history.add(this.name, res); if (!commandExists(command_name)) { - this.history.add('system', `Command ${command_name} does not exist.`); - console.warn('Agent hallucinated command:', command_name) - continue; + // RC27: Distinguish blocked commands from truly unknown ones + if (isCommandBlocked(command_name)) { + // RC29→RC31: Auto-redirect blocked commands to !dragonProgression + // This chains all 6 chunks (diamond pickaxe → nether portal → blaze rods → etc.) + const craftRedirectCmds = ['!craftRecipe', '!collectBlocks', '!searchForBlock', '!getCraftingPlan', '!newAction']; + if (craftRedirectCmds.includes(command_name) && commandExists('!dragonProgression')) { + console.log(`[RC31] Redirecting blocked ${command_name} → !dragonProgression`); + this.history.add('system', `${command_name} is blocked. Running !dragonProgression instead (it handles ALL progression automatically).`); + res = '!dragonProgression'; + command_name = '!dragonProgression'; + // Fall through to normal command execution below + } else { + this.history.add('system', `Command ${command_name} is disabled in your profile's blocked_actions.`); + console.log(`[RC27] Agent used blocked command: ${command_name}`); + continue; + } + } else { + this.history.add('system', `Command ${command_name} does not exist.`); + console.warn('Agent hallucinated command:', command_name); + continue; + } } if (checkInterrupt()) break; @@ -364,10 +495,35 @@ export class Agent { console.log('Agent executed:', command_name, 'and got:', execute_res); used_command = true; + if (this.learnings && command_name) { + const outcome = (execute_res && !execute_res.includes('Error') && !execute_res.includes('failed')) + ? 'success' : 'fail'; + this.learnings.record(command_name, res.substring(0, 100), outcome); + } + if (execute_res) this.history.add('system', execute_res); else break; + + // Auto-explore: if action_manager detected repeated collect failures, + // bypass the LLM and directly execute !explore(200) to relocate + if (this._forceExplore) { + const { distance, blockType } = this._forceExplore; + this._forceExplore = null; + console.log(`[AutoExplore] Forcing explore(${distance}) after repeated ${blockType} collect failures`); + const exploreRes = await executeCommand(this, `!explore(${distance})`); + if (exploreRes) { + this.history.add('system', exploreRes); + } + // Inject a hard directive so small models don't loop back to !collectBlocks + this.history.add('system', + `[ANTI-LOOP] You have failed to collect ${blockType} repeatedly. ` + + `Do NOT call !collectBlocks, !searchForBlock, or any gather command for ${blockType} again right now. ` + + `If you need tools, call !getDiamondPickaxe — it handles all wood/stone/iron internally with automatic relocation. ` + + `If you are mid-speedrun, call !beatMinecraft to resume the full chain.` + ); + } } else { // conversation response this.history.add(this.name, res); @@ -452,9 +608,7 @@ export class Agent { prev_health = this.bot.health; }); // Logging callbacks - this.bot.on('error' , (err) => { - console.error('Error event!', err); - }); + // Note: 'error' is already handled by initBot() login guard — no duplicate needed // Use connection handler for runtime disconnects this.bot.on('end', (reason) => { if (!this._disconnectHandled) { @@ -465,6 +619,7 @@ export class Agent { this.bot.on('death', () => { this.actions.cancelResume(); this.actions.stop(); + this.bot.respawnTime = Date.now(); }); this.bot.on('kicked', (reason) => { if (!this._disconnectHandled) { @@ -479,7 +634,7 @@ export class Agent { this.memory_bank.rememberPlace('last_death_position', death_pos.x, death_pos.y, death_pos.z); let death_pos_text = null; if (death_pos) { - death_pos_text = `x: ${death_pos.x.toFixed(2)}, y: ${death_pos.y.toFixed(2)}, z: ${death_pos.x.toFixed(2)}`; + death_pos_text = `x: ${death_pos.x.toFixed(2)}, y: ${death_pos.y.toFixed(2)}, z: ${death_pos.z.toFixed(2)}`; } let dimention = this.bot.game.dimension; this.handleMessage('system', `You died at position ${death_pos_text || "unknown"} in the ${dimention} dimension with the final message: '${message}'. Your place of death is saved as 'last_death_position' if you want to return. Previous actions were stopped and you have respawned.`); @@ -487,7 +642,7 @@ export class Agent { }); this.bot.on('idle', () => { this.bot.clearControlStates(); - this.bot.pathfinder.stop(); // clear any lingering pathfinder + this.bot.ashfinder.stop(); // RC25: clear any lingering baritone navigation this.bot.modes.unPauseAll(); setTimeout(() => { if (this.isIdle()) { @@ -530,8 +685,16 @@ export class Agent { cleanKill(msg='Killing agent process...', code=1) { this.history.add('system', msg); - this.bot.chat(code > 1 ? 'Restarting.': 'Exiting.'); + try { this.bot.chat(code > 1 ? 'Restarting.': 'Exiting.'); } catch {} this.history.save(); + if (this.learnings) { + this.learnings.save(); + } + if (this.prompter?.usageTracker) { + this.prompter.usageTracker.saveSync(); + this.prompter.usageTracker.destroy(); + } + try { this.bot.quit(); } catch {} process.exit(code); } async checkTaskDone() { @@ -550,4 +713,4 @@ export class Agent { killAll() { serverProxy.shutdown(); } -} \ No newline at end of file +} diff --git a/src/agent/coder.js b/src/agent/coder.js index 18a5f2618..fb072a53e 100644 --- a/src/agent/coder.js +++ b/src/agent/coder.js @@ -1,26 +1,19 @@ -import { writeFile, readFile, mkdirSync } from 'fs'; +import { writeFile, readFileSync, mkdirSync } from 'fs'; import { makeCompartment, lockdown } from './library/lockdown.js'; import * as skills from './library/skills.js'; import * as world from './library/world.js'; import { Vec3 } from 'vec3'; import {ESLint} from "eslint"; +import settings from './settings.js'; export class Coder { constructor(agent) { this.agent = agent; this.file_counter = 0; this.fp = '/bots/'+agent.name+'/action-code/'; - this.code_template = ''; - this.code_lint_template = ''; - readFile('./bots/execTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_template = data; - }); - readFile('./bots/lintTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_lint_template = data; - }); + this.code_template = readFileSync('./bots/execTemplate.js', 'utf8'); + this.code_lint_template = readFileSync('./bots/lintTemplate.js', 'utf8'); mkdirSync('.' + this.fp, { recursive: true }); } @@ -82,7 +75,18 @@ export class Coder { try { console.log('Executing code...'); - await executionModule.main(this.agent.bot); + const timeout_ms = settings.code_timeout_mins > 0 ? settings.code_timeout_mins * 60 * 1000 : null; + if (timeout_ms) { + await Promise.race([ + executionModule.main(this.agent.bot), + new Promise((_, reject) => setTimeout( + () => reject(new Error(`Code execution timed out after ${settings.code_timeout_mins} minutes.`)), + timeout_ms + )) + ]); + } else { + await executionModule.main(this.agent.bot); + } const code_output = this.agent.actions.getBotOutputSummary(); const summary = "Agent wrote this code: \n```" + this._sanitizeCode(code) + "```\nCode Output:\n" + code_output; @@ -120,12 +124,12 @@ export class Coder { } const allDocs = await this.agent.prompter.skill_libary.getAllSkillDocs(); // check function exists - const missingSkills = skills.filter(skill => !!allDocs[skill]); + const missingSkills = skills.filter(skill => !allDocs[skill]); if (missingSkills.length > 0) { result += 'These functions do not exist.\n'; result += '### FUNCTIONS NOT FOUND ###\n'; result += missingSkills.join('\n'); - console.log(result) + console.log(result); return result; } @@ -183,16 +187,20 @@ export class Coder { // This is where we determine the environment the agent's code should be exposed to. // It will only have access to these things, (in addition to basic javascript objects like Array, Object, etc.) // Note that the code may be able to modify the exposed objects. + // Freeze exposed module objects so Compartment code cannot replace + // methods on them (e.g. skills.collectBlocks = maliciousFn). + // Spread into plain objects first before freezing so ESLint's + // no-import-assign rule doesn't flag the namespace import references. const compartment = makeCompartment({ - skills, + skills: Object.freeze({ ...skills }), log: skills.log, - world, + world: Object.freeze({ ...world }), Vec3, }); const mainFn = compartment.evaluate(src); if (write_result) { - console.error('Error writing code execution file: ' + result); + console.error('Error writing code execution file: ' + write_result); return null; } return { func:{main: mainFn}, src_lint_copy: src_lint_copy }; @@ -200,7 +208,7 @@ export class Coder { _sanitizeCode(code) { code = code.trim(); - const remove_strs = ['Javascript', 'javascript', 'js'] + const remove_strs = ['Javascript', 'javascript', 'js']; for (let r of remove_strs) { if (code.startsWith(r)) { code = code.slice(r.length); diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index f348487ed..6157dfe7a 100644 --- a/src/agent/commands/actions.js +++ b/src/agent/commands/actions.js @@ -1,4 +1,5 @@ import * as skills from '../library/skills.js'; +import * as dragonRunner from '../library/dragon_runner.js'; import settings from '../settings.js'; import convoManager from '../conversation.js'; @@ -20,7 +21,7 @@ function runAsAction (actionFn, resume = false, timeout = -1) { if (code_return.interrupted && !code_return.timedout) return; return code_return.message; - } + }; return wrappedAction; } @@ -32,7 +33,7 @@ export const actionsList = [ params: { 'prompt': { type: 'string', description: 'A natural language prompt to guide code generation. Make a detailed step-by-step plan.' } }, - perform: async function(agent, prompt) { + perform: async function(agent, _prompt) { // just ignore prompt - it is now in context in chat history if (!settings.allow_insecure_coding) { agent.openChat('newAction is disabled. Enable with allow_insecure_coding=true in settings.js'); @@ -69,7 +70,7 @@ export const actionsList = [ description: 'Stop all chatting and self prompting, but continue current action.', perform: async function (agent) { agent.openChat('Shutting up.'); - agent.shutUp(); + await agent.shutUp(); return; } }, @@ -132,7 +133,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, block_type, range) => { if (range < 32) { - log(agent.bot, `Minimum search range is 32.`); + skills.log(agent.bot, `Minimum search range is 32.`); range = 32; } await skills.goToNearestBlock(agent.bot, block_type, 4, range); @@ -157,6 +158,14 @@ export const actionsList = [ await skills.moveAway(agent.bot, distance); }) }, + { + name: '!explore', + description: 'Move to a random location in a new direction to find fresh resources. Use when current area is depleted or you keep collecting 0.', + params: {'distance': { type: 'float', description: 'The distance to explore. Default 40. Use 60-100 if nearby areas are empty.', domain: [10, 200, '[]'] }}, + perform: runAsAction(async (agent, distance) => { + await skills.explore(agent.bot, distance); + }) + }, { name: '!rememberHere', description: 'Save the current location with a given name.', @@ -247,9 +256,9 @@ export const actionsList = [ }, perform: runAsAction(async (agent, item_name, num) => { const start_loc = agent.bot.entity.position; - await skills.moveAway(agent.bot, 5); + try { await skills.moveAway(agent.bot, 5); } catch (_) { /* navigation may fail, that's ok */ } await skills.discard(agent.bot, item_name, num); - await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0); + try { await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0); } catch (_) { /* ok */ } }) }, { @@ -260,7 +269,14 @@ export const actionsList = [ 'num': { type: 'int', description: 'The number of blocks to collect.', domain: [1, Number.MAX_SAFE_INTEGER] } }, perform: runAsAction(async (agent, type, num) => { - await skills.collectBlock(agent.bot, type, num); + try { + const result = await skills.collectBlock(agent.bot, type, num); + console.log(`[DEBUG] collectBlock returned: ${result}`); + return result; + } catch (err) { + console.error(`[DEBUG] collectBlock error: ${err.message}`); + throw err; + } }, false, 10) // 10 minute timeout }, { @@ -320,6 +336,10 @@ export const actionsList = [ description: 'Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.', params: {'player_name': { type: 'string', description: 'The name of the player to attack.'}}, perform: runAsAction(async (agent, player_name) => { + if (convoManager.isOtherAgent(player_name)) { + skills.log(agent.bot, `Cannot attack teammate bot ${player_name}! Team-killing is blocked.`); + return false; + } let player = agent.bot.players[player_name]?.entity; if (!player) { skills.log(agent.bot, `Could not find player ${player_name}.`); @@ -477,7 +497,7 @@ export const actionsList = [ description: 'Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.', params: {'distance': { type: 'int', description: 'Distance to dig down', domain: [1, Number.MAX_SAFE_INTEGER] }}, perform: runAsAction(async (agent, distance) => { - await skills.digDown(agent.bot, distance) + await skills.digDown(agent.bot, distance); }) }, { @@ -499,4 +519,127 @@ export const actionsList = [ await skills.useToolOn(agent.bot, tool_name, target); }) }, + + // ═══════════════════════════════════════════════════════════════════════ + // GENERAL IMPROVEMENT COMMANDS + // ═══════════════════════════════════════════════════════════════════════ + { + name: '!safeMoveTo', + description: 'Navigate to coordinates safely, avoiding lava and placing torches underground. Includes fall protection.', + params: { + 'x': { type: 'float', description: 'The x coordinate.', domain: [-Infinity, Infinity] }, + 'y': { type: 'float', description: 'The y coordinate.', domain: [-64, 320] }, + 'z': { type: 'float', description: 'The z coordinate.', domain: [-Infinity, Infinity] } + }, + perform: runAsAction(async (agent, x, y, z) => { + await skills.safeMoveTo(agent.bot, x, y, z, { avoidLava: true, lightPath: true }); + }) + }, + { + name: '!rangedAttack', + description: 'Attack the nearest entity of a type with bow (if available), falling back to melee.', + params: { + 'type': { type: 'string', description: 'The entity type to attack (e.g. blaze, skeleton).' } + }, + perform: runAsAction(async (agent, type) => { + await skills.rangedAttack(agent.bot, type); + }) + }, + { + name: '!buildPanicRoom', + description: 'Build an emergency 3x3x3 cobblestone shelter, eat food, and wait to heal.', + perform: runAsAction(async (agent) => { + await skills.buildPanicRoom(agent.bot); + }) + }, + { + name: '!autoManageInventory', + description: 'Clean up inventory: drop junk, store excess in chests, keep 8 empty slots.', + perform: runAsAction(async (agent) => { + await skills.autoManageInventory(agent.bot); + }) + }, + { + name: '!stockpileFood', + description: 'Hunt animals and cook meat to build a food supply.', + params: { + 'quantity': { type: 'int', description: 'Target number of food items.', domain: [1, 128] } + }, + perform: runAsAction(async (agent, quantity) => { + await skills.stockpileFood(agent.bot, quantity); + }, false, 10) + }, + { + name: '!ensureFed', + description: 'Eat the best available food if hungry.', + perform: runAsAction(async (agent) => { + await skills.ensureFed(agent.bot); + }) + }, + + // ═══════════════════════════════════════════════════════════════════════ + // DRAGON PROGRESSION COMMANDS (Gameplay Chunks) + // ═══════════════════════════════════════════════════════════════════════ + { + name: '!getDiamondPickaxe', + description: 'Automatically progress through tool tiers: wooden → stone → iron → diamond pickaxe.', + perform: runAsAction(async (agent) => { + await skills.getDiamondPickaxe(agent.bot); + }, false, 30) // 30 minute timeout + }, + { + name: '!buildNetherPortal', + description: 'Build and light a nether portal. Casts obsidian from water+lava or mines it directly.', + perform: runAsAction(async (agent) => { + await dragonRunner.buildNetherPortal(agent.bot); + }, false, 30) + }, + { + name: '!collectBlazeRods', + description: 'Enter the Nether, find a fortress, and collect blaze rods from blazes.', + params: { + 'count': { type: 'int', description: 'Number of blaze rods to collect.', domain: [1, 64] } + }, + perform: runAsAction(async (agent, count) => { + await dragonRunner.collectBlazeRods(agent.bot, count); + }, false, 30) + }, + { + name: '!collectEnderPearls', + description: 'Hunt Endermen for ender pearls in the overworld or nether.', + params: { + 'count': { type: 'int', description: 'Number of ender pearls to collect.', domain: [1, 64] } + }, + perform: runAsAction(async (agent, count) => { + await dragonRunner.collectEnderPearls(agent.bot, count); + }, false, 30) + }, + { + name: '!locateStronghold', + description: 'Use eyes of ender to find the stronghold, dig down, and activate the end portal.', + perform: runAsAction(async (agent) => { + await dragonRunner.locateStronghold(agent.bot); + }, false, 30) + }, + { + name: '!defeatEnderDragon', + description: 'Enter The End and defeat the Ender Dragon. Destroys crystals then attacks dragon.', + perform: runAsAction(async (agent) => { + await dragonRunner.defeatEnderDragon(agent.bot); + }, false, 30) + }, + { + name: '!dragonProgression', + description: 'Full autonomous run: fresh world → diamond pickaxe → nether → blaze rods → ender pearls → stronghold → defeat Ender Dragon. Skips completed steps. Persistent — survives restarts.', + perform: runAsAction(async (agent) => { + await dragonRunner.runDragonProgression(agent.bot); + }, false, 180) // 3 hour timeout + }, + { + name: '!beatMinecraft', + description: 'Alias for !dragonProgression. Full autonomous Ender Dragon run with persistent progress. Survives deaths and restarts — picks up where it left off.', + perform: runAsAction(async (agent) => { + await dragonRunner.runDragonProgression(agent.bot); + }, false, 180) // 3 hour timeout + }, ]; diff --git a/src/agent/commands/index.js b/src/agent/commands/index.js index 7ada04088..97d6c8e77 100644 --- a/src/agent/commands/index.js +++ b/src/agent/commands/index.js @@ -10,10 +10,19 @@ for (let command of commandList) { commandMap[command.name] = command; } +// RC27: Track blocked commands separately so we can distinguish +// "blocked by profile" from "truly hallucinated/unknown" +const blockedCommands = new Set(); + export function getCommand(name) { return commandMap[name]; } +export function isCommandBlocked(name) { + if (!name.startsWith('!')) name = '!' + name; + return blockedCommands.has(name); +} + export function blacklistCommands(commands) { const unblockable = ['!stop', '!stats', '!inventory', '!goal']; for (let command_name of commands) { @@ -21,12 +30,17 @@ export function blacklistCommands(commands) { console.warn(`Command ${command_name} is unblockable`); continue; } + blockedCommands.add(command_name); delete commandMap[command_name]; - delete commandList.find(command => command.name === command_name); + // Fix: findIndex + splice to actually remove from the array. + // The previous `delete commandList.find(...)` called delete on the + // returned object reference, which does nothing to the array. + const idx = commandList.findIndex(cmd => cmd.name === command_name); + if (idx !== -1) commandList.splice(idx, 1); } } -const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/ +const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/; const argRegex = /-?\d+(?:\.\d+)?|true|false|"[^"]*"/g; export function containsCommand(message) { @@ -81,7 +95,7 @@ function checkInInterval(number, lowerBound, upperBound, endpointType) { case '[]': return lowerBound <= number && number <= upperBound; default: - throw new Error('Unknown endpoint type:', endpointType) + throw new Error('Unknown endpoint type:', endpointType); } } @@ -105,7 +119,7 @@ export function parseCommandMessage(message) { else args = []; const command = getCommand(commandName); - if(!command) return `${commandName} is not a command.` + if(!command) return `${commandName} is not a command.`; const params = commandParams(command); const paramNames = commandParamNames(command); @@ -135,13 +149,14 @@ export function parseCommandMessage(message) { case 'ItemName': if (arg.endsWith('plank') || arg.endsWith('seed')) arg += 's'; // add 's' to for common mistakes like "oak_plank" or "wheat_seed" + // falls through case 'string': break; default: throw new Error(`Command '${commandName}' parameter '${paramNames[i]}' has an unknown type: ${param.type}`); } if(arg === null || Number.isNaN(arg)) - return `Error: Param '${paramNames[i]}' must be of type ${param.type}.` + return `Error: Param '${paramNames[i]}' must be of type ${param.type}.`; if(typeof arg === 'number') { //Check the domain of numbers const domain = param.domain; @@ -157,15 +172,15 @@ export function parseCommandMessage(message) { //Alternatively arg could be set to the nearest value in the domain. } } else if (!suppressNoDomainWarning) { - console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`) + console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`); suppressNoDomainWarning = true; //Don't spam console. Only give the warning once. } } else if(param.type === 'BlockName') { //Check that there is a block with this name - if(getBlockId(arg) == null) return `Invalid block type: ${arg}.` + if(getBlockId(arg) == null) return `Invalid block type: ${arg}.`; } else if(param.type === 'ItemName') { //Check that there is an item with this name - if(getItemId(arg) == null) return `Invalid item type: ${arg}.` + if(getItemId(arg) == null) return `Invalid item type: ${arg}.`; } else if(param.type === 'BlockOrItemName') { - if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.` + if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.`; } args[i] = arg; } @@ -239,7 +254,7 @@ export function getCommandDocs(agent) { 'ItemName': 'string', 'BlockOrItemName': 'string', 'boolean': 'bool' - } + }; let docs = `\n*COMMAND DOCS\n You can use the following commands to perform actions and get information about the world. Use the commands with the syntax: !commandName or !commandName("arg1", 1.2, ...) if the command takes arguments.\n Do not use codeblocks. Use double quotes for strings. Only use one command in each response, trailing commands and comments will be ignored.\n`; diff --git a/src/agent/commands/queries.js b/src/agent/commands/queries.js index ad5b701ee..d9d0c3178 100644 --- a/src/agent/commands/queries.js +++ b/src/agent/commands/queries.js @@ -7,7 +7,7 @@ import { load } from 'cheerio'; const pad = (str) => { return '\n' + str + '\n'; -} +}; // queries are commands that just return strings and don't affect anything in the world export const queryList = [ @@ -49,7 +49,7 @@ export const queryList = [ let action = agent.actions.currentActionLabel; if (agent.isIdle()) action = 'Idle'; - res += `\- Current Action: ${action}`; + res += `- Current Action: ${action}`; let players = world.getNearbyPlayerNames(bot); @@ -315,7 +315,7 @@ export const queryList = [ 'query': { type: 'string', description: 'The query to search for.' } }, perform: async function (agent, query) { - const url = `https://minecraft.wiki/w/${query}` + const url = `https://minecraft.wiki/w/${query}`; try { const response = await fetch(url); if (response.status === 404) { @@ -333,7 +333,7 @@ export const queryList = [ return divContent.trim(); } catch (error) { console.error("Error fetching or parsing HTML:", error); - return `The following error occurred: ${error}` + return `The following error occurred: ${error}`; } } }, diff --git a/src/agent/connection_handler.js b/src/agent/connection_handler.js index 31a14e1ec..f68fb1343 100644 --- a/src/agent/connection_handler.js +++ b/src/agent/connection_handler.js @@ -43,7 +43,7 @@ const ERROR_DEFINITIONS = { export const log = (agentName, msg) => { // Use console.error for visibility in terminal console.error(msg); - try { sendOutputToServer(agentName || 'system', msg); } catch (_) {} + try { sendOutputToServer(agentName || 'system', msg); } catch {} }; // Analyzes the kick reason and returns a full, human-readable sentence. @@ -64,7 +64,7 @@ export function parseKickReason(reason) { try { const obj = typeof reason === 'string' ? JSON.parse(reason) : reason; fallback = obj.translate || obj.text || (obj.value?.translate) || raw; - } catch (_) {} + } catch {} return { type: 'other', msg: `Disconnected: ${fallback}`, isFatal: true }; } diff --git a/src/agent/conversation.js b/src/agent/conversation.js index 1cd781e87..2ec1d8651 100644 --- a/src/agent/conversation.js +++ b/src/agent/conversation.js @@ -191,7 +191,7 @@ class ConversationManager { await agent.self_prompter.pause(); } - _scheduleProcessInMessage(sender, received, convo); + await _scheduleProcessInMessage(sender, received, convo); } responseScheduledFor(sender) { @@ -224,25 +224,25 @@ class ConversationManager { return Object.values(this.convos).some(c => c.active); } - endConversation(sender) { + async endConversation(sender) { if (this.convos[sender]) { this.convos[sender].end(); if (this.activeConversation.name === sender) { this._stopMonitor(); this.activeConversation = null; if (agent.self_prompter.isPaused() && !this.inConversation()) { - _resumeSelfPrompter(); + await _resumeSelfPrompter(); } } } } - endAllConversations() { + async endAllConversations() { for (const sender in this.convos) { - this.endConversation(sender); + await this.endConversation(sender); // RC30: await async endConversation } if (agent.self_prompter.isPaused()) { - _resumeSelfPrompter(); + await _resumeSelfPrompter(); } } @@ -281,7 +281,7 @@ async function _scheduleProcessInMessage(sender, received, convo) { // both are busy let canTalkOver = talkOverActions.some(a => agent.actions.currentActionLabel.includes(a)); if (canTalkOver) - scheduleResponse(fastDelay) + scheduleResponse(fastDelay); // otherwise don't respond } else if (otherAgentBusy) diff --git a/src/agent/history.js b/src/agent/history.js index 04a72f76d..56c713612 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,7 +1,34 @@ -import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; -import { NPCData } from './npc/data.js'; +import { readFileSync, mkdirSync, existsSync, writeFile, renameSync, unlinkSync } from 'fs'; +import { promisify } from 'util'; + import settings from './settings.js'; +const writeFileAsync = promisify(writeFile); + +// RC27: Atomic write — write to .tmp file then rename, preventing corruption on crash +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await writeFileAsync(tmpPath, content, 'utf8'); + // Atomic rename (overwrites destination on most OSes) + try { renameSync(tmpPath, filepath); } catch (renameErr) { + // Windows may fail rename if destination is locked; fall back to direct write + console.warn(`[RC27] Atomic rename failed for ${filepath}, falling back to direct write:`, renameErr.message); + await writeFileAsync(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch(_e) {} + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + export class History { constructor(agent) { @@ -34,9 +61,28 @@ export class History { console.log("Storing memories..."); this.memory = await this.agent.prompter.promptMemSaving(turns); - if (this.memory.length > 500) { - this.memory = this.memory.slice(0, 500); - this.memory += '...(Memory truncated to 500 chars. Compress it more next time)'; + // ── Memory sanitization: strip false "broken" beliefs ────────── + // These are residual beliefs from past bugs that are no longer true. + // The gathering system works correctly — the bots just need to move. + const toxicPatterns = [ + /\b(?:block |my )?gathering(?:\/\w+)? (?:is |remains? |still )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b[^.;]*/gi, + /\bcollect(?:Blocks?|ion)? (?:is |command )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b/gi, + /\b(?:waiting|await(?:ing)?) (?:for )?(?:the |an )?(?:update|fix|patch)\b[^.;]*/gi, + /\bneed(?:s)? (?:an? )?(?:fix|update|patch) (?:for|to (?:fix|repair)) (?:\w+ )*(?:gathering|collect(?:ion|Blocks?)|core mechanics)\b/gi, + /\bcannot gather (?:any )?resources\b/gi, + /\bgathering(?:\/\w+)? (?:commands? )?(?:are |is )?(?:still )?(?:broken|non-?functional) for both\b[^.;]*/gi, + /\bcore (?:mechanics|systems?) (?:are |is )?(?:broken|non-?functional|bugged)\b[^.;]*/gi, + /\bcrafted items don'?t persist\b/gi, + /CRITICAL:[^.;]*(?:non-?functional|broken|bugged)[^.;]*/gi, + ]; + for (const pattern of toxicPatterns) { + this.memory = this.memory.replace(pattern, 'gathering works — relocate to find blocks'); + } + // ────────────────────────────────────────────────────────────────── + + if (this.memory.length > 800) { + this.memory = this.memory.slice(0, 800); + this.memory += '...(Memory truncated to 800 chars. Compress it more next time)'; } console.log("Memory updated to: ", this.memory); @@ -46,13 +92,13 @@ export class History { if (this.full_history_fp === undefined) { const string_timestamp = new Date().toLocaleString().replace(/[/:]/g, '-').replace(/ /g, '').replace(/,/g, '_'); this.full_history_fp = `./bots/${this.name}/histories/${string_timestamp}.json`; - writeFileSync(this.full_history_fp, '[]', 'utf8'); + await safeWriteFile(this.full_history_fp, '[]'); } try { const data = readFileSync(this.full_history_fp, 'utf8'); let full_history = JSON.parse(data); full_history.push(...to_store); - writeFileSync(this.full_history_fp, JSON.stringify(full_history, null, 4), 'utf8'); + await safeWriteFile(this.full_history_fp, JSON.stringify(full_history, null, 4)); } catch (err) { console.error(`Error reading ${this.name}'s full history file: ${err.message}`); } @@ -76,6 +122,14 @@ export class History { await this.summarizeMemories(chunk); await this.appendFullHistory(chunk); + + // Prevent context-collapse on small/local models: after summarising, + // keep at most 15 recent turns so the rolling window stays tight. + const MAX_TURNS_POST_SUMMARY = 15; + if (this.turns.length > MAX_TURNS_POST_SUMMARY) { + const overflow = this.turns.splice(0, this.turns.length - MAX_TURNS_POST_SUMMARY); + await this.appendFullHistory(overflow); + } } } @@ -89,11 +143,11 @@ export class History { taskStart: this.agent.task.taskStartTime, last_sender: this.agent.last_sender }; - writeFileSync(this.memory_fp, JSON.stringify(data, null, 2)); + await safeWriteFile(this.memory_fp, JSON.stringify(data, null, 2)); console.log('Saved memory to:', this.memory_fp); + if (this.agent.learnings?._dirty) await this.agent.learnings.save(); } catch (error) { console.error('Failed to save history:', error); - throw error; } } @@ -103,14 +157,52 @@ export class History { console.log('No memory file found.'); return null; } - const data = JSON.parse(readFileSync(this.memory_fp, 'utf8')); + const raw = readFileSync(this.memory_fp, 'utf8'); + // RC27: Guard against corrupted/empty memory files + if (!raw || !raw.trim()) { + console.warn(`[RC27] Memory file ${this.memory_fp} is empty, starting fresh.`); + return null; + } + let data; + try { + data = JSON.parse(raw); + } catch (parseErr) { + console.error(`[RC27] Corrupted memory file ${this.memory_fp}: ${parseErr.message}. Starting fresh.`); + // Rename corrupted file so it's not lost but won't block startup + const backupPath = this.memory_fp + '.corrupted.' + Date.now(); + try { renameSync(this.memory_fp, backupPath); } catch(_e) {} + return null; + } this.memory = data.memory || ''; + + // ── Sanitize stale false beliefs on load ────────────────────── + const toxicPatterns = [ + /\b(?:block |my )?gathering(?:\/\w+)? (?:is |remains? |still )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b[^.;]*/gi, + /\bcollect(?:Blocks?|ion)? (?:is |command )?(?:broken|non-?functional|not work(?:ing)?|fails?|bugged)\b/gi, + /\b(?:waiting|await(?:ing)?) (?:for )?(?:the |an )?(?:update|fix|patch)\b[^.;]*/gi, + /\bneed(?:s)? (?:an? )?(?:fix|update|patch) (?:for|to (?:fix|repair)) (?:\w+ )*(?:gathering|collect(?:ion|Blocks?)|core mechanics)\b/gi, + /\bcannot gather (?:any )?resources\b/gi, + /\bgathering(?:\/\w+)? (?:commands? )?(?:are |is )?(?:still )?(?:broken|non-?functional) for both\b[^.;]*/gi, + /\bcore (?:mechanics|systems?) (?:are |is )?(?:broken|non-?functional|bugged)\b[^.;]*/gi, + /\bcrafted items don'?t persist\b/gi, + /CRITICAL:[^.;]*(?:non-?functional|broken|bugged)[^.;]*/gi, + ]; + const origLen = this.memory.length; + for (const pattern of toxicPatterns) { + this.memory = this.memory.replace(pattern, 'gathering works — relocate to find blocks'); + } + if (this.memory.length !== origLen) { + console.log('[Memory] Sanitized stale false beliefs from loaded memory'); + } + // ────────────────────────────────────────────────────────────── + this.turns = data.turns || []; console.log('Loaded memory:', this.memory); return data; } catch (error) { console.error('Failed to load history:', error); - throw error; + // RC27: Don't re-throw — return null to start fresh instead of crash-looping + return null; } } diff --git a/src/agent/learnings.js b/src/agent/learnings.js new file mode 100644 index 000000000..5cf55a349 --- /dev/null +++ b/src/agent/learnings.js @@ -0,0 +1,98 @@ +import { readFileSync, mkdirSync, existsSync, promises as fs, renameSync, unlinkSync } from 'fs'; +import path from 'path'; + +const MAX_ENTRIES = 100; + +// RC27: Atomic write — write to .tmp file then rename, preventing corruption on crash +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await fs.writeFile(tmpPath, content, 'utf8'); + try { renameSync(tmpPath, filepath); } catch (renameErr) { + console.warn(`[RC27] Atomic rename failed for ${filepath}, falling back:`, renameErr.message); + await fs.writeFile(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch(_e) {} + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + +export class Learnings { + constructor(agentName) { + this.agentName = agentName; + this.filePath = `./bots/${agentName}/learnings.json`; + this.entries = []; + this._dirty = false; + } + + load() { + try { + if (!existsSync(this.filePath)) return; + const raw = readFileSync(this.filePath, 'utf8'); + // RC27: Guard against empty/corrupted learnings file + if (!raw || !raw.trim()) { + console.warn(`[RC27] Learnings file ${this.filePath} is empty, starting fresh.`); + this.entries = []; + return; + } + this.entries = JSON.parse(raw); + if (!Array.isArray(this.entries)) this.entries = []; + console.log(`[Learnings] Loaded ${this.entries.length} entries for ${this.agentName}`); + } catch (err) { + console.error(`[Learnings] Failed to load for ${this.agentName}:`, err.message); + this.entries = []; + } + } + + async save() { + if (!this._dirty) return; + try { + const dir = path.dirname(this.filePath); + mkdirSync(dir, { recursive: true }); + await safeWriteFile(this.filePath, JSON.stringify(this.entries, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[Learnings] Save failed for ${this.agentName}:`, err.message); + } + } + + record(command, context, outcome) { + this.entries.push({ + command, + context: context.substring(0, 100), + outcome, // 'success' or 'fail' + timestamp: new Date().toISOString(), + }); + + // Prune oldest if over limit + if (this.entries.length > MAX_ENTRIES) { + this.entries = this.entries.slice(-MAX_ENTRIES); + } + + this._dirty = true; + } + + getRecentSummary(count = 10) { + const recent = this.entries.slice(-count); + if (recent.length === 0) return ''; + return recent.map(e => { + const icon = e.outcome === 'success' ? '+' : '-'; + return `[${icon}] ${e.command}: ${e.context}`; + }).join('\n'); + } + + getStats() { + const total = this.entries.length; + const successes = this.entries.filter(e => e.outcome === 'success').length; + const failures = total - successes; + return { total, successes, failures }; + } +} diff --git a/src/agent/library/dragon_progress.js b/src/agent/library/dragon_progress.js new file mode 100644 index 000000000..6e9d2a91d --- /dev/null +++ b/src/agent/library/dragon_progress.js @@ -0,0 +1,358 @@ +/** + * dragon_progress.js — Persistent Dragon Progression State + * + * Survives restarts, deaths, and crashes via atomic JSON writes. + * Tracks: completed chunks, key coordinates, inventory milestones, + * death count, current dimension, retry counts, and timestamps. + * + * Uses the same safeWriteFile pattern as history.js (RC27). + */ + +import { readFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs'; +import { writeFile } from 'fs/promises'; + +// ── RC27: Atomic write — .tmp + rename ───────────────────────────────── +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await writeFile(tmpPath, content, 'utf8'); + try { + renameSync(tmpPath, filepath); + } catch (renameErr) { + console.warn(`[DragonProgress] Atomic rename failed for ${filepath}, falling back:`, renameErr.message); + await writeFile(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch (_e) { /* ignore */ } + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + +// ── Chunk definitions ────────────────────────────────────────────────── +export const CHUNKS = Object.freeze({ + DIAMOND_PICKAXE: 'diamond_pickaxe', + NETHER_PORTAL: 'nether_portal', + BLAZE_RODS: 'blaze_rods', + ENDER_PEARLS: 'ender_pearls', + STRONGHOLD: 'stronghold', + DRAGON_FIGHT: 'dragon_fight', +}); + +const CHUNK_ORDER = [ + CHUNKS.DIAMOND_PICKAXE, + CHUNKS.NETHER_PORTAL, + CHUNKS.BLAZE_RODS, + CHUNKS.ENDER_PEARLS, + CHUNKS.STRONGHOLD, + CHUNKS.DRAGON_FIGHT, +]; + +function defaultState() { + return { + version: 2, + startedAt: new Date().toISOString(), + lastUpdated: null, + + // Which chunks are done / in-progress / failed + chunks: Object.fromEntries(CHUNK_ORDER.map(c => [c, { + status: 'pending', // pending | active | done | failed + attempts: 0, + lastAttempt: null, + completedAt: null, + }])), + + // Key coordinates discovered during the run + coords: { + overworldPortal: null, // [x, y, z] + netherPortal: null, + netherFortress: null, + stronghold: null, + endPortal: null, + lastDeathPos: null, + basecamp: null, // safe surface base + }, + + // Inventory milestones — tracks highest counts ever achieved + milestones: { + hasDiamondPick: false, + hasIronArmor: false, + hasDiamondSword: false, + hasBow: false, + blazeRods: 0, + enderPearls: 0, + eyesOfEnder: 0, + }, + + // Run statistics + stats: { + deaths: 0, + totalRetries: 0, + currentChunkIndex: 0, // index into CHUNK_ORDER + dimension: 'overworld', // overworld | the_nether | the_end + }, + + // Dragon fight specific + dragonFight: { + crystalsDestroyed: 0, + dragonHitsLanded: 0, + enteredEnd: false, + }, + }; +} + +export class DragonProgress { + /** + * @param {string} botName — used to derive file path under bots/ + */ + constructor(botName) { + this.botName = botName; + this.filePath = `./bots/${botName}/dragon_progress.json`; + this.state = defaultState(); + this._dirty = false; + } + + // ── Persistence ──────────────────────────────────────────────────── + + load() { + try { + if (!existsSync(this.filePath)) { + console.log(`[DragonProgress] No save file for ${this.botName}, starting fresh.`); + return this.state; + } + const raw = readFileSync(this.filePath, 'utf8'); + if (!raw || !raw.trim()) { + console.warn(`[DragonProgress] Empty save file, starting fresh.`); + return this.state; + } + const loaded = JSON.parse(raw); + // Merge with defaults to handle schema upgrades + this.state = { ...defaultState(), ...loaded }; + // Ensure all chunks exist (in case new ones were added) + for (const c of CHUNK_ORDER) { + if (!this.state.chunks[c]) { + this.state.chunks[c] = { status: 'pending', attempts: 0, lastAttempt: null, completedAt: null }; + } + } + console.log(`[DragonProgress] Loaded state for ${this.botName}: chunk ${this.currentChunkIndex()}/${CHUNK_ORDER.length}`); + return this.state; + } catch (err) { + console.error(`[DragonProgress] Failed to load for ${this.botName}:`, err.message); + // Rename corrupted file + if (existsSync(this.filePath)) { + const backup = this.filePath + '.corrupted.' + Date.now(); + try { renameSync(this.filePath, backup); } catch (_e) { /* ignore */ } + } + return this.state; + } + } + + async save() { + try { + const dir = `./bots/${this.botName}`; + mkdirSync(dir, { recursive: true }); + this.state.lastUpdated = new Date().toISOString(); + await safeWriteFile(this.filePath, JSON.stringify(this.state, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[DragonProgress] Failed to save:`, err.message); + } + } + + // ── Chunk State ──────────────────────────────────────────────────── + + /** Get the current chunk name (the first non-done chunk) */ + currentChunk() { + for (const c of CHUNK_ORDER) { + if (this.state.chunks[c].status !== 'done') return c; + } + return null; // all done! + } + + /** Get the 0-based index of current chunk */ + currentChunkIndex() { + const current = this.currentChunk(); + return current ? CHUNK_ORDER.indexOf(current) : CHUNK_ORDER.length; + } + + /** Is a specific chunk complete? */ + isChunkDone(chunkName) { + return this.state.chunks[chunkName]?.status === 'done'; + } + + /** Mark a chunk as started */ + markChunkActive(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'active'; + chunk.attempts++; + chunk.lastAttempt = new Date().toISOString(); + this.state.stats.totalRetries++; + this.state.stats.currentChunkIndex = CHUNK_ORDER.indexOf(chunkName); + this._dirty = true; + } + + /** Mark a chunk as successfully completed */ + markChunkDone(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'done'; + chunk.completedAt = new Date().toISOString(); + this._dirty = true; + } + + /** Mark a chunk as failed (for this attempt) */ + markChunkFailed(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'failed'; + this._dirty = true; + } + + /** Get how many attempts a chunk has had */ + getChunkAttempts(chunkName) { + return this.state.chunks[chunkName]?.attempts || 0; + } + + /** Is everything complete? */ + isComplete() { + return CHUNK_ORDER.every(c => this.state.chunks[c].status === 'done'); + } + + /** Reset a specific chunk back to pending (for retry after recovery) */ + resetChunk(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'pending'; + this._dirty = true; + } + + // ── Coordinates ──────────────────────────────────────────────────── + + setCoord(name, x, y, z) { + if (this.state.coords[name] !== undefined) { + this.state.coords[name] = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this._dirty = true; + } + } + + getCoord(name) { + return this.state.coords[name]; + } + + // ── Milestones ───────────────────────────────────────────────────── + + updateMilestones(bot) { + const inv = {}; + if (bot.inventory) { + for (const item of bot.inventory.items()) { + inv[item.name] = (inv[item.name] || 0) + item.count; + } + } + const m = this.state.milestones; + m.hasDiamondPick = m.hasDiamondPick || !!(inv['diamond_pickaxe']); + m.hasIronArmor = m.hasIronArmor || !!(inv['iron_chestplate']); + m.hasDiamondSword = m.hasDiamondSword || !!(inv['diamond_sword']); + m.hasBow = m.hasBow || !!(inv['bow']); + m.blazeRods = Math.max(m.blazeRods, inv['blaze_rod'] || 0); + m.enderPearls = Math.max(m.enderPearls, inv['ender_pearl'] || 0); + m.eyesOfEnder = Math.max(m.eyesOfEnder, inv['ender_eye'] || 0); + this._dirty = true; + } + + // ── Death tracking ───────────────────────────────────────────────── + + recordDeath(x, y, z, dimension) { + this.state.stats.deaths++; + this.state.coords.lastDeathPos = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this.state.stats.dimension = dimension || 'overworld'; + this._dirty = true; + } + + // ── Dimension ────────────────────────────────────────────────────── + + setDimension(dim) { + this.state.stats.dimension = dim; + this._dirty = true; + } + + getDimension() { + return this.state.stats.dimension; + } + + // ── Dragon fight state ───────────────────────────────────────────── + + recordCrystalDestroyed() { + this.state.dragonFight.crystalsDestroyed++; + this._dirty = true; + } + + recordDragonHit() { + this.state.dragonFight.dragonHitsLanded++; + this._dirty = true; + } + + setEnteredEnd(val = true) { + this.state.dragonFight.enteredEnd = val; + this._dirty = true; + } + + // ── Summary for LLM prompt injection ─────────────────────────────── + + getSummary() { + const s = this.state; + const idx = this.currentChunkIndex(); + const current = this.currentChunk(); + const parts = []; + + parts.push(`[DRAGON PROGRESS ${idx}/${CHUNK_ORDER.length}]`); + + // Completed chunks + const done = CHUNK_ORDER.filter(c => s.chunks[c].status === 'done'); + if (done.length > 0) { + parts.push(`Done: ${done.join(', ')}`); + } + + // Current chunk + attempts + if (current) { + const attempts = s.chunks[current].attempts; + parts.push(`Current: ${current} (attempt ${attempts + 1})`); + } else { + parts.push('ALL CHUNKS COMPLETE — dragon defeated!'); + } + + // Key coords + const coordEntries = Object.entries(s.coords) + .filter(([, v]) => v !== null) + .map(([k, v]) => `${k}: ${v.join(',')}`); + if (coordEntries.length > 0) { + parts.push(`Coords: ${coordEntries.join(' | ')}`); + } + + // Stats + parts.push(`Deaths: ${s.stats.deaths} | Dim: ${s.stats.dimension}`); + + // Dragon fight + if (s.dragonFight.enteredEnd) { + parts.push(`End fight: ${s.dragonFight.crystalsDestroyed} crystals, ${s.dragonFight.dragonHitsLanded} hits`); + } + + return parts.join('\n'); + } + + // ── Static helpers ───────────────────────────────────────────────── + + static get CHUNK_ORDER() { + return CHUNK_ORDER; + } + + static get CHUNKS() { + return CHUNKS; + } +} diff --git a/src/agent/library/dragon_runner.js b/src/agent/library/dragon_runner.js new file mode 100644 index 000000000..70c81c67d --- /dev/null +++ b/src/agent/library/dragon_runner.js @@ -0,0 +1,1597 @@ +/** + * dragon_runner.js — Autonomous Ender Dragon progression system (RC29). + * + * Six modular gameplay chunks that chain together for a full + * fresh-world → Ender Dragon defeat run: + * Chunk 1: getDiamondPickaxe (already in skills.js) + * Chunk 2: buildNetherPortal + * Chunk 3: collectBlazeRods + * Chunk 4: collectEnderPearls + * Chunk 5: locateStronghold + * Chunk 6: defeatEnderDragon + * + * Plus the meta-orchestrator: runDragonProgression() + * + * RC29 upgrades: + * - Persistent state via DragonProgress (survives restarts/deaths) + * - Smart orchestrator with exponential backoff + * - Death recovery with gear re-acquisition + * - Dimension-aware navigation + * - Proactive food/gear management between chunks + * + * All functions use existing skill primitives from skills.js and world.js. + * Each is idempotent — safe to call multiple times (skips completed steps). + */ + +import * as skills from './skills.js'; +import * as world from './world.js'; +import { DragonProgress, CHUNKS } from './dragon_progress.js'; +import { ProgressReporter } from './progress_reporter.js'; +import Vec3 from 'vec3'; + +function log(bot, msg) { + skills.log(bot, msg); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** Count a specific item in inventory */ +function countItem(bot, name) { + return (world.getInventoryCounts(bot)[name] || 0); +} + +/** Check if we have at least `n` of item */ +function hasItem(bot, name, n = 1) { + return countItem(bot, name) >= n; +} + +/** Ensure the bot has food and eats if hungry */ +async function eatIfNeeded(bot) { + if (bot.food < 14) { + await skills.ensureFed(bot); + } +} + +/** Get the bot's current dimension */ +function getDimension(bot) { + const dim = bot.game?.dimension || 'overworld'; + if (dim.includes('nether')) return 'the_nether'; + if (dim.includes('end')) return 'the_end'; + return 'overworld'; +} + +/** Ensure we have enough of an item, trying to craft then collect */ +async function _ensureItem(bot, itemName, count, craftFrom = null) { + let have = countItem(bot, itemName); + if (have >= count) return true; + + if (craftFrom) { + const needed = count - have; + for (let i = 0; i < needed; i++) { + if (bot.interrupt_code) return false; + if (!await skills.craftRecipe(bot, itemName, 1)) break; + } + have = countItem(bot, itemName); + if (have >= count) return true; + } + + const needed = count - countItem(bot, itemName); + if (needed > 0) { + await skills.collectBlock(bot, itemName, needed); + } + return countItem(bot, itemName) >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PRE-CHUNK PREPARATION & DEATH RECOVERY +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Ensure minimum gear and food before starting a chunk. + * Adapts requirements based on which chunk is next. + */ +async function prepareForChunk(bot, chunkName, progress) { + console.log(`[RC31] prepareForChunk: ${chunkName}`); + log(bot, `Preparing for chunk: ${chunkName}`); + if (bot.interrupt_code) return; + + // Always eat first + console.log('[RC31] prepareForChunk: eatIfNeeded'); + await eatIfNeeded(bot); + + // Ensure food stockpile (min 12 cooked meat) + const foodItems = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'cooked_salmon', 'cooked_cod', 'apple', 'carrot']; + let totalFood = 0; + const inv = world.getInventoryCounts(bot); + for (const f of foodItems) totalFood += (inv[f] || 0); + + if (totalFood < 12) { + console.log(`[RC31] prepareForChunk: stockpileFood (totalFood=${totalFood})`); + log(bot, `Food low (${totalFood}). Stockpiling...`); + await skills.stockpileFood(bot, 20); + } + + // RC30: Proactive inventory overflow — place a chest and store junk if nearly full + console.log('[RC31] prepareForChunk: checking inventory'); + const emptySlots = bot.inventory.emptySlotCount(); + if (emptySlots < 6 && getDimension(bot) === 'overworld') { + log(bot, `Inventory nearly full (${emptySlots} empty). Placing chest for overflow...`); + const nearbyChest = world.getNearestBlock(bot, 'chest', 32); + if (!nearbyChest && hasItem(bot, 'chest')) { + // Place a chest at current position + const pos = bot.entity.position; + try { + await skills.placeBlock(bot, 'chest', + Math.floor(pos.x) + 1, Math.floor(pos.y), Math.floor(pos.z), 'side'); + } catch (_e) { + log(bot, 'Could not place overflow chest.'); + } + } + } + + // Manage inventory (stores in nearby chests or discards junk) + console.log('[RC31] prepareForChunk: autoManageInventory'); + await skills.autoManageInventory(bot); + + // Chunk-specific prep + console.log(`[RC31] prepareForChunk: chunk-specific prep for ${chunkName}`); + switch (chunkName) { + case CHUNKS.NETHER_PORTAL: + // RC31: Must be on the surface for portal building (need iron, gravel, lava) + if (bot.entity.position.y < 50 && getDimension(bot) === 'overworld') { + log(bot, 'Underground — going to surface for nether portal building...'); + try { + await skills.goToSurface(bot); + } catch (_e) { + log(bot, 'goToSurface failed, trying pillarUp...'); + try { + await skills.pillarUp(bot, Math.min(30, 70 - Math.floor(bot.entity.position.y))); + } catch (_pe) { + log(bot, 'pillarUp also failed. Will try portal building from current position.'); + } + } + } + break; + + case CHUNKS.BLAZE_RODS: + case CHUNKS.ENDER_PEARLS: + // Need a sword for combat chunks + if (!hasItem(bot, 'diamond_sword') && !hasItem(bot, 'iron_sword')) { + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + break; + + case CHUNKS.DRAGON_FIGHT: + // Max out gear before the End + if (!hasItem(bot, 'diamond_sword') && hasItem(bot, 'diamond', 2)) { + await skills.craftRecipe(bot, 'diamond_sword', 1); + } + // Collect cobblestone for pillaring + if (countItem(bot, 'cobblestone') < 64 && getDimension(bot) === 'overworld') { + await skills.collectBlock(bot, 'cobblestone', 64); + } + break; + } + + // Update milestones + progress.updateMilestones(bot); + await progress.save(); +} + +/** + * After death: try to recover items by going to death location. + * Then re-acquire minimum gear if recovery failed. + * RC30: Hardened recovery — full tool chain re-crafting, dimension-aware + * portal linking safety, armor re-equip. + */ +async function recoverFromDeath(bot, progress) { + log(bot, 'Death recovery initiated...'); + + const deathPos = progress.getCoord('lastDeathPos'); + const deathDim = progress.getDimension(); + const currentDim = getDimension(bot); + + // RC30: Portal linking safety — if we died in the Nether but respawned in Overworld, + // and we have a saved portal coord, navigate to portal first before attempting recovery + if (deathDim === 'the_nether' && currentDim === 'overworld') { + const portalCoord = progress.getCoord('overworldPortal'); + if (portalCoord) { + log(bot, `Died in Nether, respawned in Overworld. Heading to saved portal: ${portalCoord.join(', ')}`); + try { + await skills.goToPosition(bot, portalCoord[0], portalCoord[1], portalCoord[2], 3); + // Wait for portal transition + await skills.wait(bot, 5000); + } catch (_e) { + log(bot, 'Could not reach Overworld portal. Will re-acquire gear in Overworld.'); + } + } + } + + // Attempt item recovery only if in the same dimension as death + if (deathPos && getDimension(bot) === deathDim) { + log(bot, `Heading to death location: ${deathPos.join(', ')}`); + try { + await skills.goToPosition(bot, deathPos[0], deathPos[1], deathPos[2], 3); + await skills.pickupNearbyItems(bot); + log(bot, 'Picked up items near death location.'); + } catch (_e) { + log(bot, 'Could not reach death location.'); + } + } else if (deathPos) { + log(bot, `Death was in ${deathDim} but currently in ${getDimension(bot)}. Skipping item recovery.`); + } + + // RC30: Full inventory check and re-acquisition chain + const inv = world.getInventoryCounts(bot); + const hasPickaxe = inv['diamond_pickaxe'] || inv['iron_pickaxe'] || inv['stone_pickaxe']; + const hasSword = inv['diamond_sword'] || inv['iron_sword'] || inv['stone_sword']; + + if (!hasPickaxe) { + log(bot, 'Lost pickaxe! Full tool chain re-crafting...'); + // Step 1: Get wood (try multiple tree types) + // Ocean escape: if no log exists within 64 blocks, explore until we find land + const LOG_TYPES = ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', 'acacia_log', 'jungle_log']; + const hasAnyLogInRange = () => world.getNearestBlocks(bot, LOG_TYPES, 64, 1).length > 0; + if (!hasAnyLogInRange()) { + log(bot, 'No trees within 64 blocks (ocean/void spawn?). Exploring up to 600 blocks to find land with trees...'); + for (let attempt = 0; attempt < 3 && !hasAnyLogInRange(); attempt++) { + if (bot.interrupt_code) break; + await skills.explore(bot, 200); + } + if (!hasAnyLogInRange()) { + log(bot, 'Still no trees after 3 explore attempts. Running getDiamondPickaxe full chain as fallback.'); + await skills.getDiamondPickaxe(bot); + return; + } + } + let gotWood = false; + for (const logType of LOG_TYPES) { + if (bot.interrupt_code) return; + if (hasItem(bot, logType, 1)) { gotWood = true; break; } + try { + await skills.collectBlock(bot, logType, 4); + if (countItem(bot, logType) > 0) { gotWood = true; break; } + } catch (_e) { /* try next type */ } + } + if (gotWood) { + // Step 2: Craft basic tools + const planksType = Object.keys(world.getInventoryCounts(bot)) + .find(k => k.endsWith('_log')); + if (planksType) { + const planksName = planksType.replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'crafting_table', 1); + await skills.craftRecipe(bot, 'wooden_pickaxe', 1); + // Step 3: Upgrade to stone + try { + await skills.collectBlock(bot, 'cobblestone', 8); + await skills.craftRecipe(bot, 'stone_pickaxe', 1); + await skills.craftRecipe(bot, 'stone_sword', 1); + } catch (_e) { + log(bot, 'Could not gather cobblestone for stone tools.'); + } + // Step 4: Try for iron if we have time + if (!bot.interrupt_code && getDimension(bot) === 'overworld') { + try { + await skills.collectBlock(bot, 'iron_ore', 3); + if (countItem(bot, 'raw_iron') >= 3 || countItem(bot, 'iron_ore') >= 3) { + await skills.smeltItem(bot, 'raw_iron', 3); + await skills.craftRecipe(bot, 'iron_pickaxe', 1); + } + } catch (_e) { + log(bot, 'Could not upgrade to iron. Stone tools will suffice.'); + } + } + } + } + } + + if (!hasSword) { + log(bot, 'Lost sword! Crafting replacement...'); + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2) || hasItem(bot, 'cobbled_deepslate', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } else { + // Desperate: craft wooden sword + const logTypes = Object.keys(world.getInventoryCounts(bot)).filter(k => k.endsWith('_log')); + if (logTypes.length > 0) { + const planksName = logTypes[0].replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'wooden_sword', 1); + } + } + } + + // RC30: Re-equip armor if we have any + const armorSlots = ['head', 'torso', 'legs', 'feet']; + const armorPriority = { + head: ['diamond_helmet', 'iron_helmet', 'chainmail_helmet', 'leather_helmet'], + torso: ['diamond_chestplate', 'iron_chestplate', 'chainmail_chestplate', 'leather_chestplate'], + legs: ['diamond_leggings', 'iron_leggings', 'chainmail_leggings', 'leather_leggings'], + feet: ['diamond_boots', 'iron_boots', 'chainmail_boots', 'leather_boots'], + }; + for (const slot of armorSlots) { + for (const armorName of armorPriority[slot]) { + const armorItem = bot.inventory.items().find(i => i.name === armorName); + if (armorItem) { + try { await bot.equip(armorItem, slot); } catch (_e) { /* best effort */ } + break; + } + } + } + + // Stock up food + await skills.stockpileFood(bot, 16); + await eatIfNeeded(bot); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 2: Build a Nether Portal +// ═══════════════════════════════════════════════════════════════════════════ + +export async function buildNetherPortal(bot) { + console.log('[RC31] buildNetherPortal: starting'); + /** + * Build a nether portal using one of two methods: + * (A) If the bot already has 10+ obsidian and flint_and_steel, build directly. + * (B) Otherwise, use water bucket + lava source to cast obsidian in place. + * Requires: iron_pickaxe or diamond_pickaxe, bucket, flint_and_steel. + * @param {MinecraftBot} bot + * @returns {Promise} true if nether portal is built and lit. + **/ + log(bot, '=== CHUNK 2: Build Nether Portal ==='); + + // Check prerequisites + const inv = world.getInventoryCounts(bot); + const hasPickaxe = (inv['diamond_pickaxe'] || 0) > 0 || (inv['iron_pickaxe'] || 0) > 0; + if (!hasPickaxe) { + log(bot, 'Need at least an iron pickaxe first. Running getDiamondPickaxe...'); + if (!await skills.getDiamondPickaxe(bot)) { + log(bot, 'Cannot get a pickaxe. Aborting nether portal.'); + return false; + } + } + + await eatIfNeeded(bot); + + // Ensure we have flint and steel + if (!hasItem(bot, 'flint_and_steel')) { + // Need iron ingot + flint + if (!hasItem(bot, 'iron_ingot')) { + // Mine and smelt iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 1); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 1); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 1); + } + if (!hasItem(bot, 'flint')) { + await skills.collectBlock(bot, 'gravel', 5); // flint drops from gravel + // Check if we got flint from mining gravel + if (!hasItem(bot, 'flint')) { + // Mine more gravel + for (let i = 0; i < 10 && !hasItem(bot, 'flint'); i++) { + if (bot.interrupt_code) return false; + await skills.collectBlock(bot, 'gravel', 3); + } + } + } + if (hasItem(bot, 'iron_ingot') && hasItem(bot, 'flint')) { + await skills.craftRecipe(bot, 'flint_and_steel', 1); + } + if (!hasItem(bot, 'flint_and_steel')) { + log(bot, 'Cannot craft flint_and_steel. Need iron_ingot + flint.'); + return false; + } + } + + // Ensure we have a bucket + if (!hasItem(bot, 'bucket') && !hasItem(bot, 'water_bucket') && !hasItem(bot, 'lava_bucket')) { + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } else { + // Need more iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 3); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 3); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 3); + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } + } + } + + // Method A: If we already have 10 obsidian, build directly + if (hasItem(bot, 'obsidian', 10)) { + return await buildPortalFromObsidian(bot); + } + + // Method B: Cast obsidian portal using water + lava + log(bot, 'Casting obsidian portal with water + lava method...'); + + // Get water bucket + if (!hasItem(bot, 'water_bucket')) { + const waterBlock = world.getNearestBlock(bot, 'water', 64); + if (waterBlock) { + await skills.goToPosition(bot, waterBlock.position.x, waterBlock.position.y, waterBlock.position.z, 2); + // Equip bucket and right-click water + const bucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (bucket) { + await bot.equip(bucket, 'hand'); + try { + const wBlock = bot.blockAt(waterBlock.position); + if (wBlock) await bot.activateBlock(wBlock); + } catch (_e) { /* try useOn fallback */ } + } + } + if (!hasItem(bot, 'water_bucket')) { + log(bot, 'Cannot find water source for bucket. Attempting direct mining of obsidian...'); + return await mineObsidianDirect(bot); + } + } + + // Find or create a lava source underground + log(bot, 'Finding lava source for portal casting...'); + + // Dig down to find lava (common near Y=10) + const currentY = Math.floor(bot.entity.position.y); + if (currentY > 15) { + const digDist = currentY - 11; + await skills.digDown(bot, digDist); + } + + // Find lava nearby + let lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) { + log(bot, 'No lava found nearby. Exploring at depth...'); + await skills.explore(bot, 40); + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + } + if (!lavaBlock) { + log(bot, 'Could not find lava. Try a different location.'); + return false; + } + + // Cast obsidian: pour water on lava source blocks + log(bot, 'Found lava! Casting obsidian...'); + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + + // Mine the obsidian we create — need at least 10 blocks + let obsidianCount = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidianCount < 10 && attempts < 25) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + // Pour water on lava + lavaBlock = world.getNearestBlock(bot, 'lava', 8); + if (!lavaBlock) { + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) break; + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + } + + // Place water near lava + const waterBucket = bot.inventory.items().find(i => i.name === 'water_bucket'); + if (waterBucket) { + await bot.equip(waterBucket, 'hand'); + try { + const aboveLava = bot.blockAt(lavaBlock.position.offset(0, 1, 0)); + if (aboveLava) await bot.activateBlock(aboveLava); + } catch (_e) { /* best effort */ } + await new Promise(r => setTimeout(r, 1000)); + + // Pick water back up + const waterBlock = world.getNearestBlock(bot, 'water', 5); + if (waterBlock) { + const emptyBucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (emptyBucket) { + await bot.equip(emptyBucket, 'hand'); + try { + const wb = bot.blockAt(waterBlock.position); + if (wb) await bot.activateBlock(wb); + } catch (_e) { /* best effort */ } + } + } + } + + // Mine newly created obsidian + const obsidian = world.getNearestBlock(bot, 'obsidian', 8); + if (obsidian) { + // Need diamond pickaxe to mine obsidian + const diamPick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (diamPick) { + await bot.equip(diamPick, 'hand'); + await skills.breakBlockAt(bot, obsidian.position.x, obsidian.position.y, obsidian.position.z); + await skills.pickupNearbyItems(bot); + } else { + log(bot, 'Need diamond pickaxe to mine obsidian!'); + return false; + } + } + + obsidianCount = countItem(bot, 'obsidian'); + log(bot, `Obsidian progress: ${obsidianCount}/10`); + } + + if (obsidianCount < 10) { + log(bot, `Only got ${obsidianCount} obsidian. Need 10. Try again.`); + return false; + } + + // Go to surface and build the portal + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function mineObsidianDirect(bot) { + /** Mine 10 obsidian directly (slow — need diamond pickaxe) */ + if (!hasItem(bot, 'diamond_pickaxe')) { + log(bot, 'Need diamond pickaxe to mine obsidian.'); + return false; + } + + log(bot, 'Mining obsidian directly...'); + let obsidian = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidian < 10 && attempts < 30) { + if (bot.interrupt_code) return false; + attempts++; + const block = world.getNearestBlock(bot, 'obsidian', 32); + if (!block) { + await skills.explore(bot, 40); + continue; + } + const pick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (pick) await bot.equip(pick, 'hand'); + await skills.breakBlockAt(bot, block.position.x, block.position.y, block.position.z); + await skills.pickupNearbyItems(bot); + obsidian = countItem(bot, 'obsidian'); + } + + if (obsidian < 10) return false; + + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function buildPortalFromObsidian(bot) { + /** Build a standard 4x5 nether portal frame and light it */ + log(bot, 'Building nether portal frame...'); + const pos = bot.entity.position; + const bx = Math.floor(pos.x) + 2; + const by = Math.floor(pos.y); + const bz = Math.floor(pos.z); + + // Standard portal frame: 4 wide x 5 tall, only the frame blocks + // Bottom row (2 blocks) + const portalBlocks = [ + // Bottom + [bx + 1, by, bz], [bx + 2, by, bz], + // Left column + [bx, by + 1, bz], [bx, by + 2, bz], [bx, by + 3, bz], + // Right column + [bx + 3, by + 1, bz], [bx + 3, by + 2, bz], [bx + 3, by + 3, bz], + // Top row + [bx + 1, by + 4, bz], [bx + 2, by + 4, bz], + ]; + + for (const [px, py, pz] of portalBlocks) { + if (bot.interrupt_code) return false; + const block = bot.blockAt(new Vec3(px, py, pz)); + if (block && block.name !== 'obsidian') { + // Clear the block first if not air + if (block.name !== 'air') { + await skills.breakBlockAt(bot, px, py, pz); + } + await skills.placeBlock(bot, 'obsidian', px, py, pz, 'bottom', true); + } + } + + // Clear the portal interior (2x3) + for (let dx = 1; dx <= 2; dx++) { + for (let dy = 1; dy <= 3; dy++) { + const block = bot.blockAt(new Vec3(bx + dx, by + dy, bz)); + if (block && block.name !== 'air') { + await skills.breakBlockAt(bot, bx + dx, by + dy, bz); + } + } + } + + // Light the portal with flint and steel + log(bot, 'Lighting nether portal...'); + const flintSteel = bot.inventory.items().find(i => i.name === 'flint_and_steel'); + if (flintSteel) { + await bot.equip(flintSteel, 'hand'); + const insideBlock = bot.blockAt(new Vec3(bx + 1, by + 1, bz)); + if (insideBlock) { + try { + await bot.activateBlock(insideBlock); + } catch (_e) { + // Try activating the bottom obsidian + const bottomBlock = bot.blockAt(new Vec3(bx + 1, by, bz)); + if (bottomBlock) await bot.activateBlock(bottomBlock); + } + } + } + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const portalBlock = world.getNearestBlock(bot, 'nether_portal', 8); + if (portalBlock) { + log(bot, 'Nether portal built and activated!'); + // Remember portal location + bot.memory_bank?.rememberPlace?.('overworld_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frame built but not activated. May need to manually light it.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 3: Collect Blaze Rods +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectBlazeRods(bot, count = 12) { + /** + * Travel to the Nether, find a Nether Fortress, and kill Blazes for rods. + * Prerequisites: nether portal exists, good gear + food. + * @param {MinecraftBot} bot + * @param {number} count - number of blaze rods to collect. Default 12. + * @returns {Promise} true if enough blaze rods collected. + **/ + log(bot, `=== CHUNK 3: Collect ${count} Blaze Rods ===`); + + const currentRods = countItem(bot, 'blaze_rod'); + if (currentRods >= count) { + log(bot, `Already have ${currentRods} blaze rods!`); + return true; + } + + // Ensure we have gear + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Check we're prepared + const inv = world.getInventoryCounts(bot); + if (!inv['iron_sword'] && !inv['diamond_sword'] && !inv['stone_sword']) { + log(bot, 'Need a sword before entering the Nether.'); + // Try to craft one + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (inv['cobblestone'] >= 2) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + + // Enter nether portal + const portal = world.getNearestBlock(bot, 'nether_portal', 64); + if (!portal) { + log(bot, 'No nether portal found! Build one first with !buildNetherPortal.'); + return false; + } + + log(bot, 'Entering nether portal...'); + await skills.goToPosition(bot, portal.position.x, portal.position.y, portal.position.z, 0); + + // Wait for dimension change + await new Promise(r => setTimeout(r, 8000)); + + // Check if we're in the nether + const dimension = bot.game?.dimension || 'overworld'; + if (!dimension.includes('nether') && !dimension.includes('the_nether')) { + log(bot, 'Failed to enter the Nether. Standing on portal...'); + // Try stepping into the portal + await new Promise(r => setTimeout(r, 5000)); + } + + // Search for nether fortress (nether_bricks) + log(bot, 'Searching for Nether Fortress...'); + let fortressFound = false; + let searchAttempts = 0; + + while (!fortressFound && searchAttempts < 15) { + if (bot.interrupt_code) return false; + searchAttempts++; + await eatIfNeeded(bot); + + // Look for nether_bricks which indicate a fortress + const bricks = world.getNearestBlock(bot, 'nether_bricks', 64); + if (bricks) { + log(bot, 'Found Nether Fortress!'); + await skills.goToPosition(bot, bricks.position.x, bricks.position.y, bricks.position.z, 3); + fortressFound = true; + } else { + log(bot, `Fortress search attempt ${searchAttempts}/15...`); + // Travel in a consistent direction through the nether + const pos = bot.entity.position; + const angle = (searchAttempts * 0.6) * Math.PI; // spiral pattern + const dist = 50 + searchAttempts * 20; + const targetX = pos.x + Math.cos(angle) * dist; + const targetZ = pos.z + Math.sin(angle) * dist; + await skills.goToPosition(bot, targetX, pos.y, targetZ, 5); + } + } + + if (!fortressFound) { + log(bot, 'Could not find a Nether Fortress after extensive search.'); + return false; + } + + // Hunt blazes + log(bot, 'Hunting blazes for blaze rods...'); + let rods = countItem(bot, 'blaze_rod'); + let huntAttempts = 0; + + while (rods < count && huntAttempts < 40) { + if (bot.interrupt_code) return false; + huntAttempts++; + await eatIfNeeded(bot); + + // Check health — retreat if low + if (bot.health < 8) { + log(bot, 'Low health! Building emergency shelter...'); + await skills.buildPanicRoom(bot); + } + + const blaze = world.getNearestEntityWhere(bot, e => e.name === 'blaze', 48); + if (blaze) { + // Prefer ranged attack for blazes + const hasBow = hasItem(bot, 'bow') && hasItem(bot, 'arrow'); + if (hasBow) { + await skills.rangedAttack(bot, 'blaze'); + } else { + log(bot, 'Fighting blaze in melee...'); + await skills.attackEntity(bot, blaze, true); + } + await skills.pickupNearbyItems(bot); + } else { + // Explore fortress to find blaze spawners + log(bot, 'No blazes visible. Searching fortress...'); + const spawner = world.getNearestBlock(bot, 'spawner', 32); + if (spawner) { + await skills.goToPosition(bot, spawner.position.x, spawner.position.y, spawner.position.z, 5); + await skills.wait(bot, 5000); // Wait for blazes to spawn + } else { + await skills.explore(bot, 30); + } + } + + rods = countItem(bot, 'blaze_rod'); + log(bot, `Blaze rods: ${rods}/${count}`); + } + + if (rods >= count) { + log(bot, `Collected ${rods} blaze rods! Heading back to portal...`); + } else { + log(bot, `Only got ${rods}/${count} blaze rods. May need to retry.`); + } + + return rods >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 4: Collect Ender Pearls +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectEnderPearls(bot, count = 12) { + /** + * Collect ender pearls by hunting Endermen. Works in both Overworld and Nether. + * Endermen are taller, so look at their feet to aggro them safely. + * @param {MinecraftBot} bot + * @param {number} count - target ender pearls. Default 12. + * @returns {Promise} true if enough ender pearls collected. + **/ + log(bot, `=== CHUNK 4: Collect ${count} Ender Pearls ===`); + + let pearls = countItem(bot, 'ender_pearl'); + if (pearls >= count) { + log(bot, `Already have ${pearls} ender pearls!`); + return true; + } + + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Ensure we have a good sword + const inv = world.getInventoryCounts(bot); + if (!inv['diamond_sword'] && !inv['iron_sword']) { + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } + } + + log(bot, 'Hunting Endermen for ender pearls...'); + let attempts = 0; + + while (pearls < count && attempts < 50) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // Find an enderman + const enderman = world.getNearestEntityWhere(bot, e => e.name === 'enderman', 48); + + if (enderman) { + log(bot, `Found Enderman! Distance: ${Math.floor(bot.entity.position.distanceTo(enderman.position))}`); + + // Get close enough + if (bot.entity.position.distanceTo(enderman.position) > 5) { + await skills.goToPosition(bot, + enderman.position.x, enderman.position.y, enderman.position.z, 4); + } + + // Look at its feet to aggro it (looking at head triggers teleportation aggro) + await bot.lookAt(enderman.position.offset(0, 0.5, 0)); + await new Promise(r => setTimeout(r, 500)); + + // Attack + await skills.attackEntity(bot, enderman, true); + await skills.pickupNearbyItems(bot); + } else { + // Endermen spawn more at night and in specific biomes + const timeOfDay = bot.time?.timeOfDay || 0; + if (timeOfDay < 13000) { + log(bot, 'Waiting for night (endermen spawn more at night)...'); + await skills.wait(bot, 5000); + } else { + // Explore to find endermen + await skills.explore(bot, 80); + } + } + + pearls = countItem(bot, 'ender_pearl'); + if (attempts % 5 === 0) { + log(bot, `Ender pearls: ${pearls}/${count} (attempt ${attempts})`); + } + } + + log(bot, `Collected ${pearls} ender pearls.`); + return pearls >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 5: Locate Stronghold & Enter the End +// ═══════════════════════════════════════════════════════════════════════════ + +export async function locateStronghold(bot) { + /** + * Craft eyes of ender, throw them to triangulate the stronghold, + * dig down to find it, locate the end portal, and activate it. + * Prerequisites: blaze rods + ender pearls. + * @param {MinecraftBot} bot + * @returns {Promise} true if end portal found and activated. + **/ + log(bot, '=== CHUNK 5: Locate Stronghold & Enter the End ==='); + + // Craft blaze powder from blaze rods + const blazeRods = countItem(bot, 'blaze_rod'); + const blazePowder = countItem(bot, 'blaze_powder'); + const enderPearls = countItem(bot, 'ender_pearl'); + const eyesOfEnder = countItem(bot, 'ender_eye'); + + const totalEyes = eyesOfEnder; + const canCraftEyes = Math.min( + blazeRods * 2 + blazePowder, + enderPearls + ); + + if (totalEyes + canCraftEyes < 12) { + log(bot, `Not enough materials for 12 eyes of ender. Have: ${eyesOfEnder} eyes, ${blazeRods} rods, ${enderPearls} pearls.`); + return false; + } + + // Craft blaze powder + if (blazeRods > 0 && countItem(bot, 'blaze_powder') < enderPearls) { + const rodsToCraft = Math.min(blazeRods, Math.ceil((enderPearls - blazePowder) / 2)); + await skills.craftRecipe(bot, 'blaze_powder', rodsToCraft); + } + + // Craft eyes of ender + const currentEyes = countItem(bot, 'ender_eye'); + if (currentEyes < 12) { + const toCraft = Math.min( + countItem(bot, 'blaze_powder'), + countItem(bot, 'ender_pearl'), + 12 - currentEyes + ); + for (let i = 0; i < toCraft; i++) { + if (bot.interrupt_code) return false; + await skills.craftRecipe(bot, 'ender_eye', 1); + } + } + + const finalEyes = countItem(bot, 'ender_eye'); + if (finalEyes < 12) { + log(bot, `Only crafted ${finalEyes} eyes of ender. Need 12.`); + return false; + } + + log(bot, `Crafted ${finalEyes} eyes of ender. Triangulating stronghold...`); + + // Throw eyes of ender to find stronghold direction + // The eye floats toward the stronghold then drops + // We need 2 throws from different positions to triangulate + + const throw1Pos = bot.entity.position.clone(); + let throw1Dir = null; + let throw2Dir = null; + + // First throw + log(bot, 'Throwing first eye of ender...'); + const eye1 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye1) { + await bot.equip(eye1, 'hand'); + await bot.look(0, 0); // look forward + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + // The eye entity should appear and float in a direction + // Watch for thrown ender eye entity + const eyeEntity = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity) { + const eyePos = eyeEntity.position; + throw1Dir = { + x: eyePos.x - throw1Pos.x, + z: eyePos.z - throw1Pos.z + }; + log(bot, `Eye flew toward (${Math.floor(eyePos.x)}, ${Math.floor(eyePos.z)})`); + } + } + + // Move perpendicular for second throw + if (throw1Dir) { + const perpX = throw1Pos.x + (-throw1Dir.z > 0 ? 200 : -200); + const perpZ = throw1Pos.z + (throw1Dir.x > 0 ? 200 : -200); + log(bot, 'Moving for second triangulation throw...'); + await skills.goToPosition(bot, perpX, bot.entity.position.y, perpZ, 10); + } else { + // First throw failed, just move and try again + await skills.explore(bot, 200); + } + + const throw2Pos = bot.entity.position.clone(); + + // Second throw + log(bot, 'Throwing second eye of ender...'); + const eye2 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye2) { + await bot.equip(eye2, 'hand'); + await bot.look(0, 0); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const eyeEntity2 = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity2) { + throw2Dir = { + x: eyeEntity2.position.x - throw2Pos.x, + z: eyeEntity2.position.z - throw2Pos.z + }; + } + } + + // Estimate stronghold position from two throws + let targetX, targetZ; + if (throw1Dir && throw2Dir) { + // Line intersection to find stronghold + const det = throw1Dir.x * throw2Dir.z - throw1Dir.z * throw2Dir.x; + if (Math.abs(det) > 0.01) { + const t = ((throw2Pos.x - throw1Pos.x) * throw2Dir.z - (throw2Pos.z - throw1Pos.z) * throw2Dir.x) / det; + targetX = throw1Pos.x + throw1Dir.x * t; + targetZ = throw1Pos.z + throw1Dir.z * t; + log(bot, `Stronghold estimated at (${Math.floor(targetX)}, ${Math.floor(targetZ)})`); + } else { + // Lines are parallel, just follow the first direction + targetX = throw1Pos.x + throw1Dir.x * 100; + targetZ = throw1Pos.z + throw1Dir.z * 100; + } + } else { + // Fallback: strongholds typically generate 1000-3000 blocks from origin + // in ring patterns. Head toward origin at ~1500 block radius + const pos = bot.entity.position; + const distFromOrigin = Math.sqrt(pos.x * pos.x + pos.z * pos.z); + if (distFromOrigin > 2000) { + targetX = pos.x * 0.6; // Move toward origin + targetZ = pos.z * 0.6; + } else { + targetX = pos.x + 500; + targetZ = pos.z + 500; + } + log(bot, `Eye tracking failed. Heading toward estimated stronghold area...`); + } + + // Navigate to estimated position + log(bot, 'Traveling to stronghold area...'); + await skills.goToPosition(bot, targetX, bot.entity.position.y, targetZ, 20); + + // Keep throwing eyes to refine position until they go DOWN + log(bot, 'Refining position with more eye throws...'); + let goingDown = false; + let refineAttempts = 0; + while (!goingDown && refineAttempts < 10) { + if (bot.interrupt_code) return false; + refineAttempts++; + await eatIfNeeded(bot); + + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const flyingEye = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (flyingEye) { + const eyeY = flyingEye.position.y; + const botY = bot.entity.position.y; + if (eyeY < botY) { + // Eye went DOWN — stronghold is below us! + goingDown = true; + log(bot, 'Eye went underground — stronghold is directly below!'); + } else { + // Still need to follow + await skills.goToPosition(bot, + flyingEye.position.x, bot.entity.position.y, flyingEye.position.z, 10); + } + } + await skills.pickupNearbyItems(bot); // Recover dropped eye + } + + // Dig down to find the stronghold + log(bot, 'Digging down to stronghold...'); + await skills.digDown(bot, 40); + + // Search for end portal frame blocks + let portalFrame = null; + let searchAttempts = 0; + while (!portalFrame && searchAttempts < 20) { + if (bot.interrupt_code) return false; + searchAttempts++; + + portalFrame = world.getNearestBlock(bot, 'end_portal_frame', 32); + if (!portalFrame) { + // Look for stone_bricks (stronghold material) + const stoneBricks = world.getNearestBlock(bot, 'stone_bricks', 32); + if (stoneBricks) { + log(bot, 'Found stronghold stonework! Searching for portal room...'); + await skills.goToPosition(bot, + stoneBricks.position.x, stoneBricks.position.y, stoneBricks.position.z, 2); + } + await skills.explore(bot, 20); + } + } + + if (!portalFrame) { + log(bot, 'Could not find end portal frame. Dig around in the stronghold to find it.'); + return false; + } + + log(bot, 'Found end portal frame! Filling with eyes of ender...'); + await skills.goToPosition(bot, + portalFrame.position.x, portalFrame.position.y, portalFrame.position.z, 3); + + // Fill all portal frames with eyes of ender + const frames = bot.findBlocks({ + matching: block => block && block.name === 'end_portal_frame', + maxDistance: 16, + count: 12 + }); + + let filled = 0; + for (const framePos of frames) { + if (bot.interrupt_code) return false; + const frameBlock = bot.blockAt(framePos); + if (!frameBlock) continue; + + // Check if frame already has an eye (metadata check) + // end_portal_frame has property 'eye' which is true/false + const hasEye = frameBlock.getProperties?.()?.eye === 'true' || + frameBlock.getProperties?.()?.eye === true; + if (hasEye) { + filled++; + continue; + } + + // Place eye of ender in frame + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + try { + await bot.activateBlock(frameBlock); + filled++; + await new Promise(r => setTimeout(r, 500)); + } catch (_e) { + log(bot, 'Failed to place eye in frame.'); + } + } + + log(bot, `Filled ${filled}/${frames.length} portal frames.`); + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (endPortal) { + log(bot, 'End portal is ACTIVE! Ready to enter.'); + // Remember location + const pos = bot.entity.position; + bot.memory_bank?.rememberPlace?.('end_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frames placed but portal not active. May need more eyes.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 6: Defeat the Ender Dragon +// ═══════════════════════════════════════════════════════════════════════════ + +export async function defeatEnderDragon(bot) { + /** + * Enter The End and defeat the Ender Dragon. + * Strategy: destroy end crystals first, then attack dragon during perching. + * Requires: strong weapon, bow + arrows, blocks for pillaring, food. + * @param {MinecraftBot} bot + * @returns {Promise} true if dragon defeated. + **/ + log(bot, '=== CHUNK 6: Defeat the Ender Dragon ==='); + + await eatIfNeeded(bot); + + // Ensure we have needed supplies + const inv = world.getInventoryCounts(bot); + const hasSword = inv['diamond_sword'] || inv['iron_sword']; + const hasBow = inv['bow'] && (inv['arrow'] || 0) >= 32; + const hasBlocks = (inv['cobblestone'] || 0) >= 64; + + if (!hasSword) { + log(bot, 'Need a sword for dragon fight.'); + if (inv['diamond'] >= 2) await skills.craftRecipe(bot, 'diamond_sword', 1); + else if (inv['iron_ingot'] >= 2) await skills.craftRecipe(bot, 'iron_sword', 1); + } + + if (!hasBow) { + log(bot, 'Bow + arrows strongly recommended for end crystals.'); + } + + // Ensure we have blocks for pillaring up to crystals + if (!hasBlocks) { + await skills.collectBlock(bot, 'cobblestone', 64); + } + + // Enter the end portal + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (!endPortal) { + log(bot, 'No end portal found! Run !locateStronghold first.'); + return false; + } + + log(bot, 'Jumping into the End...'); + await skills.goToPosition(bot, endPortal.position.x, endPortal.position.y, endPortal.position.z, 0); + await new Promise(r => setTimeout(r, 10000)); // Wait for dimension transfer + + // In The End now + log(bot, 'Arrived in The End. Beginning dragon fight!'); + + // Phase 1: Destroy end crystals on obsidian pillars + log(bot, 'Phase 1: Destroying end crystals...'); + let crystalsDestroyed = 0; + let crystalAttempts = 0; + + while (crystalAttempts < 30) { + if (bot.interrupt_code) return false; + crystalAttempts++; + await eatIfNeeded(bot); + + // Health check + if (bot.health < 8) { + log(bot, 'Low health! Eating and hiding...'); + await skills.buildPanicRoom(bot); + } + + // Find end crystals + const crystal = world.getNearestEntityWhere(bot, e => + e.name === 'end_crystal' || e.name === 'ender_crystal', 64); + + if (!crystal) { + log(bot, `All visible crystals destroyed (${crystalsDestroyed} confirmed).`); + break; + } + + const dist = bot.entity.position.distanceTo(crystal.position); + log(bot, `End crystal found at distance ${Math.floor(dist)}`); + + if (hasBow && dist > 8) { + // Shoot the crystal with bow + await skills.rangedAttack(bot, crystal.name); + crystalsDestroyed++; + } else { + // Pillar up and melee the crystal + // Get close first + await skills.goToPosition(bot, + crystal.position.x, bot.entity.position.y, crystal.position.z, 4); + + // If crystal is high up, pillar + const heightDiff = crystal.position.y - bot.entity.position.y; + if (heightDiff > 3) { + log(bot, `Pillaring up ${Math.floor(heightDiff)} blocks...`); + const pos = bot.entity.position; + for (let i = 0; i < Math.floor(heightDiff); i++) { + if (bot.interrupt_code) return false; + await skills.placeBlock(bot, 'cobblestone', + Math.floor(pos.x), Math.floor(pos.y) + i, Math.floor(pos.z), 'bottom', true); + bot.setControlState('jump', true); + await new Promise(r => setTimeout(r, 400)); + bot.setControlState('jump', false); + } + } + + // Attack the crystal (causes explosion — back away!) + try { + await bot.attack(crystal); + crystalsDestroyed++; + log(bot, 'Crystal destroyed! (watch for explosion damage)'); + } catch (_e) { + log(bot, 'Failed to attack crystal directly.'); + } + + // Move away from explosion + await skills.moveAway(bot, 5); + } + } + + // Phase 2: Fight the dragon + log(bot, 'Phase 2: Fighting the Ender Dragon!'); + let dragonAlive = true; + let fightAttempts = 0; + + while (dragonAlive && fightAttempts < 100) { + if (bot.interrupt_code) return false; + fightAttempts++; + await eatIfNeeded(bot); + + // RC30: Golden apple priority when health is critical during dragon fight + if (bot.health < 10) { + const inv = world.getInventoryCounts(bot); + const gapple = (inv['golden_apple'] || 0) > 0 ? 'golden_apple' + : (inv['enchanted_golden_apple'] || 0) > 0 ? 'enchanted_golden_apple' : null; + if (gapple) { + log(bot, `Critical health (${bot.health.toFixed(1)})! Eating ${gapple}!`); + await skills.consume(bot, gapple); + } + } + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // RC30: Void edge avoidance — check before we get too close + const pos = bot.entity.position; + if (pos.y < 5 || (Math.abs(pos.x) > 40 && pos.y < 55) || (Math.abs(pos.z) > 40 && pos.y < 55)) { + log(bot, 'DANGER: Near void edge! Moving to center...'); + await skills.goToPosition(bot, 0, 64, 0, 10); // Center of End island + continue; + } + + // Find the dragon + const dragon = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 128); + + if (!dragon) { + // Dragon might be dead or far away + const dragonEntity = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 256); + if (!dragonEntity) { + log(bot, 'Dragon not found. It might be defeated!'); + dragonAlive = false; + break; + } + // Move toward center where dragon perches + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 3000); + continue; + } + + const dist = bot.entity.position.distanceTo(dragon.position); + + // When dragon is perching on the fountain (near 0,64,0), it's vulnerable + if (dragon.position.y < 70 && dist < 20) { + log(bot, 'Dragon is perching! Attacking!'); + // Equip best sword + await equipBestSword(bot); + try { + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + } catch (_e) { + // Dragon may have moved + } + } else if (hasBow && dist < 64) { + // Shoot with bow when dragon is flying + log(bot, 'Shooting dragon with bow...'); + const bow = bot.inventory.items().find(i => i.name === 'bow'); + if (bow) { + await bot.equip(bow, 'hand'); + const predictedPos = dragon.position.offset( + (dragon.velocity?.x || 0) * 2, + (dragon.velocity?.y || 0) * 2 + 2, + (dragon.velocity?.z || 0) * 2 + ); + await bot.lookAt(predictedPos); + bot.activateItem(); + await new Promise(r => setTimeout(r, 1200)); + bot.deactivateItem(); + } + } else { + // Move toward center and wait for dragon to perch + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 2000); + } + + // Check for experience orbs (dragon death indicator) + const xpOrb = world.getNearestEntityWhere(bot, e => + e.name === 'experience_orb' || e.name === 'xp_orb', 32); + if (xpOrb) { + log(bot, 'Experience orbs detected — Dragon might be dead!'); + dragonAlive = false; + } + } + + if (!dragonAlive) { + log(bot, '🐉 ENDER DRAGON DEFEATED! VICTORY!'); + await skills.pickupNearbyItems(bot); + return true; + } + + log(bot, 'Dragon fight timed out. May need to retry.'); + return false; +} + +async function equipBestSword(bot) { + const swords = bot.inventory.items().filter(i => i.name.includes('sword')); + if (swords.length === 0) return; + // Sort by attack damage (diamond > iron > stone > wooden) + const priority = { 'netherite_sword': 5, 'diamond_sword': 4, 'iron_sword': 3, 'stone_sword': 2, 'golden_sword': 1, 'wooden_sword': 0 }; + swords.sort((a, b) => (priority[b.name] || 0) - (priority[a.name] || 0)); + await bot.equip(swords[0], 'hand'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// META ORCHESTRATOR: Full Dragon Progression (RC29 — persistent + smart) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Complete autonomous run from fresh world to defeating the Ender Dragon. + * Uses persistent DragonProgress to survive restarts and deaths. + * Smart retry with exponential backoff, death recovery, dimension awareness. + * @param {MinecraftBot} bot + * @returns {Promise} true if Ender Dragon defeated. + */ +export async function runDragonProgression(bot) { + log(bot, '╔══════════════════════════════════════════════════╗'); + log(bot, '║ DRAGON PROGRESSION v2 (RC29): Smart Orchestrator║'); + log(bot, '╚══════════════════════════════════════════════════╝'); + + // ── Load or initialize persistent state ──────────────────────────── + const botName = bot.username || bot.entity?.username || 'UnknownBot'; + const progress = new DragonProgress(botName); + progress.load(); + + // Log current state + log(bot, progress.getSummary()); + + // ── Register death handler for this run ──────────────────────────── + let deathOccurred = false; + const deathHandler = () => { + try { + deathOccurred = true; + const pos = bot.entity?.position; + if (pos && isFinite(pos.x) && isFinite(pos.y) && isFinite(pos.z)) { + progress.recordDeath(pos.x, pos.y, pos.z, getDimension(bot)); + } + progress.save().catch(err => console.error('[DragonProgress] Save on death failed:', err)); + } catch (err) { + console.error('[DragonProgress] Death handler error:', err.message); + } + }; + bot.on('death', deathHandler); + + // ── RC30: Start progress reporter ────────────────────────────────── + const reporter = new ProgressReporter(bot, progress); + reporter.start(); + + // ── Chunk definitions ────────────────────────────────────────────── + const chunkRunners = { + [CHUNKS.DIAMOND_PICKAXE]: { + name: 'Diamond Pickaxe', + check: () => hasItem(bot, 'diamond_pickaxe') || progress.isChunkDone(CHUNKS.DIAMOND_PICKAXE), + run: () => skills.getDiamondPickaxe(bot), + }, + [CHUNKS.NETHER_PORTAL]: { + name: 'Nether Portal', + check: () => { + if (progress.isChunkDone(CHUNKS.NETHER_PORTAL)) return true; + return world.getNearestBlock(bot, 'nether_portal', 128) !== null; + }, + run: () => buildNetherPortal(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('overworldPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.BLAZE_RODS]: { + name: 'Blaze Rods', + check: () => hasItem(bot, 'blaze_rod', 7) || progress.state.milestones.blazeRods >= 7, + run: () => collectBlazeRods(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.ENDER_PEARLS]: { + name: 'Ender Pearls', + check: () => { + const totalEyeMaterial = countItem(bot, 'ender_pearl') + countItem(bot, 'ender_eye'); + return totalEyeMaterial >= 12 || progress.state.milestones.eyesOfEnder >= 12; + }, + run: () => collectEnderPearls(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.STRONGHOLD]: { + name: 'Stronghold', + check: () => { + if (progress.isChunkDone(CHUNKS.STRONGHOLD)) return true; + return world.getNearestBlock(bot, 'end_portal', 16) !== null; + }, + run: () => locateStronghold(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('stronghold', p.x, p.y, p.z); + progress.setCoord('endPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.DRAGON_FIGHT]: { + name: 'Ender Dragon Fight', + check: () => false, // Always attempt + run: () => defeatEnderDragon(bot), + onSuccess: () => { + progress.setEnteredEnd(true); + }, + }, + }; + + // ── Main orchestration loop ──────────────────────────────────────── + const MAX_RETRIES_PER_CHUNK = 5; + + try { + for (const chunkKey of DragonProgress.CHUNK_ORDER) { + console.log(`[RC31] orchestrator: processing chunk ${chunkKey}`); + if (bot.interrupt_code) { + log(bot, 'Dragon progression interrupted.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + + const runner = chunkRunners[chunkKey]; + const chunkIdx = DragonProgress.CHUNK_ORDER.indexOf(chunkKey) + 1; + const totalChunks = DragonProgress.CHUNK_ORDER.length; + + // Skip completed chunks + if (runner.check()) { + console.log(`[RC31] orchestrator: chunk ${chunkKey} check=true, skipping`); + if (!progress.isChunkDone(chunkKey)) { + progress.markChunkDone(chunkKey); + await progress.save(); + } + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- already complete, skipping.`); + continue; + } + + log(bot, `\n>> Chunk ${chunkIdx}/${totalChunks}: ${runner.name}`); + + // Pre-chunk preparation + await prepareForChunk(bot, chunkKey, progress); + + let success = false; + let retries = 0; + + while (!success && retries < MAX_RETRIES_PER_CHUNK) { + if (bot.interrupt_code) break; + retries++; + + // Handle death recovery between retries + if (deathOccurred) { + deathOccurred = false; + log(bot, `Died during ${runner.name}. Recovering...`); + await new Promise(r => setTimeout(r, 3000)); // Wait for respawn + await recoverFromDeath(bot, progress); + } + + progress.markChunkActive(chunkKey); + await progress.save(); + + const backoffMs = Math.min(1000 * Math.pow(2, retries - 1), 30000); + if (retries > 1) { + log(bot, `Retry ${retries}/${MAX_RETRIES_PER_CHUNK} for ${runner.name} (backoff ${Math.round(backoffMs / 1000)}s)...`); + await new Promise(r => setTimeout(r, backoffMs)); + await eatIfNeeded(bot); + // Explore to fresh area before retrying + if (getDimension(bot) === 'overworld') { + await skills.explore(bot, 100 + retries * 50); + } + } + + try { + console.log(`[RC31] orchestrator: running chunk ${runner.name} (retry ${retries})`); + success = await runner.run(); + console.log(`[RC31] orchestrator: chunk ${runner.name} returned success=${success}`); + } catch (err) { + console.error(`[RC31] orchestrator: chunk ${runner.name} threw: ${err.message}`); + log(bot, `Chunk ${runner.name} error: ${err.message}`); + success = false; + } + + if (success) { + // Run onSuccess hook + if (runner.onSuccess) { + try { runner.onSuccess(); } catch (_e) { /* best effort */ } + } + progress.markChunkDone(chunkKey); + progress.updateMilestones(bot); + await progress.save(); + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- COMPLETE!`); + reporter.onChunkChange(); // RC30: trigger progress report on chunk transition + } else if (!bot.interrupt_code) { + progress.markChunkFailed(chunkKey); + await progress.save(); + } + } + + if (!success) { + log(bot, `Chunk ${runner.name} failed after ${MAX_RETRIES_PER_CHUNK} attempts.`); + log(bot, 'Dragon progression paused. Run !beatMinecraft or !dragonProgression to resume.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + } + } finally { + reporter.stop(); // RC30: stop progress reporter + bot.removeListener('death', deathHandler); + } + + // ── Victory! ─────────────────────────────────────────────────────── + log(bot, '\n== ENDER DRAGON DEFEATED! GG! =='); + log(bot, progress.getSummary()); + await progress.save(); + return true; +} diff --git a/src/agent/library/full_state.js b/src/agent/library/full_state.js index 45a1fbe22..0314f5a57 100644 --- a/src/agent/library/full_state.js +++ b/src/agent/library/full_state.js @@ -55,7 +55,12 @@ export function getFullState(agent) { }, action: { current: agent.isIdle() ? 'Idle' : agent.actions.currentActionLabel, - isIdle: agent.isIdle() + isIdle: agent.isIdle(), + resumeName: agent.actions.resume_name || null + }, + selfPrompter: { + prompt: agent.self_prompter.prompt || '', + state: agent.self_prompter.state }, surroundings: { below, diff --git a/src/agent/library/index.js b/src/agent/library/index.js index ae864b035..3f9540350 100644 --- a/src/agent/library/index.js +++ b/src/agent/library/index.js @@ -1,5 +1,6 @@ import * as skills from './skills.js'; import * as world from './world.js'; +import * as dragonRunner from './dragon_runner.js'; export function docHelper(functions, module_name) { @@ -19,5 +20,6 @@ export function getSkillDocs() { let docArray = []; docArray = docArray.concat(docHelper(Object.values(skills), 'skills')); docArray = docArray.concat(docHelper(Object.values(world), 'world')); + docArray = docArray.concat(docHelper(Object.values(dragonRunner), 'dragonRunner')); return docArray; } diff --git a/src/agent/library/lockdown.js b/src/agent/library/lockdown.js index 2db7e3f0c..ecf27673c 100644 --- a/src/agent/library/lockdown.js +++ b/src/agent/library/lockdown.js @@ -15,8 +15,11 @@ export function lockdown() { consoleTaming: 'unsafe', errorTaming: 'unsafe', stackFiltering: 'verbose', - // allow eval outside of created compartments - // (mineflayer dep "protodef" uses eval) + // NOTE: 'unsafeEval' is required for compatibility with mineflayer's + // 'protodef' dependency which uses eval internally. Switching to + // 'safeEval' or 'noEval' breaks mineflayer. AI-generated code still runs + // inside a sandboxed Compartment (see makeCompartment below), so the + // outer eval exposure is limited to trusted application dependencies only. evalTaming: 'unsafeEval', }); } @@ -29,4 +32,4 @@ export const makeCompartment = (endowments = {}) => { // standard endowments ...endowments }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/agent/library/progress_reporter.js b/src/agent/library/progress_reporter.js new file mode 100644 index 000000000..2c97fa018 --- /dev/null +++ b/src/agent/library/progress_reporter.js @@ -0,0 +1,231 @@ +/** + * progress_reporter.js — Periodic Dragon Progression Status Reporter (RC30). + * + * Reports bot status every 5 minutes (or on chunk change) to: + * 1. Console/log output + * 2. Optional Discord webhook (if DISCORD_PROGRESS_WEBHOOK env var is set) + * + * Status includes: current chunk, health/hunger, dimension, location, + * elapsed time, estimated time to next stage, next goal, and optionally + * a screenshot if vision is enabled. + * + * Uses the same safeWriteFile and logging patterns as dragon_progress.js. + */ + +import * as world from './world.js'; +import * as skills from './skills.js'; + +// ── Estimated durations per chunk (minutes), for ETA calculation ──────── +const CHUNK_ESTIMATES = { + diamond_pickaxe: 15, + nether_portal: 12, + blaze_rods: 20, + ender_pearls: 25, + stronghold: 15, + dragon_fight: 20, +}; + +const CHUNK_GOALS = { + diamond_pickaxe: 'Mine diamonds and craft a diamond pickaxe', + nether_portal: 'Collect obsidian and build a Nether portal', + blaze_rods: 'Find a Nether fortress and collect 7+ blaze rods', + ender_pearls: 'Hunt endermen for 12+ ender pearls, craft eyes of ender', + stronghold: 'Triangulate and locate the stronghold / End portal', + dragon_fight: 'Enter the End, destroy crystals, defeat the Ender Dragon', +}; + +/** + * ProgressReporter — attaches to a bot + DragonProgress instance. + * Call start() to begin periodic reporting, stop() to end. + */ +export class ProgressReporter { + /** + * @param {object} bot — mineflayer bot instance + * @param {import('./dragon_progress.js').DragonProgress} progress — dragon state tracker + * @param {object} [options] + * @param {number} [options.intervalMs=300000] — report interval (default 5 min) + * @param {string} [options.webhookUrl] — Discord webhook URL (or set DISCORD_PROGRESS_WEBHOOK env) + * @param {object} [options.visionInterpreter] — VisionInterpreter instance for screenshots + */ + constructor(bot, progress, options = {}) { + this.bot = bot; + this.progress = progress; + this.intervalMs = options.intervalMs || 300_000; // 5 minutes + this.webhookUrl = options.webhookUrl || process.env.DISCORD_PROGRESS_WEBHOOK || null; + this.visionInterpreter = options.visionInterpreter || null; + this._timer = null; + this._startTime = null; + this._lastChunk = null; + this._reportCount = 0; + } + + /** Start the periodic reporter. Safe to call multiple times (idempotent). */ + start() { + if (this._timer) return; // already running + this._startTime = Date.now(); + this._lastChunk = this.progress.currentChunk(); + + // Immediate first report + this._report().catch(err => console.error('[ProgressReporter] First report error:', err.message)); + + this._timer = setInterval(() => { + this._report().catch(err => console.error('[ProgressReporter] Report error:', err.message)); + }, this.intervalMs); + + console.log(`[ProgressReporter] Started — reporting every ${Math.round(this.intervalMs / 60_000)}min`); + } + + /** Stop the reporter. */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + // Send final report + this._report().catch(() => {}); + console.log('[ProgressReporter] Stopped.'); + } + + /** + * Check if chunk changed and trigger an off-cycle report. + * Call this from the orchestrator after each chunk transition. + */ + onChunkChange() { + const current = this.progress.currentChunk(); + if (current !== this._lastChunk) { + this._lastChunk = current; + this._report().catch(err => + console.error('[ProgressReporter] Chunk-change report error:', err.message)); + } + } + + // ── Internal ─────────────────────────────────────────────────────── + + async _report() { + this._reportCount++; + const status = this._buildStatus(); + const text = this._formatConsole(status); + + // Always log to console + skills.log(this.bot, `\n${text}`); + console.log(text); + + // Send to Discord webhook if configured + if (this.webhookUrl) { + await this._sendWebhook(status); + } + } + + _buildStatus() { + const bot = this.bot; + const progress = this.progress; + const pos = bot.entity?.position; + const currentChunk = progress.currentChunk(); + const chunkIndex = progress.currentChunkIndex(); + const totalChunks = progress.constructor.CHUNK_ORDER.length; + + // Elapsed time + const elapsedMs = Date.now() - (this._startTime || Date.now()); + const elapsedMin = Math.round(elapsedMs / 60_000); + + // ETA for current chunk + const chunkAttempts = currentChunk ? progress.getChunkAttempts(currentChunk) : 0; + const estimatedMin = currentChunk ? (CHUNK_ESTIMATES[currentChunk] || 15) : 0; + // Rough ETA: base estimate × (1 + 0.5 * retries) — retries take longer + const etaMin = Math.round(estimatedMin * (1 + 0.3 * chunkAttempts)); + + // Inventory summary + const inv = world.getInventoryCounts(bot); + const keyItems = []; + for (const item of ['diamond_pickaxe', 'diamond_sword', 'iron_sword', 'bow', + 'blaze_rod', 'ender_pearl', 'ender_eye', 'obsidian']) { + const count = inv[item] || 0; + if (count > 0) keyItems.push(`${item}×${count}`); + } + + // Food count + const foodNames = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'apple', 'carrot', 'golden_apple']; + let foodCount = 0; + for (const f of foodNames) foodCount += (inv[f] || 0); + + return { + botName: bot.username || 'Bot', + chunk: currentChunk || 'COMPLETE', + chunkIndex: chunkIndex + 1, + totalChunks, + chunkName: currentChunk ? (CHUNK_GOALS[currentChunk] || currentChunk) : 'Dragon defeated!', + health: bot.health?.toFixed(1) || '?', + hunger: bot.food ?? '?', + dimension: bot.game?.dimension || 'unknown', + position: pos ? `${Math.floor(pos.x)}, ${Math.floor(pos.y)}, ${Math.floor(pos.z)}` : 'unknown', + elapsedMin, + etaMin, + deaths: progress.state.stats.deaths, + totalRetries: progress.state.stats.totalRetries, + keyItems, + foodCount, + reportNumber: this._reportCount, + }; + } + + _formatConsole(s) { + const bar = '═'.repeat(50); + return [ + `╔${bar}╗`, + `║ PROGRESS REPORT #${s.reportNumber}`, + `╠${bar}╣`, + `║ Bot: ${s.botName}`, + `║ Chunk: ${s.chunkIndex}/${s.totalChunks} — ${s.chunk}`, + `║ Goal: ${s.chunkName}`, + `║ Health: ${s.health}/20 Hunger: ${s.hunger}/20 Food: ${s.foodCount}`, + `║ Dimension: ${s.dimension}`, + `║ Position: ${s.position}`, + `║ Elapsed: ${s.elapsedMin}min ETA chunk: ~${s.etaMin}min`, + `║ Deaths: ${s.deaths} Retries: ${s.totalRetries}`, + s.keyItems.length > 0 ? `║ Key items: ${s.keyItems.join(', ')}` : null, + `╚${bar}╝`, + ].filter(Boolean).join('\n'); + } + + async _sendWebhook(status) { + if (!this.webhookUrl) return; + try { + const embed = { + title: `🐉 Progress Report #${status.reportNumber}`, + color: status.chunk === 'COMPLETE' ? 0x00ff00 : 0x7289da, + fields: [ + { name: 'Chunk', value: `${status.chunkIndex}/${status.totalChunks} — ${status.chunk}`, inline: true }, + { name: 'Goal', value: status.chunkName, inline: false }, + { name: 'Health', value: `${status.health}/20`, inline: true }, + { name: 'Hunger', value: `${status.hunger}/20`, inline: true }, + { name: 'Food', value: `${status.foodCount}`, inline: true }, + { name: 'Dimension', value: status.dimension, inline: true }, + { name: 'Position', value: status.position, inline: true }, + { name: 'Elapsed', value: `${status.elapsedMin}min`, inline: true }, + { name: 'ETA', value: `~${status.etaMin}min`, inline: true }, + { name: 'Deaths', value: `${status.deaths}`, inline: true }, + { name: 'Retries', value: `${status.totalRetries}`, inline: true }, + ], + timestamp: new Date().toISOString(), + }; + + if (status.keyItems.length > 0) { + embed.fields.push({ name: 'Key Items', value: status.keyItems.join(', '), inline: false }); + } + + const payload = { + username: `${status.botName} Progress`, + embeds: [embed], + }; + + await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.warn('[ProgressReporter] Webhook send failed:', err.message); + } + } +} diff --git a/src/agent/library/skill_library.js b/src/agent/library/skill_library.js index 4470586f1..e5777f950 100644 --- a/src/agent/library/skill_library.js +++ b/src/agent/library/skill_library.js @@ -8,7 +8,7 @@ export class SkillLibrary { this.embedding_model = embedding_model; this.skill_docs_embeddings = {}; this.skill_docs = null; - this.always_show_skills = ['skills.placeBlock', 'skills.wait', 'skills.breakBlockAt'] + this.always_show_skills = ['skills.placeBlock', 'skills.wait', 'skills.breakBlockAt']; } async initSkillLibrary() { const skillDocs = getSkillDocs(); @@ -23,7 +23,7 @@ export class SkillLibrary { }); await Promise.all(embeddingPromises); } catch (error) { - console.warn('Error with embedding model, using word-overlap instead.'); + console.warn('Error with embedding model, using word-overlap instead.', error?.message || error); this.embedding_model = null; } } diff --git a/src/agent/library/skills.js b/src/agent/library/skills.js index 715455073..377a7af59 100644 --- a/src/agent/library/skills.js +++ b/src/agent/library/skills.js @@ -1,9 +1,13 @@ import * as mc from "../../utils/mcdata.js"; import * as world from "./world.js"; -import pf from 'mineflayer-pathfinder'; +import baritoneModule from '@miner-org/mineflayer-baritone'; +import pf from 'mineflayer-pathfinder'; // RC25: retained ONLY for Movements.safeToBreak() in collectBlock import Vec3 from 'vec3'; import settings from "../../../settings.js"; +// RC25: Baritone A* pathfinding goals (replaces mineflayer-pathfinder goals) +const baritoneGoals = baritoneModule.goals; + const blockPlaceDelay = settings.block_place_delay == null ? 0 : settings.block_place_delay; const useDelay = blockPlaceDelay > 0; @@ -16,7 +20,7 @@ async function autoLight(bot) { try { const pos = world.getPosition(bot); return await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); - } catch (err) {return false;} + } catch (_err) {return false;} } return false; } @@ -65,6 +69,10 @@ export async function craftRecipe(bot, itemName, num=1) { let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; if (hasTable) { let pos = world.getNearestFreeSpace(bot, 1, 6); + if (!pos) { + log(bot, `No free space to place crafting table.`); + return false; + } await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z); craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); if (craftingTable) { @@ -73,7 +81,7 @@ export async function craftRecipe(bot, itemName, num=1) { } } else { - log(bot, `Crafting ${itemName} requires a crafting table.`) + log(bot, `Crafting ${itemName} requires a crafting table.`); return false; } } @@ -91,6 +99,21 @@ export async function craftRecipe(bot, itemName, num=1) { if (craftingTable && bot.entity.position.distanceTo(craftingTable.position) > 4) { await goToNearestBlock(bot, 'crafting_table', 4, craftingTableRange); + // If still can't reach (e.g., table is above/below in unreachable spot), place one from inventory + if (bot.entity.position.distanceTo(craftingTable.position) > 4) { + let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; + if (hasTable) { + let pos = world.getNearestFreeSpace(bot, 1, 6); + if (pos) { + await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z); + craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); + if (craftingTable) { + recipes = bot.recipesFor(mc.getItemId(itemName), null, 1, craftingTable); + placedTable = true; + } + } + } + } } const recipe = recipes[0]; @@ -100,7 +123,15 @@ export async function craftRecipe(bot, itemName, num=1) { const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe); //Items required to use the recipe once. const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients); - await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable); + try { + await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable); + } catch (err) { + log(bot, `Failed to craft ${itemName}: ${err.message}`); + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1); + } + return false; + } if(craftLimit.num 4) { @@ -222,7 +253,7 @@ export async function smeltItem(bot, itemName, num=1) { } await furnace.putFuel(fuel.type, null, put_fuel); log(bot, `Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`); - console.log(`Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`) + console.log(`Added ${put_fuel} ${mc.getItemName(fuel.type)} to furnace fuel.`); } // put the items in the furnace await furnace.putInput(mc.getItemId(itemName), null, num); @@ -291,7 +322,7 @@ export async function clearNearestFurnace(bot) { console.log('clearing furnace...'); const furnace = await bot.openFurnace(furnaceBlock); - console.log('opened furnace...') + console.log('opened furnace...'); // take the items out of the furnace let smelted_item, intput_item, fuel_item; if (furnace.outputItem()) @@ -300,7 +331,7 @@ export async function clearNearestFurnace(bot) { intput_item = await furnace.takeInput(); if (furnace.fuelItem()) fuel_item = await furnace.takeFuel(); - console.log(smelted_item, intput_item, fuel_item) + console.log(smelted_item, intput_item, fuel_item); let smelted_name = smelted_item ? `${smelted_item.count} ${smelted_item.name}` : `0 smelted items`; let input_name = intput_item ? `${intput_item.count} ${intput_item.name}` : `0 input items`; let fuel_name = fuel_item ? `${fuel_item.count} ${fuel_item.name}` : `0 fuel items`; @@ -341,18 +372,31 @@ export async function attackEntity(bot, entity, kill=true) { * await skills.attackEntity(bot, entity); **/ + if (!entity || !bot.entities[entity.id]) { + log(bot, 'Entity no longer exists, skipping attack.'); + return false; + } + let pos = entity.position; - await equipHighestAttack(bot) + await equipHighestAttack(bot); if (!kill) { if (bot.entity.position.distanceTo(pos) > 5) { - console.log('moving to mob...') + console.log('moving to mob...'); await goToPosition(bot, pos.x, pos.y, pos.z); } - console.log('attacking mob...') + if (!bot.entities[entity.id]) { + log(bot, 'Entity despawned during approach, skipping attack.'); + return false; + } + console.log('attacking mob...'); await bot.attack(entity); } else { + if (!bot.entities[entity.id]) { + log(bot, 'Entity despawned before pvp start, skipping attack.'); + return false; + } bot.pvp.attack(entity); while (world.getNearbyEntities(bot, 24).includes(entity)) { await new Promise(resolve => setTimeout(resolve, 1000)); @@ -384,16 +428,17 @@ export async function defendSelf(bot, range=9) { await equipHighestAttack(bot); if (bot.entity.position.distanceTo(enemy.position) >= 4 && enemy.name !== 'creeper' && enemy.name !== 'phantom') { try { - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(new pf.goals.GoalFollow(enemy, 3.5), true); - } catch (err) {/* might error if entity dies, ignore */} + // RC25: Baritone chase to melee range + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(enemy.position, 3.5)); + } catch (_err) {/* might error if entity dies, ignore */} } if (bot.entity.position.distanceTo(enemy.position) <= 2) { try { - bot.pathfinder.setMovements(new pf.Movements(bot)); - let inverted_goal = new pf.goals.GoalInvert(new pf.goals.GoalFollow(enemy, 2)); - await bot.pathfinder.goto(inverted_goal, true); - } catch (err) {/* might error if entity dies, ignore */} + // RC25: Baritone retreat from too-close enemy + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalAvoid(enemy.position, 4, bot)); + } catch (_err) {/* might error if entity dies, ignore */} } bot.pvp.attack(enemy); attacked = true; @@ -412,7 +457,22 @@ export async function defendSelf(bot, range=9) { return attacked; } - +// RC24: Timeout helper for Paper server compatibility. +// bot.ashfinder.gotoSmart() and bot.dig() can hang indefinitely on Paper due +// to event handling differences. This races the operation against a timer and +// calls onTimeout (e.g. ashfinder.stop()) to cancel if it exceeds the limit. +function withTimeout(promise, ms, onTimeout) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + if (onTimeout) onTimeout(); + reject(new Error(`Timed out after ${ms}ms`)); + }, ms); + }) + ]).finally(() => clearTimeout(timer)); +} export async function collectBlock(bot, blockType, num=1, exclude=null) { /** @@ -429,6 +489,12 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { log(bot, `Invalid number of blocks to collect: ${num}.`); return false; } + + // RC18b: Pause unstuck mode during collection. Mining/navigating to blocks + // causes the bot to appear "stuck" (moving slowly or pausing while digging), + // triggering false unstuck interruptions that abort the collection. + bot.modes.pause('unstuck'); + let blocktypes = [blockType]; if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' || blockType === 'redstone') blocktypes.push(blockType+'_ore'); @@ -438,9 +504,20 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { blocktypes.push('grass_block'); if (blockType === 'cobblestone') blocktypes.push('stone'); + // RC13: If requesting any log type, also accept other log variants as fallback + // This prevents bots from starving for wood when oak isn't available but birch/spruce is + // RC27: Don't eagerly add all log types — only expand AFTER the primary type yields 0 results. + // Eager expansion causes the bot to target wrong biome logs (e.g., acacia underground) + // when the requested type (oak) exists on the surface nearby. + const isLogFallbackEligible = blockType.endsWith('_log'); + let logFallbackExpanded = false; const isLiquid = blockType === 'lava' || blockType === 'water'; let collected = 0; + let consecutiveFails = 0; + let interruptRetries = 0; // RC20: track retries from mode interruptions (e.g., self_defense) + const MAX_CONSECUTIVE_FAILS = 3; // break out after 3 consecutive failed attempts + const MAX_INTERRUPT_RETRIES = 8; // RC20: max total retries from combat/mode interruptions const movements = new pf.Movements(bot); movements.dontMineUnderFallingBlock = false; @@ -449,14 +526,31 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { // Blocks to ignore safety for, usually next to lava/water const unsafeBlocks = ['obsidian']; + // RC13 fix: Log/wood blocks have no gravity and are always safe to break. + // mineflayer-pathfinder's safeToBreak() is overly conservative for tree logs, + // causing searchForBlock to find logs that collectBlock then filters out. + const isNoGravityNaturalBlock = blockType.endsWith('_log') || blockType.endsWith('_wood') || + blockType.endsWith('_stem') || blockType === 'mushroom_stem' || + blockType.endsWith('leaves') || blockType.endsWith('_planks'); + for (let i=0; i { + // RC18 FIX: mineflayer's findBlocks has a section palette pre-filter + // (isBlockInSection) that creates blocks via Block.fromStateId() which + // sets position=null. We MUST check block name first and return true + // for palette pre-filter (position=null) to avoid skipping entire sections. + // Previously: `if (!block.position || !blocktypes.includes(block.name)) return false` + // caused ALL sections to be skipped because position is always null during palette check. if (!blocktypes.includes(block.name)) { return false; } + // If position is null, we're in the palette pre-filter — name matched, + // so tell mineflayer this section might contain our target block. + if (!block.position) return true; + if (exclude) { for (let position of exclude) { - if (block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { + if (position && block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { return false; } } @@ -466,17 +560,29 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { return block.metadata === 0; } - return movements.safeToBreak(block) || unsafeBlocks.includes(block.name); - }, 64, 1); + return movements.safeToBreak(block) || unsafeBlocks.includes(block.name) || isNoGravityNaturalBlock; + }, 128, 1); // RC17: Increased from 64 to 128 to match searchForBlock range if (blocks.length === 0) { + // RC27: If no primary log type found, expand to all log variants as fallback + if (isLogFallbackEligible && !logFallbackExpanded) { + logFallbackExpanded = true; + const allLogs = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + for (const logType of allLogs) { + if (!blocktypes.includes(logType)) blocktypes.push(logType); + } + console.log(`[RC27] No ${blockType} found, expanding search to all log types`); + i--; // retry this iteration with expanded types + continue; + } if (collected === 0) - log(bot, `No ${blockType} nearby to collect.`); + log(bot, `No ${blockType} found within 128 blocks. Gathering system is working fine — this area simply has none. Use !explore(200) to travel far enough to find new resources, then retry. Do NOT use !searchForBlock — explore first to load fresh chunks.`); else - log(bot, `No more ${blockType} nearby to collect.`); + log(bot, `No more ${blockType} nearby to collect. Successfully collected ${collected} so far.`); break; } const block = blocks[0]; + await bot.tool.equipForBlock(block); if (isLiquid) { const bucket = bot.inventory.items().find(item => item.name === 'bucket'); @@ -486,37 +592,161 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { } await bot.equip(bucket, 'hand'); } - const itemId = bot.heldItem ? bot.heldItem.type : null + const itemId = bot.heldItem ? bot.heldItem.type : null; if (!block.canHarvest(itemId)) { log(bot, `Don't have right tools to harvest ${blockType}.`); return false; } try { let success = false; + const invBefore = world.getInventoryCounts(bot); if (isLiquid) { success = await useToolOnBlock(bot, 'bucket', block); } else if (mc.mustCollectManually(blockType)) { - await goToPosition(bot, block.position.x, block.position.y, block.position.z, 2); - await bot.dig(block); - await pickupNearbyItems(bot); - success = true; + // RC24c: Distance-adaptive timeout for manual collection + const dist = bot.entity.position.distanceTo(block.position); + const navTimeout = Math.max(20000, Math.round(dist * 1000) + 10000); + console.log(`[RC24] Manual-collect ${blockType} at (${block.position.x}, ${block.position.y}, ${block.position.z}) dist=${Math.round(dist)} timeout=${navTimeout}ms`); + await withTimeout( + goToPosition(bot, block.position.x, block.position.y, block.position.z, 2), + navTimeout, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // RC26: Re-fetch block after navigation (see RC26 comment above) + const freshBlockManual = bot.blockAt(block.position); + if (!freshBlockManual || !blocktypes.includes(freshBlockManual.name)) { + console.log(`[RC26] Manual block at (${block.position.x}, ${block.position.y}, ${block.position.z}) changed to ${freshBlockManual?.name ?? 'null'}, skipping`); + if (!exclude) exclude = []; + exclude.push(block.position); + i--; // retry this slot with a different block + continue; + } + console.log(`[RC24] Digging ${freshBlockManual.name} (manual)`); + await withTimeout( + bot.dig(freshBlockManual), + 10000, + () => { try { bot.stopDigging(); } catch(_e) {} } + ); + await new Promise(r => setTimeout(r, 300)); + await withTimeout( + pickupNearbyItems(bot), + 8000, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // Verify items actually entered inventory + const invAfter = world.getInventoryCounts(bot); + const totalBefore = Object.values(invBefore).reduce((a, b) => a + b, 0); + const totalAfter = Object.values(invAfter).reduce((a, b) => a + b, 0); + success = totalAfter > totalBefore; + console.log(`[RC24] Manual-collect result: success=${success}`); } else { - await bot.collectBlock.collect(block); - success = true; + // RC24: Manual dig with timeout protection for Paper servers. + // bot.collectBlock.collect() hangs indefinitely on Paper due to event + // handling differences. Manual dig also needs timeouts since + // pathfinder.goto() has no built-in timeout. + try { + // RC24c: Scale nav timeout by distance — bot walks ~4 blocks/sec, + // plus overhead for path computation and terrain navigation. + const dist = bot.entity.position.distanceTo(block.position); + const navTimeout = Math.max(20000, Math.round(dist * 1000) + 10000); + console.log(`[RC24] Navigating to ${blockType} at (${block.position.x}, ${block.position.y}, ${block.position.z}) dist=${Math.round(dist)} timeout=${navTimeout}ms`); + await withTimeout( + goToPosition(bot, block.position.x, block.position.y, block.position.z, 2), + navTimeout, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + // RC26: Re-fetch block at position after navigation. + // The original block reference can go stale if chunks + // unloaded/reloaded during pathfinding, causing bot.dig() + // to silently no-op (items 0→0). + const freshBlock = bot.blockAt(block.position); + if (!freshBlock || !blocktypes.includes(freshBlock.name)) { + console.log(`[RC26] Block at (${block.position.x}, ${block.position.y}, ${block.position.z}) changed to ${freshBlock?.name ?? 'null'}, skipping`); + if (!exclude) exclude = []; + exclude.push(block.position); + i--; // retry this slot with a different block + continue; + } + console.log(`[RC24] Digging ${freshBlock.name}`); + await withTimeout( + bot.dig(freshBlock), + 10000, + () => { try { bot.stopDigging(); } catch(_e) {} } + ); + console.log(`[RC24] Picking up items`); + await new Promise(r => setTimeout(r, 300)); + await withTimeout( + pickupNearbyItems(bot), + 8000, + () => { try { bot.ashfinder.stop(); } catch(_e) {} } + ); + const invAfter = world.getInventoryCounts(bot); + const totalBefore = Object.values(invBefore).reduce((a, b) => a + b, 0); + const totalAfter = Object.values(invAfter).reduce((a, b) => a + b, 0); + success = totalAfter > totalBefore; + console.log(`[RC24] Result: success=${success}, items ${totalBefore}→${totalAfter}`); + } catch (_digErr) { + // RC24b: Re-throw "aborted" errors so the outer RC20 handler + // can retry them (self_defense/combat interruption recovery) + if (_digErr.message && _digErr.message.includes('aborted')) { + throw _digErr; + } + console.log(`[RC24] Failed for ${blockType}: ${_digErr.message}`); + try { bot.ashfinder.stop(); } catch(_e) {} + } + if (!success) { + if (!exclude) exclude = []; + exclude.push(block.position); + } } - if (success) + if (success) { + collected++; + consecutiveFails = 0; + } else { + + // Exclude this position so we don't keep retrying the same unreachable block + if (block && block.position) { + if (!exclude) exclude = []; + exclude.push(block.position); + } + consecutiveFails++; + if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + log(bot, `Failed to collect ${blockType} ${MAX_CONSECUTIVE_FAILS} times in a row. Blocks may be unreachable. Use !explore(200) to travel to a completely new area, then retry.`); + break; + } + } await autoLight(bot); } catch (err) { + if (err.name === 'NoChests') { log(bot, `Failed to collect ${blockType}: Inventory full, no place to deposit.`); break; } + // RC20: "Digging aborted" comes from self_defense/self_preservation interrupting the dig. + // Don't count this as a real failure — wait for the mode to finish, then retry the same block. + else if (err.message && err.message.includes('aborted') && interruptRetries < MAX_INTERRUPT_RETRIES) { + interruptRetries++; + console.log(`[RC20] Dig interrupted (retry ${interruptRetries}/${MAX_INTERRUPT_RETRIES}), waiting for mode to finish...`); + await new Promise(r => setTimeout(r, 2000)); // Wait 2s for combat/mode to finish + i--; // Retry the same block on next loop iteration + continue; + } else { log(bot, `Failed to collect ${blockType}: ${err}.`); + // Exclude this block position so we don't retry it + if (block && block.position) { + if (!exclude) exclude = []; + exclude.push(block.position); + } + consecutiveFails++; + if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) { + log(bot, `Failed ${MAX_CONSECUTIVE_FAILS} times in a row. Blocks may be unreachable. Call !getDiamondPickaxe — it handles relocation and tool progression automatically.`); + break; + } continue; } } @@ -524,7 +754,14 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { if (bot.interrupt_code) break; } - log(bot, `Collected ${collected} ${blockType}.`); + // RC18b: Resume unstuck mode after collection is done + bot.modes.unpause('unstuck'); + + if (collected === 0 && num > 0) { + log(bot, `Collected 0 ${blockType}. No ${blockType} found in this area. Call !getDiamondPickaxe — it handles wood collection and tool progression automatically with built-in relocation. Do NOT call !explore or !collectBlocks manually.`); + } else { + log(bot, `Collected ${collected} ${blockType}.`); + } return collected > 0; } @@ -536,25 +773,36 @@ export async function pickupNearbyItems(bot) { * @example * await skills.pickupNearbyItems(bot); **/ - const distance = 8; + const distance = 10; const getNearestItem = bot => bot.nearestEntity(entity => entity.name === 'item' && bot.entity.position.distanceTo(entity.position) < distance); let nearestItem = getNearestItem(bot); let pickedUp = 0; - while (nearestItem) { - let movements = new pf.Movements(bot); - movements.canDig = false; - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalFollow(nearestItem, 1)); - await new Promise(resolve => setTimeout(resolve, 200)); - let prev = nearestItem; - nearestItem = getNearestItem(bot); - if (prev === nearestItem) { - break; + const maxAttempts = 10; + let attempts = 0; + while (nearestItem && attempts < maxAttempts) { + attempts++; + const invBefore = bot.inventory.items().reduce((sum, item) => sum + item.count, 0); + // RC25: Navigate to item without breaking blocks + const prevBreak = bot.ashfinder.config.breakBlocks; + bot.ashfinder.config.breakBlocks = false; + try { + await goToGoal(bot, new baritoneGoals.GoalNear(nearestItem.position, 1)); + } finally { + bot.ashfinder.config.breakBlocks = prevBreak; + } + // Wait for item pickup with increasing delays + for (let wait = 0; wait < 5; wait++) { + await new Promise(resolve => setTimeout(resolve, 200)); + const invAfter = bot.inventory.items().reduce((sum, item) => sum + item.count, 0); + if (invAfter > invBefore) { + pickedUp += (invAfter - invBefore); + break; + } } - pickedUp++; + nearestItem = getNearestItem(bot); } log(bot, `Picked up ${pickedUp} items.`); - return true; + return pickedUp > 0; } @@ -582,16 +830,18 @@ export async function breakBlockAt(bot, x, y, z) { } if (bot.entity.position.distanceTo(block.position) > 4.5) { - let pos = block.position; - let movements = new pf.Movements(bot); - movements.canPlaceOn = false; - movements.allow1by1towers = false; - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Navigate to block with baritone (no placing for breakBlockAt) + const prevPlace = bot.ashfinder.config.placeBlocks; + bot.ashfinder.config.placeBlocks = false; + try { + await goToGoal(bot, new baritoneGoals.GoalNear(block.position, 4)); + } finally { + bot.ashfinder.config.placeBlocks = prevPlace; + } } if (bot.game.gameMode !== 'creative') { await bot.tool.equipForBlock(block); - const itemId = bot.heldItem ? bot.heldItem.type : null + const itemId = bot.heldItem ? bot.heldItem.type : null; if (!block.canHarvest(itemId)) { log(bot, `Don't have right tools to break ${block.name}.`); return false; @@ -723,7 +973,7 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont 'south': Vec3(0, 0, 1), 'east': Vec3(1, 0, 0), 'west': Vec3(-1, 0, 0), - } + }; let dirs = []; if (placeOn === 'side') { dirs.push(dir_map['north'], dir_map['south'], dir_map['east'], dir_map['west']); @@ -755,18 +1005,13 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont const dont_move_for = ['torch', 'redstone_torch', 'redstone', 'lever', 'button', 'rail', 'detector_rail', 'powered_rail', 'activator_rail', 'tripwire_hook', 'tripwire', 'water_bucket', 'string']; if (!dont_move_for.includes(item_name) && (pos.distanceTo(targetBlock.position) < 1.1 || pos_above.distanceTo(targetBlock.position) < 1.1)) { - // too close - let goal = new pf.goals.GoalNear(targetBlock.position.x, targetBlock.position.y, targetBlock.position.z, 2); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(inverted_goal); + // RC25: Too close — move away using baritone GoalAvoid + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalAvoid(targetBlock.position, 2, bot)); } if (bot.entity.position.distanceTo(targetBlock.position) > 4.5) { - // too far - let pos = targetBlock.position; - let movements = new pf.Movements(bot); - bot.pathfinder.setMovements(movements); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Too far — navigate closer with baritone + await goToGoal(bot, new baritoneGoals.GoalNear(targetBlock.position, 4)); } // will throw error if an entity is in the way, and sometimes even if the block was placed @@ -782,7 +1027,7 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont await new Promise(resolve => setTimeout(resolve, 200)); return true; } - } catch (err) { + } catch (_err) { log(bot, `Failed to place ${blockType} at ${target_dest}.`); return false; } @@ -1010,7 +1255,7 @@ export async function giveToPlayer(bot, itemType, username, num=1) { log(bot, `You cannot give items to yourself.`); return false; } - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) { log(bot, `Could not find ${username}.`); return false; @@ -1069,45 +1314,109 @@ export async function giveToPlayer(bot, itemType, username, num=1) { export async function goToGoal(bot, goal) { /** - * Navigate to the given goal. Use doors and attempt minimally destructive movements. + * RC25b: Navigate to the given goal using Baritone A* pathfinding. + * Uses bot.ashfinder.gotoSmart() which auto-chooses between direct A* + * and waypoint navigation based on distance. + * + * CRITICAL: gotoSmart() awaits the PathExecutor's completionPromise, but + * executor.stop() only rejects currentPromise — NOT completionPromise. + * This means gotoSmart() hangs forever if stopped externally. We MUST wrap + * it with Promise.race + timeout. After timeout, we check proximity to goal + * since Paper server position corrections prevent the executor's tight reach + * check (0.35 blocks) from passing even when the bot is very close. + * * @param {MinecraftBot} bot, reference to the minecraft bot. - * @param {pf.goals.Goal} goal, the goal to navigate to. + * @param {Goal} goal, a baritone goal to navigate to. **/ - const nonDestructiveMovements = new pf.Movements(bot); - const dontBreakBlocks = ['glass', 'glass_pane']; - for (let block of dontBreakBlocks) { - nonDestructiveMovements.blocksCantBreak.add(mc.getBlockId(block)); + // Ensure any previous navigation is stopped before starting new one + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + + // Add glass types to blocksToAvoid so baritone prefers non-destructive paths + const blocksToAvoid = bot.ashfinder.config.blocksToAvoid || []; + const glassList = ['glass', 'glass_pane']; + const addedBlocks = []; + for (const name of glassList) { + if (!blocksToAvoid.includes(name)) { + blocksToAvoid.push(name); + addedBlocks.push(name); + } } - nonDestructiveMovements.placeCost = 2; - nonDestructiveMovements.digCost = 10; - const destructiveMovements = new pf.Movements(bot); + // RC25b: Calculate timeout based on distance to goal + // Paper server's movement corrections make executor reach checks unreliable, + // so we need our own timeout to prevent infinite hangs. + let navTimeout = 30000; // default 30s + try { + const goalPos = goal.pos || (goal.getPosition ? goal.getPosition() : null); + if (goalPos) { + const dist = bot.entity.position.distanceTo(goalPos); + navTimeout = Math.max(12000, Math.round(dist * 1200) + 5000); + } + } catch (_) {} - let final_movements = destructiveMovements; + const doorCheckInterval = startDoorInterval(bot); + let timeoutId = null; - const pathfind_timeout = 1000; - if (await bot.pathfinder.getPathTo(nonDestructiveMovements, goal, pathfind_timeout).status === 'success') { - final_movements = nonDestructiveMovements; - log(bot, `Found non-destructive path.`); - } - else if (await bot.pathfinder.getPathTo(destructiveMovements, goal, pathfind_timeout).status === 'success') { - log(bot, `Found destructive path.`); - } - else { - log(bot, `Path not found, but attempting to navigate anyway using destructive movements.`); - } + const restoreConfig = () => { + clearInterval(doorCheckInterval); + if (timeoutId) clearTimeout(timeoutId); + for (const name of addedBlocks) { + const idx = blocksToAvoid.indexOf(name); + if (idx !== -1) blocksToAvoid.splice(idx, 1); + } + }; - const doorCheckInterval = startDoorInterval(bot); + // RC25b: Helper — check if bot is close enough to goal to count as success. + // Paper's position corrections prevent the executor from passing its tight + // reach threshold (0.35 blocks), but the bot is often within 2-4 blocks. + // Use generous threshold: goal.distance + 2 (or 5 as fallback). + const isCloseEnough = () => { + try { + const goalPos = goal.pos || (goal.getPosition ? goal.getPosition() : null); + if (!goalPos) return false; + const dist = bot.entity.position.distanceTo(goalPos); + const threshold = (goal.distance || 4) + 2; + return dist <= threshold; + } catch (_e) { return false; } + }; - bot.pathfinder.setMovements(final_movements); try { - await bot.pathfinder.goto(goal); - clearInterval(doorCheckInterval); - return true; + // RC25b: Wrap gotoSmart with Promise.race because the PathExecutor's + // stop() doesn't reject completionPromise, causing gotoSmart to hang forever. + const result = await Promise.race([ + bot.ashfinder.gotoSmart(goal).catch(err => { + // RC27: Baritone executor.js:185 can crash with "Cannot read properties + // of undefined (reading 'length')" when this.path becomes null mid-execution. + // Catch this here so it doesn't crash the entire process. + if (err?.message?.includes('Cannot read properties of undefined')) { + console.warn(`[RC27] Baritone internal error (non-fatal): ${err.message}`); + return { status: 'error', error: err }; + } + throw err; + }), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + try { bot.ashfinder.stop(); } catch (_) {} + resolve({ status: 'timeout' }); + }, navTimeout); + }) + ]); + + restoreConfig(); + + // Check proximity first — if close enough, it's success regardless of executor status + if (isCloseEnough()) return true; + + if (result && result.status === 'success') return true; + if (result && result.status === 'timeout') { + throw new Error(`Navigation timed out after ${Math.round(navTimeout/1000)}s`); + } + throw new Error(result?.error?.message || 'Navigation failed'); } catch (err) { - clearInterval(doorCheckInterval); - // we need to catch so we can clean up the door check interval, then rethrow the error + restoreConfig(); + // Even on error, if we're close enough, consider it success + if (isCloseEnough()) return true; throw err; } } @@ -1116,6 +1425,9 @@ let _doorInterval = null; function startDoorInterval(bot) { /** * Start helper interval that opens nearby doors if the bot is stuck. + * Phase 1 (1.2s stuck): Try opening doors, fence gates, trapdoors. + * Phase 2 (8s stuck): Last resort — temporarily enable breakBlocks so + * Baritone can dig through the obstacle, then disable again. * @param {MinecraftBot} bot, reference to the minecraft bot. * @returns {number} the interval id. **/ @@ -1125,17 +1437,27 @@ function startDoorInterval(bot) { let prev_pos = bot.entity.position.clone(); let prev_check = Date.now(); let stuck_time = 0; + let doorAttempted = false; // track whether we already tried doors this stuck episode + const DOOR_THRESHOLD = 1200; // ms — try doors first + const BREAK_THRESHOLD = 8000; // ms — last resort: enable block breaking const doorCheckInterval = setInterval(() => { const now = Date.now(); if (bot.entity.position.distanceTo(prev_pos) >= 0.1) { stuck_time = 0; + doorAttempted = false; + // RC26: If we previously enabled breakBlocks as last resort, disable it again + if (bot.ashfinder && bot.ashfinder.config.breakBlocks) { + bot.ashfinder.config.breakBlocks = false; + } } else { stuck_time += now - prev_check; } - - if (stuck_time > 1200) { + + // Phase 1: Open doors / fence gates / trapdoors + if (stuck_time > DOOR_THRESHOLD && !doorAttempted) { + doorAttempted = true; // shuffle positions so we're not always opening the same door const positions = [ bot.entity.position.clone(), @@ -1143,7 +1465,7 @@ function startDoorInterval(bot) { bot.entity.position.offset(0, 0, -1), bot.entity.position.offset(1, 0, 0), bot.entity.position.offset(-1, 0, 0), - ] + ]; let elevated_positions = positions.map(position => position.offset(0, 1, 0)); positions.push(...elevated_positions); positions.push(bot.entity.position.offset(0, 2, 0)); // above head @@ -1169,8 +1491,18 @@ function startDoorInterval(bot) { break; } } + } + + // Phase 2: Last resort — enable block breaking temporarily + if (stuck_time > BREAK_THRESHOLD) { + if (bot.ashfinder && !bot.ashfinder.config.breakBlocks) { + console.log('[RC26] Stuck >8s after door attempts — enabling breakBlocks as last resort'); + bot.ashfinder.config.breakBlocks = true; + } stuck_time = 0; + doorAttempted = false; } + prev_pos = bot.entity.position.clone(); prev_check = now; }, 200); @@ -1201,22 +1533,43 @@ export async function goToPosition(bot, x, y, z, min_distance=2) { return true; } + let lastDigTarget = null; + let unharvestableTicks = 0; const checkDigProgress = () => { if (bot.targetDigBlock) { const targetBlock = bot.targetDigBlock; const itemId = bot.heldItem ? bot.heldItem.type : null; if (!targetBlock.canHarvest(itemId)) { - log(bot, `Pathfinding stopped: Cannot break ${targetBlock.name} with current tools.`); - bot.pathfinder.stop(); - bot.stopDigging(); + // RC27: Only abort after 2 consecutive checks on the same unharvstable block. + // Single transient ticks happen when the pathfinder equips tools mid-dig. + if (lastDigTarget && lastDigTarget.x === targetBlock.position.x && + lastDigTarget.y === targetBlock.position.y && + lastDigTarget.z === targetBlock.position.z) { + unharvestableTicks++; + } else { + lastDigTarget = targetBlock.position.clone(); + unharvestableTicks = 1; + } + if (unharvestableTicks >= 2) { + log(bot, `Pathfinding stopped: Cannot break ${targetBlock.name} with current tools.`); + bot.ashfinder.stop(); + bot.stopDigging(); + unharvestableTicks = 0; + } + } else { + unharvestableTicks = 0; + lastDigTarget = null; } + } else { + unharvestableTicks = 0; + lastDigTarget = null; } }; const progressInterval = setInterval(checkDigProgress, 1000); try { - await goToGoal(bot, new pf.goals.GoalNear(x, y, z, min_distance)); + await goToGoal(bot, new baritoneGoals.GoalNear(new Vec3(x, y, z), min_distance)); clearInterval(progressInterval); const distance = bot.entity.position.distanceTo(new Vec3(x, y, z)); if (distance <= min_distance+1) { @@ -1261,13 +1614,32 @@ export async function goToNearestBlock(bot, blockType, min_distance=2, range=64 } else { block = world.getNearestBlock(bot, blockType, range); + // RC13: If searching for a log type and none found, try any log type as fallback + if (!block && blockType.endsWith('_log')) { + const allLogs = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + for (const logType of allLogs) { + if (logType !== blockType) { + block = world.getNearestBlock(bot, logType, range); + if (block) { + log(bot, `No ${blockType} found, but found ${logType} instead.`); + break; + } + } + } + } } if (!block) { log(bot, `Could not find any ${blockType} in ${range} blocks.`); return false; } log(bot, `Found ${blockType} at ${block.position}. Navigating...`); - await goToPosition(bot, block.position.x, block.position.y, block.position.z, min_distance); + // RC17: Pause unstuck during navigation to prevent false stuck detection + bot.modes.pause('unstuck'); + try { + await goToPosition(bot, block.position.x, block.position.y, block.position.z, min_distance); + } finally { + bot.modes.unpause('unstuck'); + } return true; } @@ -1313,16 +1685,17 @@ export async function goToPlayer(bot, username, distance=3) { bot.modes.pause('self_defense'); bot.modes.pause('cowardice'); - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) { log(bot, `Could not find ${username}.`); return false; } distance = Math.max(distance, 0.5); - const goal = new pf.goals.GoalFollow(player, distance); + // RC25: Baritone — use GoalNear with player's current position instead of GoalFollow + const goal = new baritoneGoals.GoalNear(player.position, distance); - await goToGoal(bot, goal, true); + await goToGoal(bot, goal); log(bot, `You have reached ${username}.`); } @@ -1337,16 +1710,14 @@ export async function followPlayer(bot, username, distance=4) { * @example * await skills.followPlayer(bot, "player"); **/ - let player = bot.players[username].entity + let player = bot.players[username].entity; if (!player) return false; - const move = new pf.Movements(bot); - move.digCost = 10; - bot.pathfinder.setMovements(move); + // RC25: Baritone followEntity for continuous following + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + bot.ashfinder.followEntity(player, { distance: distance, updateInterval: 500 }); let doorCheckInterval = startDoorInterval(bot); - - bot.pathfinder.setGoal(new pf.goals.GoalFollow(player, distance), true); log(bot, `You are now actively following player ${username}.`); @@ -1389,6 +1760,7 @@ export async function followPlayer(bot, username, distance=4) { bot.modes.unpause('elbow_room'); } } + bot.ashfinder.stopFollowing(); // RC25: stop baritone entity following clearInterval(doorCheckInterval); return true; } @@ -1404,15 +1776,14 @@ export async function moveAway(bot, distance) { * await skills.moveAway(bot, 8); **/ const pos = bot.entity.position; - let goal = new pf.goals.GoalNear(pos.x, pos.y, pos.z, distance); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); + // RC25: Baritone GoalAvoid moves away from a position + let avoidGoal = new baritoneGoals.GoalAvoid(pos, distance, bot); if (bot.modes.isOn('cheat')) { - const move = new pf.Movements(bot); - const path = await bot.pathfinder.getPathTo(move, inverted_goal, 10000); - let last_move = path.path[path.path.length-1]; - if (last_move) { + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + const pathResult = await bot.ashfinder.generatePath(avoidGoal); + if (pathResult && pathResult.path && pathResult.path.length > 0) { + let last_move = pathResult.path[pathResult.path.length-1]; let x = Math.floor(last_move.x); let y = Math.floor(last_move.y); let z = Math.floor(last_move.z); @@ -1421,7 +1792,7 @@ export async function moveAway(bot, distance) { } } - await goToGoal(bot, inverted_goal); + await goToGoal(bot, avoidGoal); let new_pos = bot.entity.position; log(bot, `Moved away from ${pos.floored()} to ${new_pos.floored()}.`); return true; @@ -1435,11 +1806,168 @@ export async function moveAwayFromEntity(bot, entity, distance=16) { * @param {number} distance, the distance to move away. * @returns {Promise} true if the bot moved away, false otherwise. **/ - let goal = new pf.goals.GoalFollow(entity, distance); - let inverted_goal = new pf.goals.GoalInvert(goal); - bot.pathfinder.setMovements(new pf.Movements(bot)); - await bot.pathfinder.goto(inverted_goal); - return true; + // RC25: Baritone GoalAvoid to move away from entity + let avoidGoal = new baritoneGoals.GoalAvoid(entity.position, distance, bot); + await goToGoal(bot, avoidGoal); +} + + +export async function explore(bot, distance=40) { + /** + * Move to a random position to explore new terrain and find fresh resources. + * Uses multi-hop navigation for distances > 60 blocks to avoid pathfinder timeouts. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} distance, the approximate distance to explore. Defaults to 40. + * @returns {Promise} true if exploration succeeded, false otherwise. + * @example + * await skills.explore(bot, 200); + **/ + const startPos = bot.entity.position.clone(); + let angle = Math.random() * 2 * Math.PI; // RC17b: let (mutable) — water avoidance changes direction + const HOP_SIZE = 50; // max distance per pathfinding hop + const numHops = Math.max(1, Math.ceil(distance / HOP_SIZE)); + + // Pause unstuck mode during multi-hop — pathfinding between hops can trigger false stuck detection + if (numHops > 1) bot.modes.pause('unstuck'); + + log(bot, `Exploring ${distance} blocks (${numHops} hops)...`); + + let totalMoved = 0; + let consecutiveFails = 0; + + for (let hop = 0; hop < numHops; hop++) { + if (bot.interrupt_code) break; + + const currentPos = bot.entity.position; + const hopDist = Math.min(HOP_SIZE, distance - totalMoved); + + // Add slight random angle variation per hop to avoid obstacles + const hopAngle = angle + (Math.random() - 0.5) * 0.4; + const tx = Math.floor(currentPos.x + hopDist * Math.cos(hopAngle)); + const tz = Math.floor(currentPos.z + hopDist * Math.sin(hopAngle)); + + try { + await goToPosition(bot, tx, currentPos.y, tz, 3); + const moved = currentPos.distanceTo(bot.entity.position); + totalMoved += moved; + consecutiveFails = 0; + + // RC17b: Water/ocean detection — if we dropped below sea level (y<=62) + // or are standing on water, the path is heading toward ocean. Change direction. + // Note: y=63 is sea level and many valid forest areas exist there, so only trigger at y<=62. + const postHopY = bot.entity.position.y; + const feetBlock = bot.blockAt(bot.entity.position.offset(0, -0.5, 0)); + const isNearWater = (feetBlock && (feetBlock.name === 'water' || feetBlock.name === 'kelp' || feetBlock.name === 'seagrass')) || postHopY <= 62; + + if (isNearWater && hop < numHops - 1) { + log(bot, `Heading toward water (y=${Math.round(postHopY)}), changing direction...`); + // Reverse + perpendicular to move away from water + angle = angle + Math.PI * 0.75 + (Math.random() - 0.5) * 0.5; + } + + if (moved < 5 && hop > 0) { + // Barely moved — try a perpendicular direction + const perpAngle = angle + (Math.random() > 0.5 ? Math.PI/2 : -Math.PI/2); + const px = Math.floor(bot.entity.position.x + hopDist * Math.cos(perpAngle)); + const pz = Math.floor(bot.entity.position.z + hopDist * Math.sin(perpAngle)); + log(bot, `Path blocked, trying perpendicular direction...`); + try { + await goToPosition(bot, px, bot.entity.position.y, pz, 3); + totalMoved += bot.entity.position.distanceTo(currentPos); + } catch (_e) { /* continue with next hop */ } + } + } catch (_err) { + consecutiveFails++; + if (consecutiveFails >= 2) { + // RC29b: Terrain escape — when consecutive hops fail (ocean/cliff blocking), + // try going to the surface first then attempt an uphill path before giving up. + log(bot, `Exploration stuck after ${Math.round(totalMoved)} blocks. Trying terrain escape (surface + uphill)...`); + try { + // Step 1: Climb to surface to clear water/cliff terrain + await goToSurface(bot); + // Step 2: Try a new completely random direction from surface + const escapeAngle = Math.random() * 2 * Math.PI; + const ex = Math.floor(bot.entity.position.x + hopDist * Math.cos(escapeAngle)); + const ez = Math.floor(bot.entity.position.z + hopDist * Math.sin(escapeAngle)); + const surfY2 = Math.floor(bot.entity.position.y); + await goToPosition(bot, ex, surfY2, ez, 3); + totalMoved += currentPos.distanceTo(bot.entity.position); + consecutiveFails = 0; + angle = escapeAngle; // continue in the escape direction + } catch (_escape) { + log(bot, `Terrain escape failed. Explored ${Math.round(totalMoved)} blocks total.`); + break; + } + continue; + } + // Try perpendicular direction on first failure + const perpAngle = angle + (Math.random() > 0.5 ? Math.PI/2 : -Math.PI/2); + const px = Math.floor(currentPos.x + hopDist * Math.cos(perpAngle)); + const pz = Math.floor(currentPos.z + hopDist * Math.sin(perpAngle)); + try { + await goToPosition(bot, px, currentPos.y, pz, 3); + totalMoved += currentPos.distanceTo(bot.entity.position); + consecutiveFails = 0; + } catch (_e2) { /* will try next hop */ } + } + } + + let finalPos = bot.entity.position; + let directDistance = startPos.distanceTo(finalPos); + + // RC17: Smart explore — check if we landed near resources (any log type). + // If not, auto-retry in different directions before returning to the LLM. + const LOG_TYPES = ['oak_log', 'birch_log', 'spruce_log', 'jungle_log', 'acacia_log', 'dark_oak_log', 'mangrove_log', 'cherry_log']; + const MAX_DIRECTION_RETRIES = 2; + + for (let retry = 0; retry < MAX_DIRECTION_RETRIES; retry++) { + if (bot.interrupt_code) break; + + // Check for any log blocks within 128 blocks + let hasLogs = false; + for (const logType of LOG_TYPES) { + const logBlock = world.getNearestBlock(bot, logType, 128); + if (logBlock) { + hasLogs = true; + break; + } + } + + if (hasLogs) break; // Found logs nearby, good landing spot + + // Also check if we're in water (bad landing) + const feetBlock = bot.blockAt(bot.entity.position.offset(0, -1, 0)); + const isInWater = feetBlock && (feetBlock.name === 'water' || feetBlock.name === 'ice' || feetBlock.name === 'blue_ice'); + + if (!hasLogs) { + log(bot, `No trees in this area${isInWater ? ' (landed in water)' : ''}. Trying a different direction (attempt ${retry + 1}/${MAX_DIRECTION_RETRIES})...`); + + // Pick a direction roughly perpendicular to our original angle + const retryAngle = angle + Math.PI / 2 + (retry * Math.PI / 3) + (Math.random() - 0.5) * 0.5; + const retryDist = Math.min(100, distance); + const retryHops = Math.max(1, Math.ceil(retryDist / HOP_SIZE)); + + for (let hop = 0; hop < retryHops; hop++) { + if (bot.interrupt_code) break; + const cp = bot.entity.position; + const hd = Math.min(HOP_SIZE, retryDist - (hop * HOP_SIZE)); + const tx = Math.floor(cp.x + hd * Math.cos(retryAngle)); + const tz = Math.floor(cp.z + hd * Math.sin(retryAngle)); + try { + await goToPosition(bot, tx, cp.y, tz, 3); + } catch (_e) { break; } + } + + finalPos = bot.entity.position; + directDistance = startPos.distanceTo(finalPos); + } + } + + // Resume unstuck mode + if (numHops > 1) bot.modes.unpause('unstuck'); + + log(bot, `Explored ${Math.round(directDistance)} blocks to (${Math.floor(finalPos.x)}, ${Math.floor(finalPos.y)}, ${Math.floor(finalPos.z)}). New chunks loaded — try gathering here.`); + return directDistance > 10; } export async function avoidEnemies(bot, distance=16) { @@ -1454,10 +1982,12 @@ export async function avoidEnemies(bot, distance=16) { bot.modes.pause('self_preservation'); // prevents damage-on-low-health from interrupting the bot let enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), distance); while (enemy) { - const follow = new pf.goals.GoalFollow(enemy, distance+1); // move a little further away - const inverted_goal = new pf.goals.GoalInvert(follow); - bot.pathfinder.setMovements(new pf.Movements(bot)); - bot.pathfinder.setGoal(inverted_goal, true); + // RC25: Baritone GoalAvoid to flee from enemy + const avoidGoal = new baritoneGoals.GoalAvoid(enemy.position, distance + 1, bot); + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + try { + await bot.ashfinder.gotoSmart(avoidGoal); + } catch (_e) { /* best-effort flee */ } await new Promise(resolve => setTimeout(resolve, 500)); enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), distance); if (bot.interrupt_code) { @@ -1467,7 +1997,7 @@ export async function avoidEnemies(bot, distance=16) { await attackEntity(bot, enemy, false); } } - bot.pathfinder.stop(); + bot.ashfinder.stop(); log(bot, `Moved ${distance} away from enemies.`); return true; } @@ -1520,24 +2050,43 @@ export async function useDoor(bot, door_pos=null) { return false; } - bot.pathfinder.setGoal(new pf.goals.GoalNear(door_pos.x, door_pos.y, door_pos.z, 1)); - await new Promise((resolve) => setTimeout(resolve, 1000)); - while (bot.pathfinder.isMoving()) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - let door_block = bot.blockAt(door_pos); - await bot.lookAt(door_pos); - if (!door_block._properties.open) - await bot.activateBlock(door_block); - - bot.setControlState("forward", true); - await new Promise((resolve) => setTimeout(resolve, 600)); - bot.setControlState("forward", false); - await bot.activateBlock(door_block); + try { + // RC25: Baritone gotoSmart replaces pathfinder.setGoal + isMoving poll + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(door_pos, 1)); + + let door_block = bot.blockAt(door_pos); + if (!door_block) { + log(bot, `Door block disappeared at ${door_pos}.`); + return false; + } + + await bot.lookAt(door_pos.offset(0.5, 0.5, 0.5)); + + // Toggle door state if it exists + if (door_block._properties && !door_block._properties.open) + await bot.activateBlock(door_block); + else if (!door_block._properties) + await bot.activateBlock(door_block); + + // Wait and move through + await new Promise((resolve) => setTimeout(resolve, 300)); + bot.setControlState("forward", true); + await new Promise((resolve) => setTimeout(resolve, 800)); + bot.setControlState("forward", false); + + // Close door if it's still open + door_block = bot.blockAt(door_pos); + if (door_block && door_block._properties && door_block._properties.open) { + await bot.activateBlock(door_block); + } - log(bot, `Used door at ${door_pos}.`); - return true; + log(bot, `Used door at ${door_pos}.`); + return true; + } catch (err) { + log(bot, `Error using door: ${err.message}.`); + return false; + } } export async function goToBed(bot) { @@ -1562,7 +2111,40 @@ export async function goToBed(bot) { let loc = beds[0]; await goToPosition(bot, loc.x, loc.y, loc.z); const bed = bot.blockAt(loc); - await bot.sleep(bed); + if (!bed) { + log(bot, `Could not find a bed block at location.`); + return false; + } + let slept = false; + try { + await bot.sleep(bed); + slept = true; + } catch (err) { + // Sometimes the located bed is the wrong half. Try adjacent blocks. + const offsets = [ + { x: 1, y: 0, z: 0 }, + { x: -1, y: 0, z: 0 }, + { x: 0, y: 0, z: 1 }, + { x: 0, y: 0, z: -1 } + ]; + for (const offset of offsets) { + const otherPos = bed.position.offset(offset.x, offset.y, offset.z); + const otherBed = bot.blockAt(otherPos); + if (otherBed && otherBed.name === bed.name) { + try { + await bot.sleep(otherBed); + slept = true; + break; + } catch (_e) { + // continue trying other halves + } + } + } + if (!slept) { + log(bot, `Could not sleep in bed: ${err?.message ?? 'unknown error'}`); + return false; + } + } log(bot, `You are in bed.`); bot.modes.pause('unstuck'); while (bot.isSleeping) { @@ -1596,8 +2178,8 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { seedType = seedType.replace(remove, ''); } } - placeBlock(bot, 'farmland', x, y, z); - placeBlock(bot, seedType, x, y+1, z); + await placeBlock(bot, 'farmland', x, y, z); + await placeBlock(bot, seedType, x, y+1, z); return true; } @@ -1620,8 +2202,8 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { // if distance is too far, move to the block if (bot.entity.position.distanceTo(block.position) > 4.5) { let pos = block.position; - bot.pathfinder.setMovements(new pf.Movements(bot)); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Baritone GoalNear + await goToGoal(bot, new baritoneGoals.GoalNear(pos, 4)); } if (block.name !== 'farmland') { let hoe = bot.inventory.items().find(item => item.name.includes('hoe')); @@ -1665,8 +2247,8 @@ export async function activateNearestBlock(bot, type) { } if (bot.entity.position.distanceTo(block.position) > 4.5) { let pos = block.position; - bot.pathfinder.setMovements(new pf.Movements(bot)); - await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); + // RC25: Baritone GoalNear + await goToGoal(bot, new baritoneGoals.GoalNear(pos, 4)); } await bot.activateBlock(block); log(bot, `Activated ${type} at x:${block.position.x.toFixed(1)}, y:${block.position.y.toFixed(1)}, z:${block.position.z.toFixed(1)}.`); @@ -1720,7 +2302,8 @@ async function findAndGoToVillager(bot, id) { log(bot, `Villager is ${distance.toFixed(1)} blocks away, moving closer...`); try { bot.modes.pause('unstuck'); - const goal = new pf.goals.GoalFollow(entity, 2); + // RC25: Baritone GoalNear replaces GoalFollow for villager approach + const goal = new baritoneGoals.GoalNear(entity.position, 2); await goToGoal(bot, goal); @@ -1903,9 +2486,182 @@ function stringifyItem(bot, item) { return text; } +// RC30: Pillar up out of a shaft by placing blocks below the bot +export async function pillarUp(bot, distance = 10) { + /** + * Pillar up by placing blocks below the bot. Uses cobblestone, dirt, or netherrack. + * Breaks blocks above the bot's head before jumping. + * @param {MinecraftBot} bot + * @param {number} distance - number of blocks to go up + * @returns {Promise} true if successfully pillared all the way up + */ + const placeableBlocks = ['cobblestone', 'dirt', 'netherrack', 'stone', 'granite', 'diorite', 'andesite', 'deepslate', 'cobbled_deepslate']; + + for (let i = 0; i < distance; i++) { + // Find a suitable block in inventory to place + const inv = world.getInventoryCounts(bot); + const blockToPlace = placeableBlocks.find(b => (inv[b] ?? 0) > 0); + if (!blockToPlace) { + log(bot, `Ran out of blocks to place after pillaring up ${i} blocks.`); + return i > 0; + } + + // Break blocks above the bot's head (need 2 blocks of air above to jump) + const headPos = bot.entity.position.offset(0, 2, 0); + const headBlock = bot.blockAt(new Vec3(Math.floor(headPos.x), Math.floor(headPos.y), Math.floor(headPos.z))); + if (headBlock && headBlock.name !== 'air' && headBlock.name !== 'cave_air') { + const dug = await breakBlockAt(bot, headBlock.position.x, headBlock.position.y, headBlock.position.z); + if (!dug) { + log(bot, `Cannot break block above head at y=${headBlock.position.y}. Stopped after ${i} blocks.`); + return i > 0; + } + } + // Also check one more above for safety + const aboveHead = bot.blockAt(new Vec3(Math.floor(headPos.x), Math.floor(headPos.y) + 1, Math.floor(headPos.z))); + if (aboveHead && aboveHead.name !== 'air' && aboveHead.name !== 'cave_air') { + await breakBlockAt(bot, aboveHead.position.x, aboveHead.position.y, aboveHead.position.z); + } + + // Jump and place block below + await bot.equip(bot.registry.itemsByName[blockToPlace].id, 'hand'); + bot.setControlState('jump', true); + await new Promise(r => setTimeout(r, 350)); // wait to be at top of jump + bot.setControlState('jump', false); + + // Place block at the position below the bot's feet + const feetPos = bot.entity.position; + const belowBlock = bot.blockAt(new Vec3(Math.floor(feetPos.x), Math.floor(feetPos.y) - 1, Math.floor(feetPos.z))); + if (belowBlock && (belowBlock.name === 'air' || belowBlock.name === 'cave_air')) { + try { + // Find an adjacent solid face to place against + const neighbors = [ + belowBlock.position.offset(0, -1, 0), + belowBlock.position.offset(1, 0, 0), + belowBlock.position.offset(-1, 0, 0), + belowBlock.position.offset(0, 0, 1), + belowBlock.position.offset(0, 0, -1), + ]; + let placed = false; + for (const nPos of neighbors) { + const nBlock = bot.blockAt(nPos); + if (nBlock && nBlock.name !== 'air' && nBlock.name !== 'cave_air' && nBlock.name !== 'water' && nBlock.name !== 'lava') { + const face = belowBlock.position.minus(nPos); + await bot.placeBlock(nBlock, face); + placed = true; + break; + } + } + if (!placed) { + log(bot, `Could not find adjacent block to place against at y=${belowBlock.position.y}. Stopped after ${i} blocks.`); + return i > 0; + } + } catch (placeErr) { + log(bot, `Failed to place block: ${placeErr.message}. Stopped after ${i} blocks.`); + return i > 0; + } + } + await new Promise(r => setTimeout(r, 250)); // small delay between pillars + } + log(bot, `Pillared up ${distance} blocks.`); + return true; +} + +// RC30: Strip-mine horizontally to find ore when pathfinder can't reach any +export async function stripMineForOre(bot, oreNames, length = 32) { + /** + * Dig a 1x2 horizontal tunnel to find ore. Mines in the direction the bot is facing. + * @param {MinecraftBot} bot + * @param {string[]} oreNames - ore block names to look for (e.g. ['iron_ore', 'deepslate_iron_ore']) + * @param {number} length - how far to dig (blocks) + * @returns {Promise} true if ore was found and collected + */ + // Determine direction from bot's yaw + const yaw = bot.entity.yaw; + let dx = 0, dz = 0; + // Normalize yaw to cardinal direction + const facing = ((yaw % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + if (facing >= 5.5 || facing < 0.785) { dz = 1; } // south + else if (facing >= 0.785 && facing < 2.356) { dx = -1; } // west + else if (facing >= 2.356 && facing < 3.927) { dz = -1; } // north + else { dx = 1; } // east + + log(bot, `Strip-mining ${length} blocks (dx=${dx}, dz=${dz}) looking for ${oreNames.join('/')}`); + + const startPos = bot.entity.position.clone(); + let oresCollected = 0; + + for (let i = 1; i <= length; i++) { + if (bot.interrupt_code) break; + + const x = Math.floor(startPos.x) + dx * i; + const y = Math.floor(startPos.y); + const z = Math.floor(startPos.z) + dz * i; + + // Break the two blocks in front (feet level and head level) + for (const yOff of [0, 1]) { + const block = bot.blockAt(new Vec3(x, y + yOff, z)); + if (!block || block.name === 'air' || block.name === 'cave_air') continue; + + // Check for lava + if (block.name === 'lava' || block.name === 'water') { + log(bot, `Hit ${block.name} while strip-mining. Stopping.`); + return oresCollected > 0; + } + + // Check if this IS an ore we want + if (oreNames.includes(block.name)) { + oresCollected++; + log(bot, `Found ${block.name} while strip-mining at (${x}, ${y + yOff}, ${z})!`); + } + + await breakBlockAt(bot, x, y + yOff, z); + } + + // Also check blocks to the sides and above/below for ore + const sideOffsets = [ + [0, 2, 0], // above head + [0, -1, 0], // below feet + [-dz, 0, dx], // left wall (feet) + [-dz, 1, dx], // left wall (head) + [dz, 0, -dx], // right wall (feet) + [dz, 1, -dx], // right wall (head) + ]; + for (const [ox, oy, oz] of sideOffsets) { + const sideBlock = bot.blockAt(new Vec3(x + ox, y + oy, z + oz)); + if (sideBlock && oreNames.includes(sideBlock.name)) { + oresCollected++; + log(bot, `Found ${sideBlock.name} adjacent to tunnel at (${x + ox}, ${y + oy}, ${z + oz})!`); + await breakBlockAt(bot, x + ox, y + oy, z + oz); + } + } + + // Move into the newly cleared space + try { + await bot.waitForTicks(2); + // Simple movement: walk forward into the cleared space + await goToPosition(bot, x + 0.5, y, z + 0.5, 0); + } catch (_moveErr) { + // If pathfinder fails, try teleporting via simple walk + bot.setControlState('forward', true); + await new Promise(r => setTimeout(r, 500)); + bot.setControlState('forward', false); + } + + // Pick up dropped items every few blocks + if (i % 4 === 0) { + await pickupNearbyItems(bot); + } + } + + await pickupNearbyItems(bot); + log(bot, `Strip-mine complete. Found ${oresCollected} ore blocks in ${length}-block tunnel.`); + return oresCollected > 0; +} + export async function digDown(bot, distance = 10) { /** - * Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot. + * Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=6 blocks below the bot. + * For drops of 3-5 blocks, places blocks to create a safe staircase down. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {int} distance, distance to dig down. * @returns {Promise} true if successfully dug all the way down. @@ -1926,21 +2682,23 @@ export async function digDown(bot, distance = 10) { // Check for lava, water if (targetBlock.name === 'lava' || targetBlock.name === 'water' || belowBlock.name === 'lava' || belowBlock.name === 'water') { - log(bot, `Dug down ${i-1} blocks, but reached ${belowBlock ? belowBlock.name : '(lava/water)'}`) + log(bot, `Dug down ${i-1} blocks, but reached ${belowBlock ? belowBlock.name : '(lava/water)'}`); return false; } - const MAX_FALL_BLOCKS = 2; + // RC30: Increased from 2 to 5. Count air blocks below. + const MAX_FALL_BLOCKS = 5; let num_fall_blocks = 0; + let checkBlock = belowBlock; for (let j = 0; j <= MAX_FALL_BLOCKS; j++) { - if (!belowBlock || (belowBlock.name !== 'air' && belowBlock.name !== 'cave_air')) { + if (!checkBlock || (checkBlock.name !== 'air' && checkBlock.name !== 'cave_air')) { break; } num_fall_blocks++; - belowBlock = bot.blockAt(belowBlock.position.offset(0, -1, 0)); + checkBlock = bot.blockAt(start_block_pos.offset(0, -i-1-j-1, 0)); } if (num_fall_blocks > MAX_FALL_BLOCKS) { - log(bot, `Dug down ${i-1} blocks, but reached a drop below the next block.`); + log(bot, `Dug down ${i-1} blocks, but reached a large drop (${num_fall_blocks} blocks) below.`); return false; } @@ -1973,7 +2731,7 @@ export async function goToSurface(bot) { continue; } await goToPosition(bot, block.position.x, block.position.y + 1, block.position.z, 0); // this will probably work most of the time but a custom mining and towering up implementation could be added if needed - log(bot, `Going to the surface at y=${y+1}.`);`` + log(bot, `Going to the surface at y=${y+1}.`); return true; } return false; @@ -2061,7 +2819,7 @@ export async function useToolOn(bot, toolName, targetName) { return blockInView && !blockInView.position.equals(block.position) && blockInView.position.distanceTo(headPos) < block.position.distanceTo(headPos); - } + }; const blockInView = bot.blockAtCursor(5); if (viewBlocked()) { log(bot, `Block ${blockInView.name} is in the way, moving closer...`); @@ -2090,4 +2848,933 @@ export async function useToolOn(bot, toolName, targetName) { } log(bot, `Used ${toolName} on ${block.name}.`); return true; - } +} + +export async function enterMinecart(bot, minecart_pos=null) { + /** + * Enter a minecart at the given position. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {Vec3} minecart_pos, the position of the minecart. If null, the nearest minecart will be used. + * @returns {Promise} true if the minecart was entered, false otherwise. + * @example + * await skills.enterMinecart(bot); + **/ + try { + if (!minecart_pos) { + const minecarts = bot.entities + .filter(e => e.type === 'minecart' || e.type === 'object') + .sort((a, b) => a.position.distanceTo(bot.entity.position) - b.position.distanceTo(bot.entity.position)); + + if (minecarts.length === 0) { + log(bot, `No minecart found nearby.`); + return false; + } + minecart_pos = minecarts[0].position; + } + + // Go to the minecart + // RC25: Baritone gotoSmart replaces pathfinder.setGoal + isMoving poll + if (!bot.ashfinder.stopped) bot.ashfinder.stop(); + await bot.ashfinder.gotoSmart(new baritoneGoals.GoalNear(minecart_pos, 1)); + + // Right-click to enter + await bot.lookAt(minecart_pos.offset(0.5, 0.5, 0.5)); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Use interaction (activate the minecart) + const minecartEntity = bot.nearestEntity(entity => + (entity.type === 'minecart' || entity.type === 'object') && + entity.position.distanceTo(minecart_pos) < 2 + ); + + if (minecartEntity) { + await bot.activateEntity(minecartEntity); + await new Promise((resolve) => setTimeout(resolve, 500)); + log(bot, `Entered minecart at ${minecart_pos}.`); + return true; + } + + log(bot, `Could not find minecart entity to enter.`); + return false; + } catch (err) { + log(bot, `Error entering minecart: ${err.message}`); + return false; + } +} + +export async function exitMinecart(bot) { + /** + * Exit the current minecart. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if exited minecart, false otherwise. + * @example + * await skills.exitMinecart(bot); + **/ + try { + if (!bot.vehicle) { + log(bot, `Not currently in a minecart.`); + return false; + } + + // Jump to exit + bot.setControlState("jump", true); + await new Promise((resolve) => setTimeout(resolve, 200)); + bot.setControlState("jump", false); + + await new Promise((resolve) => setTimeout(resolve, 300)); + log(bot, `Exited minecart.`); + return true; + } catch (err) { + log(bot, `Error exiting minecart: ${err.message}`); + return false; + } +} + +export async function getDiamondPickaxe(bot) { + /** + * Automatically obtain a diamond pickaxe by progressing through tool tiers. + * Detects any existing pickaxe and starts from the appropriate tier, + * so calling it twice is safe (idempotent). + * Tiers: wooden → stone → iron → diamond. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if diamond pickaxe obtained, false otherwise. + **/ + console.log('[RC30] getDiamondPickaxe: starting'); + + // Clear near-full inventory first so items can be picked up during collection + const invSize = Object.values(world.getInventoryCounts(bot)).reduce((a, b) => a + b, 0); + console.log(`[RC30] getDiamondPickaxe: invSize=${invSize}`); + if (invSize >= 30) { + log(bot, `Inventory near-full (${invSize} stacks). Clearing junk before collecting resources...`); + await autoManageInventory(bot); + } + + let inv; + + // Already done + inv = world.getInventoryCounts(bot); + if (inv['diamond_pickaxe'] > 0) { + log(bot, 'Already have a diamond pickaxe!'); + return true; + } + + // ── TIER 1: wooden pickaxe ─────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['wooden_pickaxe'] && !inv['stone_pickaxe'] && !inv['iron_pickaxe']) { + // Skip wooden pickaxe entirely if we already have materials for stone pickaxe + if ((inv['cobblestone'] ?? 0) >= 3 && (inv['stick'] ?? 0) >= 2) { + log(bot, 'Already have cobblestone and sticks — skipping wooden pickaxe, going straight to stone.'); + // Fall through to tier 2 + } else { + log(bot, 'Starting tool progression: collecting logs...'); + const logTypes = ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', + 'acacia_log', 'jungle_log', 'mangrove_log']; + // Use logs already in inventory, or collect some + let logType = logTypes.find(l => (inv[l] ?? 0) >= 3); + if (!logType) { + for (const lt of logTypes) { + if (await collectBlock(bot, lt, 3)) { logType = lt; break; } + } + } + if (!logType) { + log(bot, 'No logs nearby. Exploring 200 blocks to find trees...'); + await explore(bot, 200); + for (const lt of logTypes) { + if (await collectBlock(bot, lt, 3)) { logType = lt; break; } + } + } + if (!logType) { + log(bot, 'Cannot find any logs even after exploring. Run !getDiamondPickaxe again from a different area.'); + return false; + } + // Re-read inventory — collectBlock may have picked up a different log type + // (e.g., RC27 expanded search returns oak while hunting birch). + inv = world.getInventoryCounts(bot); + const actualLog = logTypes.find(l => (inv[l] ?? 0) >= 1); + if (actualLog && actualLog !== logType) { + log(bot, `Collected ${actualLog} (was hunting ${logType}), adjusting.`); + logType = actualLog; + } + const plankType = logType.replace('_log', '_planks'); + + // Craft ALL logs into planks (need ≥9: 4 crafting_table + 2 sticks + 3 pickaxe) + const logsAvail = inv[logType] ?? 0; + if (!await craftRecipe(bot, plankType, Math.max(logsAvail, 3))) { + log(bot, `Failed to craft ${plankType}.`); + return false; + } + + // Craft a crafting table — required for all pickaxe recipes + inv = world.getInventoryCounts(bot); + const nearTable = world.getNearestBlock(bot, 'crafting_table', 16); + const isReachable = nearTable && bot.entity.position.distanceTo(nearTable.position) <= 4; + if (!(inv['crafting_table'] > 0) && !isReachable) { + if (!await craftRecipe(bot, 'crafting_table', 1)) { + log(bot, 'Failed to craft crafting table.'); + return false; + } + } + + if (!await craftRecipe(bot, 'stick', 1)) { + log(bot, 'Failed to craft sticks.'); + return false; + } + if (!await craftRecipe(bot, 'wooden_pickaxe', 1)) { + log(bot, 'Failed to craft wooden pickaxe.'); + return false; + } + log(bot, 'Wooden pickaxe crafted.'); + } // end else (wooden pickaxe craft) + } + + // Helper: ensure we have enough sticks & a crafting table for upcoming pickaxe + async function ensureSticksAndTable() { + const inv2 = world.getInventoryCounts(bot); + // Need at least 2 sticks + if ((inv2['stick'] ?? 0) < 2) { + // Need planks for sticks — check if we have some + const anyPlanks = ['oak_planks','birch_planks','spruce_planks','dark_oak_planks', + 'acacia_planks','jungle_planks','mangrove_planks'].find(p => (inv2[p] ?? 0) >= 2); + if (anyPlanks) { + await craftRecipe(bot, 'stick', 1); + } else { + // Collect 1 log and make planks + sticks + const logTypes2 = ['oak_log','birch_log','spruce_log','dark_oak_log', + 'acacia_log','jungle_log','mangrove_log']; + for (const lt of logTypes2) { + if (await collectBlock(bot, lt, 1)) { + const pt = lt.replace('_log', '_planks'); + await craftRecipe(bot, pt, 1); + await craftRecipe(bot, 'stick', 1); + break; + } + } + } + } + // Ensure crafting table available (in inventory, not just "nearby" which might be unreachable) + const inv3 = world.getInventoryCounts(bot); + const nearbyTable = world.getNearestBlock(bot, 'crafting_table', 16); + const tableReachable = nearbyTable && bot.entity.position.distanceTo(nearbyTable.position) <= 4; + if (!(inv3['crafting_table'] > 0) && !tableReachable) { + let anyPlanks = ['oak_planks','birch_planks','spruce_planks','dark_oak_planks', + 'acacia_planks','jungle_planks','mangrove_planks'].find(p => (inv3[p] ?? 0) >= 4); + if (!anyPlanks) { + // Convert logs to planks if we have any + const logTypes3 = ['oak_log','birch_log','spruce_log','dark_oak_log', + 'acacia_log','jungle_log','mangrove_log']; + const logType3 = logTypes3.find(l => (inv3[l] ?? 0) >= 1); + if (logType3) { + const pt3 = logType3.replace('_log', '_planks'); + await craftRecipe(bot, pt3, 1); // 1 log → 4 planks + anyPlanks = pt3; + } else { + // Collect a log if none in inventory + for (const lt of logTypes3) { + if (await collectBlock(bot, lt, 1)) { + const pt3 = lt.replace('_log', '_planks'); + await craftRecipe(bot, pt3, 1); + anyPlanks = pt3; + break; + } + } + } + } + if (anyPlanks) { + await craftRecipe(bot, 'crafting_table', 1); + } + } + } + + // ── TIER 2: stone pickaxe ──────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + if (!inv['stone_pickaxe'] && !inv['iron_pickaxe']) { + if ((inv['cobblestone'] ?? 0) < 3) { + log(bot, 'Collecting cobblestone for stone pickaxe...'); + if (!await collectBlock(bot, 'stone', 3)) { + log(bot, 'Could not find stone. Try moving to a rocky area.'); + return false; + } + } else { + log(bot, 'Already have enough cobblestone for stone pickaxe.'); + } + await ensureSticksAndTable(); + if (!await craftRecipe(bot, 'stone_pickaxe', 1)) { + log(bot, 'Failed to craft stone pickaxe.'); + return false; + } + log(bot, 'Stone pickaxe crafted.'); + } + + // ── TIER 3: iron pickaxe ───────────────────────────────────────────────── + inv = world.getInventoryCounts(bot); + console.log(`[RC30] Tier 3 check: iron_pickaxe=${inv['iron_pickaxe'] ?? 0}`); + if (!inv['iron_pickaxe']) { + try { + log(bot, 'Collecting iron ore for iron pickaxe...'); + const ironOres = ['iron_ore', 'deepslate_iron_ore']; + + // Check if we already have raw_iron or iron_ingot in inventory + inv = world.getInventoryCounts(bot); + const rawIronHave = (inv['raw_iron'] ?? 0); + const ironIngotHave = (inv['iron_ingot'] ?? 0); + const ironNeeded = 3 - ironIngotHave; // need 3 total iron ingots + const oreNeeded = Math.max(0, ironNeeded - rawIronHave); + + let gotIron = oreNeeded <= 0; + + if (!gotIron) { + // Attempt 1: regular collectBlock + gotIron = await collectBlock(bot, 'iron_ore', oreNeeded); + if (!gotIron) gotIron = await collectBlock(bot, 'deepslate_iron_ore', oreNeeded); + } + + if (!gotIron) { + // Attempt 2: strip-mine at current level (works even in shafts) + log(bot, 'Cannot reach iron ore via pathfinding. Strip-mining to find iron...'); + const currentY = Math.floor(bot.entity.position.y); + + // If we're deep underground in a shaft, try to escape upward first + if (currentY < 50) { + log(bot, 'Underground — trying to pillar up to better terrain...'); + const pillarDist = Math.min(20, 60 - currentY); // aim for ~y=60 (surface-ish) + if (pillarDist > 0) { + await pillarUp(bot, pillarDist); + } + } + + // Now try strip-mining at current position (any y-level from 0-64 has iron) + gotIron = await stripMineForOre(bot, ironOres, 40); + } + + if (!gotIron) { + // Attempt 3: explore on surface, then dig fresh shaft + log(bot, 'Strip-mine did not find iron. Exploring to find a new area...'); + try { await goToSurface(bot); } catch (_e) { + // goToSurface may fail in enclosed spaces — pillar up instead + await pillarUp(bot, 30); + } + await explore(bot, 200); + const newY = Math.floor(bot.entity.position.y); + if (newY > 16) { + await digDown(bot, Math.min(newY - 16, 40)); + } + gotIron = await collectBlock(bot, 'iron_ore', oreNeeded); + if (!gotIron) gotIron = await collectBlock(bot, 'deepslate_iron_ore', oreNeeded); + if (!gotIron) gotIron = await stripMineForOre(bot, ironOres, 40); + } + + if (!gotIron) { + log(bot, 'Could not find iron ore after multiple attempts. Try !getDiamondPickaxe again later.'); + return false; + } + + // Check how much raw iron we have now + inv = world.getInventoryCounts(bot); + const totalRaw = (inv['raw_iron'] ?? 0); + const totalIngots = (inv['iron_ingot'] ?? 0); + + if (totalIngots < 3 && totalRaw > 0) { + // RC30: Ensure we have a furnace before smelting + const furnaceInv = (inv['furnace'] ?? 0); + const furnaceNearby = world.getNearestBlock(bot, 'furnace', 16); + if (!furnaceInv && !furnaceNearby) { + log(bot, 'Crafting furnace for smelting (8 cobblestone)...'); + await ensureSticksAndTable(); // need crafting table + if (!await craftRecipe(bot, 'furnace', 1)) { + log(bot, 'Failed to craft furnace. Need 8 cobblestone.'); + return false; + } + } + + // RC30: Ensure we have fuel — collect coal if no fuel in inventory + const fuelItems = ['coal', 'charcoal', 'oak_planks', 'birch_planks', 'spruce_planks', + 'dark_oak_planks', 'acacia_planks', 'jungle_planks', 'mangrove_planks', + 'oak_log', 'birch_log', 'spruce_log']; + const hasFuel = fuelItems.some(f => (inv[f] ?? 0) > 0); + if (!hasFuel) { + log(bot, 'No fuel for furnace. Collecting coal...'); + let gotFuel = await collectBlock(bot, 'coal_ore', 2); + if (!gotFuel) { + // Use wood as backup fuel — collect a log + const logTypes4 = ['oak_log','birch_log','spruce_log','dark_oak_log','acacia_log','jungle_log']; + for (const lt of logTypes4) { + if (await collectBlock(bot, lt, 2)) { gotFuel = true; break; } + } + } + if (!gotFuel) { + log(bot, 'Cannot find fuel for smelting. Try again from a different area.'); + return false; + } + } + + const smeltCount = Math.min(totalRaw, 3 - totalIngots); + if (!await smeltItem(bot, 'raw_iron', smeltCount)) { + log(bot, 'Failed to smelt raw iron into iron ingots.'); + return false; + } + } else if (totalIngots < 3 && totalRaw === 0) { + log(bot, 'Collected ore blocks but no raw_iron in inventory. May need a better pickaxe or retry.'); + return false; + } + + await ensureSticksAndTable(); + if (!await craftRecipe(bot, 'iron_pickaxe', 1)) { + log(bot, 'Failed to craft iron pickaxe.'); + return false; + } + log(bot, 'Iron pickaxe crafted.'); + } catch (tier3Err) { + log(bot, `[RC30] Iron tier error: ${tier3Err.message}. Will retry on next !getDiamondPickaxe call.`); + console.error('[RC30] Tier 3 error:', tier3Err); + return false; + } + } + + // ── TIER 4: diamond pickaxe ────────────────────────────────────────────── + // RC31: Quick-check — if we already have a diamond pickaxe, return immediately + if (world.getInventoryCounts(bot)['diamond_pickaxe']) { + log(bot, 'Already have a diamond pickaxe! Skipping tier 4.'); + return true; + } + console.log('[RC30] Entering tier 4: diamond pickaxe'); + try { + const targetY = -11; + const MAX_DIG_ATTEMPTS = 4; + let reachedDiamondLevel = false; + + for (let attempt = 0; attempt < MAX_DIG_ATTEMPTS && !reachedDiamondLevel; attempt++) { + const currentY = Math.floor(bot.entity.position.y); + log(bot, `Digging to diamond level (y=${targetY}), attempt ${attempt + 1}/${MAX_DIG_ATTEMPTS} from y=${currentY}...`); + + if (currentY <= targetY + 5) { + reachedDiamondLevel = true; + break; + } + + const dist = currentY - targetY; + const digSuccess = await digDown(bot, dist); + const afterY = Math.floor(bot.entity.position.y); + console.log(`[RC30] Tier4 dig attempt ${attempt + 1}: digSuccess=${digSuccess}, afterY=${afterY}`); + + if (digSuccess || afterY <= targetY + 10) { + reachedDiamondLevel = true; + } else { + // Failed — move horizontally and try a new shaft (NO goToSurface — it times out) + log(bot, `Dig attempt ${attempt + 1} stopped at y=${afterY}. Moving sideways to try new shaft...`); + try { + // Pick a random cardinal direction and walk 15 blocks + const dirs = [{x:15,z:0},{x:-15,z:0},{x:0,z:15},{x:0,z:-15}]; + const dir = dirs[attempt % dirs.length]; + const pos = bot.entity.position; + await goToPosition(bot, pos.x + dir.x, afterY, pos.z + dir.z, 2); + } catch (moveErr) { + console.log(`[RC30] Tier4 sideways move failed: ${moveErr.message}. Trying pillarUp...`); + try { await pillarUp(bot, 5); } catch (_pe) { /* ignore */ } + // Fall through and try dig from new position anyway + } + } + } + + if (!reachedDiamondLevel) { + const finalY = Math.floor(bot.entity.position.y); + if (finalY > targetY + 15) { + log(bot, `Could not reach diamond level after ${MAX_DIG_ATTEMPTS} attempts (y=${finalY}). Call !getDiamondPickaxe again.`); + return false; + } + log(bot, `Not at target but y=${finalY} is close enough. Trying anyway...`); + } + + log(bot, 'Searching for diamond ore...'); + const diamondOres = ['deepslate_diamond_ore', 'diamond_ore']; + let gotDiamonds = false; + try { gotDiamonds = await collectBlock(bot, 'deepslate_diamond_ore', 3); } catch (_ce) { /* timeout safe */ } + if (!gotDiamonds) { + try { gotDiamonds = await collectBlock(bot, 'diamond_ore', 3); } catch (_ce) { /* timeout safe */ } + } + + if (!gotDiamonds) { + log(bot, 'No diamond ore found via pathfinding. Strip-mining at diamond level...'); + try { gotDiamonds = await stripMineForOre(bot, diamondOres, 50); } catch (_se) { /* timeout safe */ } + } + + if (!gotDiamonds) { + // Try a second strip-mine in perpendicular direction + log(bot, 'First strip-mine found nothing. Trying perpendicular branch...'); + try { + // Rotate bot ~90 degrees by nudging sideways + const p = bot.entity.position; + try { await goToPosition(bot, p.x + 3, p.y, p.z, 1); } catch (_) { /* ok */ } + gotDiamonds = await stripMineForOre(bot, diamondOres, 50); + } catch (_se2) { /* timeout safe */ } + } + + if (!gotDiamonds) { + log(bot, 'No diamond ore found after mining. Explore and try !getDiamondPickaxe again.'); + return false; + } + + await ensureSticksAndTable(); + if (!await craftRecipe(bot, 'diamond_pickaxe', 1)) { + log(bot, 'Failed to craft diamond pickaxe. Need 3 diamonds + 2 sticks on a crafting table.'); + return false; + } + + try { await pillarUp(bot, 30); } catch (_e) { /* best effort */ } + log(bot, 'Diamond pickaxe obtained!'); + return true; + } catch (tier4Err) { + log(bot, `[RC30] Diamond tier error: ${tier4Err.message}. Will retry on next call.`); + console.error('[RC30] Tier 4 error:', tier4Err); + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// GENERAL IMPROVEMENTS — Safe Movement, Combat, Inventory, Food +// ═══════════════════════════════════════════════════════════════════════════ + +export async function safeMoveTo(bot, x, y, z, options = {}) { + /** + * Navigate to a position with safety checks: avoids lava, deep water, + * and large falls. Places torches underground every ~10 blocks. + * If the bot is falling >2 blocks, attempts water bucket clutch. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} x, the x coordinate to navigate to. + * @param {number} y, the y coordinate to navigate to. + * @param {number} z, the z coordinate to navigate to. + * @param {boolean} options.avoidLava, avoid lava paths (default true). + * @param {boolean} options.lightPath, place torches underground (default true). + * @param {number} options.timeout, navigation timeout in seconds (default 60). + * @returns {Promise} true if destination reached. + * @example + * await skills.safeMoveTo(bot, 100, 64, 200); + * await skills.safeMoveTo(bot, 100, 64, 200, {avoidLava: true, lightPath: true}); + **/ + const avoidLava = options.avoidLava !== false; + const lightPath = options.lightPath !== false; + + // Pre-flight: check destination is not in lava/void + if (avoidLava && y < -64) { + log(bot, 'Destination is below the void. Aborting.'); + return false; + } + + const startPos = bot.entity.position.clone(); + let lastTorchPos = startPos.clone(); + let torchInterval = null; + + // Auto-torch placer for underground travel + if (lightPath) { + torchInterval = setInterval(async () => { + try { + const pos = bot.entity.position; + if (pos.y < 50 && pos.distanceTo(lastTorchPos) >= 10) { + const inv = world.getInventoryCounts(bot); + if ((inv['torch'] || 0) > 0 && world.shouldPlaceTorch(bot)) { + await placeBlock(bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); + lastTorchPos = pos.clone(); + } + } + } catch (_e) { /* non-critical */ } + }, 3000); + } + + // Fall detection: water bucket clutch + let fallWatcher = null; + const startFallWatch = () => { + let lastY = bot.entity.position.y; + let fallStart = -1; + fallWatcher = setInterval(async () => { + const curY = bot.entity.position.y; + const vel = bot.entity.velocity; + if (vel && vel.y < -0.5) { + if (fallStart < 0) fallStart = lastY; + const fallDist = fallStart - curY; + if (fallDist > 4) { + // Attempt water bucket clutch + const waterBucket = bot.inventory.items().find(i => i.name === 'water_bucket'); + if (waterBucket) { + try { + await bot.equip(waterBucket, 'hand'); + const below = bot.blockAt(bot.entity.position.offset(0, -1, 0)); + if (below) await bot.placeBlock(below, new Vec3(0, 1, 0)); + } catch (_e) { /* best effort */ } + } + } + } else { + fallStart = -1; + } + lastY = curY; + }, 200); + }; + startFallWatch(); + + let success = false; + try { + success = await goToPosition(bot, x, y, z, 2); + } catch (err) { + log(bot, `safeMoveTo failed: ${err.message}`); + success = false; + } finally { + if (torchInterval) clearInterval(torchInterval); + if (fallWatcher) clearInterval(fallWatcher); + } + + if (!success) { + log(bot, `Could not safely reach (${x}, ${y}, ${z}).`); + } + return success; +} + +export async function rangedAttack(bot, entityType, preferredWeapon = 'bow') { + /** + * Attack the nearest entity of a given type using a bow if available, + * falling back to melee. Predicts target position for better aim. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {string} entityType, the type of entity to attack (e.g. 'blaze', 'skeleton'). + * @param {string} preferredWeapon, preferred ranged weapon: 'bow' or 'crossbow'. Default 'bow'. + * @returns {Promise} true if entity killed or driven off. + * @example + * await skills.rangedAttack(bot, "blaze"); + * await skills.rangedAttack(bot, "skeleton", "bow"); + **/ + const entity = world.getNearestEntityWhere(bot, e => e.name === entityType, 48); + if (!entity) { + log(bot, `No ${entityType} found nearby.`); + return false; + } + + const inv = world.getInventoryCounts(bot); + const hasBow = (inv['bow'] || 0) > 0; + const hasCrossbow = (inv['crossbow'] || 0) > 0; + const hasArrows = (inv['arrow'] || 0) > 0; + + // If we have ranged weapon + arrows, use ranged attack + if ((hasBow || hasCrossbow) && hasArrows) { + const weaponName = (preferredWeapon === 'crossbow' && hasCrossbow) ? 'crossbow' : (hasBow ? 'bow' : 'crossbow'); + const weapon = bot.inventory.items().find(i => i.name === weaponName); + if (weapon) await bot.equip(weapon, 'hand'); + + log(bot, `Attacking ${entityType} with ${weaponName}...`); + let attempts = 0; + const maxAttempts = 20; + while (entity.isValid && attempts < maxAttempts) { + if (bot.interrupt_code) return false; + attempts++; + + const dist = bot.entity.position.distanceTo(entity.position); + if (dist > 40) { + // Too far, close distance + await goToPosition(bot, entity.position.x, entity.position.y, entity.position.z, 20); + continue; + } + if (dist < 6) { + // Too close for bow, use melee fallback + await attackEntity(bot, entity, true); + return true; + } + + // Predict target position (lead the shot) + const vel = entity.velocity || new Vec3(0, 0, 0); + const flightTime = dist / 30; // approximate arrow speed + const predictedPos = entity.position.offset( + vel.x * flightTime, + vel.y * flightTime + entity.height * 0.7, + vel.z * flightTime + ); + + await bot.lookAt(predictedPos); + + // Activate bow (hold right click) + bot.activateItem(); + await new Promise(r => setTimeout(r, 1200)); // charge bow + bot.deactivateItem(); + await new Promise(r => setTimeout(r, 500)); + + // Check if entity is dead + if (!entity.isValid) { + log(bot, `${entityType} defeated with ${weaponName}!`); + return true; + } + } + + if (!entity.isValid) { + log(bot, `${entityType} defeated!`); + return true; + } + } + + // Fallback: melee attack + log(bot, `No ranged weapon available, using melee against ${entityType}.`); + return await attackNearest(bot, entityType, true); +} + +export async function buildPanicRoom(bot) { + /** + * Emergency shelter: builds a 3x3x3 hollow cobblestone box around the bot. + * Used when health is critically low. Eats available food inside. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if shelter built. + * @example + * await skills.buildPanicRoom(bot); + **/ + const inv = world.getInventoryCounts(bot); + const cobble = (inv['cobblestone'] || 0) + (inv['stone'] || 0) + (inv['deepslate'] || 0); + const material = cobble >= 20 ? + (inv['cobblestone'] >= 20 ? 'cobblestone' : (inv['stone'] >= 20 ? 'stone' : 'deepslate')) : + null; + + if (!material || cobble < 20) { + log(bot, 'Not enough blocks to build panic room (need 20+ cobblestone/stone).'); + // Just eat and heal in place + await ensureFed(bot); + return false; + } + + log(bot, 'Building emergency shelter!'); + const pos = bot.entity.position; + const bx = Math.floor(pos.x); + const by = Math.floor(pos.y); + const bz = Math.floor(pos.z); + + // Build floor, walls, and ceiling (3x3x3 hollow box) + const offsets = []; + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + offsets.push([dx, -1, dz]); // floor + offsets.push([dx, 2, dz]); // ceiling + } + } + // walls + for (let dy = 0; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === -1 || dx === 1) { + offsets.push([dx, dy, -1]); + offsets.push([dx, dy, 0]); + offsets.push([dx, dy, 1]); + } + } + offsets.push([0, dy, -1]); + offsets.push([0, dy, 1]); + } + + let placed = 0; + for (const [dx, dy, dz] of offsets) { + if (bot.interrupt_code) return false; + const block = bot.blockAt(new Vec3(bx + dx, by + dy, bz + dz)); + if (block && block.name === 'air') { + try { + await placeBlock(bot, material, bx + dx, by + dy, bz + dz, 'bottom', true); + placed++; + } catch (_e) { /* best effort */ } + } + } + + log(bot, `Panic room built with ${placed} blocks. Eating food...`); + await ensureFed(bot); + + // Wait until health recovers + let waitTime = 0; + while (bot.health < 18 && waitTime < 30000) { + if (bot.interrupt_code) return true; + await new Promise(r => setTimeout(r, 2000)); + waitTime += 2000; + if (bot.food < 18) await ensureFed(bot); + } + + log(bot, `Health recovered to ${bot.health}. Breaking out...`); + // Break front wall to exit + try { + await breakBlockAt(bot, bx, by, bz - 1); + await breakBlockAt(bot, bx, by + 1, bz - 1); + } catch (_e) { /* exit best effort */ } + + return true; +} + +export async function ensureFed(bot) { + /** + * Eat the best available food item if hunger is below 18. + * Prioritizes cooked food, then raw food. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if food was consumed. + * @example + * await skills.ensureFed(bot); + **/ + if (bot.food >= 18) return true; + + // Food priority list (best to worst) + const foodPriority = [ + 'golden_apple', 'enchanted_golden_apple', + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', + 'cooked_salmon', 'cooked_cod', 'cooked_chicken', 'cooked_rabbit', + 'bread', 'baked_potato', 'beetroot_soup', 'mushroom_stew', + 'pumpkin_pie', 'cookie', 'melon_slice', 'sweet_berries', + 'apple', 'carrot', 'potato', + 'beef', 'porkchop', 'mutton', 'chicken', 'rabbit', 'cod', 'salmon', + 'dried_kelp', 'beetroot', 'rotten_flesh' + ]; + + const inv = world.getInventoryCounts(bot); + for (const food of foodPriority) { + if ((inv[food] || 0) > 0) { + log(bot, `Eating ${food}...`); + return await consume(bot, food); + } + } + + log(bot, 'No food available!'); + return false; +} + +export async function autoManageInventory(bot) { + /** + * Clean up inventory: drop junk items, keep important items, + * ensure at least 8 empty slots. Stores excess in a nearby chest if available. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @returns {Promise} true if inventory was managed. + * @example + * await skills.autoManageInventory(bot); + **/ + const junkItems = [ + 'dirt', 'gravel', 'sand', 'andesite', 'diorite', 'granite', + 'cobbled_deepslate', 'tuff', 'netherrack', 'cobblestone', + 'rotten_flesh', 'poisonous_potato', 'spider_eye', + 'pufferfish', 'tropical_fish' + ]; + + // Keep threshold: always keep some cobblestone for crafting + const keepAmounts = { + 'cobblestone': 64, + 'dirt': 0, + 'gravel': 0, + 'sand': 0, + 'andesite': 0, + 'diorite': 0, + 'granite': 0, + 'cobbled_deepslate': 0, + 'tuff': 0, + 'netherrack': 0, + 'rotten_flesh': 0, + 'poisonous_potato': 0, + 'spider_eye': 0, + 'pufferfish': 0, + 'tropical_fish': 0 + }; + + const inv = world.getInventoryCounts(bot); + let emptySlots = bot.inventory.emptySlotCount(); + let discarded = 0; + + if (emptySlots >= 8) { + log(bot, `Inventory is fine (${emptySlots} empty slots).`); + return true; + } + + // Try to store in nearby chest first + const chest = world.getNearestBlock(bot, 'chest', 32); + if (chest) { + log(bot, 'Storing excess items in nearby chest...'); + for (const item of junkItems) { + if (bot.interrupt_code) return false; + const count = inv[item] || 0; + const keep = keepAmounts[item] || 0; + const toStore = count - keep; + if (toStore > 0) { + await putInChest(bot, item, toStore); + discarded += toStore; + } + } + } else { + // No chest — discard junk + log(bot, 'No chest nearby. Discarding junk items...'); + for (const item of junkItems) { + if (bot.interrupt_code) return false; + const count = inv[item] || 0; + const keep = keepAmounts[item] || 0; + const toDrop = count - keep; + if (toDrop > 0) { + await discard(bot, item, toDrop); + discarded += toDrop; + } + emptySlots = bot.inventory.emptySlotCount(); + if (emptySlots >= 8) break; + } + } + + emptySlots = bot.inventory.emptySlotCount(); + log(bot, `Inventory managed. Discarded/stored ${discarded} items. ${emptySlots} slots free.`); + return emptySlots >= 8; +} + +export async function stockpileFood(bot, quantity = 32) { + /** + * Gather food by hunting passive animals nearby. Cooks meat in a furnace + * if fuel and furnace materials are available. + * @param {MinecraftBot} bot, reference to the minecraft bot. + * @param {number} quantity, target number of food items. Default 32. + * @returns {Promise} true if enough food collected. + * @example + * await skills.stockpileFood(bot, 32); + **/ + const meatAnimals = ['cow', 'pig', 'sheep', 'chicken', 'rabbit']; + + // Count current food + const foodItems = [ + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', 'cooked_rabbit', + 'bread', 'apple', 'carrot', 'baked_potato', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'rabbit' + ]; + + let inv = world.getInventoryCounts(bot); + let totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + + log(bot, `Current food supply: ${totalFood}/${quantity}`); + + // Hunt animals until we have enough + let huntAttempts = 0; + while (totalFood < quantity && huntAttempts < 20) { + if (bot.interrupt_code) return false; + huntAttempts++; + + let hunted = false; + for (const animal of meatAnimals) { + const entity = world.getNearestEntityWhere(bot, e => e.name === animal, 32); + if (entity) { + await attackEntity(bot, entity, true); + await pickupNearbyItems(bot); + hunted = true; + break; + } + } + + if (!hunted) { + log(bot, 'No animals nearby. Exploring to find more...'); + await explore(bot, 60); + } + + inv = world.getInventoryCounts(bot); + totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + } + + // Cook raw meat if we have a furnace and fuel + const rawMeats = ['beef', 'porkchop', 'mutton', 'chicken', 'rabbit']; + inv = world.getInventoryCounts(bot); + for (const meat of rawMeats) { + if (bot.interrupt_code) return false; + const rawCount = inv[meat] || 0; + if (rawCount > 0) { + const cooked = await smeltItem(bot, meat, rawCount); + if (cooked) log(bot, `Cooked ${rawCount} ${meat}.`); + } + } + + inv = world.getInventoryCounts(bot); + totalFood = 0; + for (const f of foodItems) totalFood += (inv[f] || 0); + log(bot, `Food stockpile complete: ${totalFood} food items.`); + return totalFood >= quantity; +} + diff --git a/src/agent/library/world.js b/src/agent/library/world.js index d993a0931..02816a1a3 100644 --- a/src/agent/library/world.js +++ b/src/agent/library/world.js @@ -1,4 +1,5 @@ -import pf from 'mineflayer-pathfinder'; +import baritoneModule from '@miner-org/mineflayer-baritone'; +const baritoneGoals = baritoneModule.goals; import * as mc from '../../utils/mcdata.js'; @@ -97,7 +98,7 @@ export function getFirstBlockAboveHead(bot, ignore_types=null, distance=32) { } // The block above, stops when it finds a solid block . let block_above = {name: 'air'}; - let height = 0 + let height = 0; for (let i = 0; i < distance; i++) { let block = bot.blockAt(bot.entity.position.offset(0, i+2, 0)); if (!block) block = {name: 'air'}; @@ -401,17 +402,29 @@ export function getNearbyBlockTypes(bot, distance=16) { export async function isClearPath(bot, target) { /** * Check if there is a path to the target that requires no digging or placing blocks. + * RC25: Uses Baritone generatePath with restrictive config. + * RC30: Guard against missing ashfinder (e.g. during respawn). * @param {Bot} bot - The bot to get the path for. * @param {Entity} target - The target to path to. * @returns {boolean} - True if there is a clear path, false otherwise. */ - let movements = new pf.Movements(bot) - movements.canDig = false; - movements.canPlaceOn = false; - movements.canOpenDoors = false; - let goal = new pf.goals.GoalNear(target.position.x, target.position.y, target.position.z, 1); - let path = await bot.pathfinder.getPathTo(movements, goal, 100); - return path.status === 'success'; + // RC30: Guard — ashfinder may not be initialized yet (respawn, early tick) + if (!bot.ashfinder?.config) { + return false; + } + // Temporarily disable break/place to test clear-path feasibility + const prevBreak = bot.ashfinder.config.breakBlocks; + const prevPlace = bot.ashfinder.config.placeBlocks; + bot.ashfinder.config.breakBlocks = false; + bot.ashfinder.config.placeBlocks = false; + try { + let goal = new baritoneGoals.GoalNear(target.position, 1); + let pathResult = await bot.ashfinder.generatePath(goal); + return pathResult && pathResult.status === 'success'; + } finally { + bot.ashfinder.config.breakBlocks = prevBreak; + bot.ashfinder.config.placeBlocks = prevPlace; + } } export function shouldPlaceTorch(bot) { diff --git a/src/agent/memory_bank.js b/src/agent/memory_bank.js index a32ab7839..9210b31b6 100644 --- a/src/agent/memory_bank.js +++ b/src/agent/memory_bank.js @@ -12,7 +12,7 @@ export class MemoryBank { } getJson() { - return this.memory + return this.memory; } loadJson(json) { @@ -20,6 +20,6 @@ export class MemoryBank { } getKeys() { - return Object.keys(this.memory).join(', ') + return Object.keys(this.memory).join(', '); } } \ No newline at end of file diff --git a/src/agent/mindserver_proxy.js b/src/agent/mindserver_proxy.js index d0b9be838..667fb28dd 100644 --- a/src/agent/mindserver_proxy.js +++ b/src/agent/mindserver_proxy.js @@ -18,15 +18,25 @@ class MindServerProxy { MindServerProxy.instance = this; } - async connect(name, port) { + async connect(name, urlOrPort, remoteSettings = null) { if (this.connected) return; - + this.name = name; - this.socket = io(`http://localhost:${port}`); + const url = (typeof urlOrPort === 'string' && urlOrPort.startsWith('http')) + ? urlOrPort + : `http://localhost:${urlOrPort}`; + this.socket = io(url); await new Promise((resolve, reject) => { - this.socket.on('connect', resolve); + const timeout = setTimeout(() => { + reject(new Error(`MindServer connection timed out after 30s (${url})`)); + }, 30000); + this.socket.on('connect', () => { + clearTimeout(timeout); + resolve(); + }); this.socket.on('connect_error', (err) => { + clearTimeout(timeout); console.error('Connection failed:', err); reject(err); }); @@ -57,7 +67,13 @@ class MindServerProxy { }); this.socket.on('restart-agent', (agentName) => { - console.log(`Restarting agent: ${agentName}`); + // Ignore unnamed/broadcast restarts (e.g. from stale UI settings updates) + if (!agentName) { + console.log('Ignoring unnamed restart-agent event'); + return; + } + if (agentName !== this.agent.name) return; + console.log(`Restarting agent: ${this.agent.name}`); this.agent.cleanKill(); }); @@ -79,22 +95,45 @@ class MindServerProxy { } }); - // Request settings and wait for response - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Settings request timed out after 5 seconds')); - }, 5000); + this.socket.on('get-usage', (callback) => { + try { + const snapshot = this.agent?.prompter?.usageTracker?.getSnapshot() || null; + callback(snapshot); + } catch (error) { + console.error('Error getting usage:', error); + callback(null); + } + }); - this.socket.emit('get-settings', name, (response) => { - clearTimeout(timeout); - if (response.error) { - return reject(new Error(response.error)); - } - setSettings(response.settings); - this.socket.emit('connect-agent-process', name); - resolve(); + if (remoteSettings) { + // Remote mode: register ourselves on the remote MindServer + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Remote agent registration timed out after 10s')); + }, 10000); + this.socket.emit('register-remote-agent', remoteSettings, (response) => { + clearTimeout(timeout); + if (response.error) return reject(new Error(response.error)); + setSettings(response.settings); + this.socket.emit('connect-agent-process', name); + resolve(); + }); }); - }); + } else { + // Local mode: request settings from MindServer + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Settings request timed out after 5 seconds')); + }, 5000); + this.socket.emit('get-settings', name, (response) => { + clearTimeout(timeout); + if (response.error) return reject(new Error(response.error)); + setSettings(response.settings); + this.socket.emit('connect-agent-process', name); + resolve(); + }); + }); + } } setAgent(agent) { diff --git a/src/agent/modes.js b/src/agent/modes.js index 21b7b955e..fb61ef2a7 100644 --- a/src/agent/modes.js +++ b/src/agent/modes.js @@ -1,7 +1,7 @@ import * as skills from './library/skills.js'; import * as world from './library/world.js'; import * as mc from '../utils/mcdata.js'; -import settings from './settings.js' +import settings from './settings.js'; import convoManager from './conversation.js'; async function say(agent, message) { @@ -35,41 +35,54 @@ const modes_list = [ let blockAbove = bot.blockAt(bot.entity.position.offset(0, 1, 0)); if (!block) block = {name: 'air'}; // hacky fix when blocks are not loaded if (!blockAbove) blockAbove = {name: 'air'}; - if (blockAbove.name === 'water') { - // does not call execute so does not interrupt other actions - if (!bot.pathfinder.goal) { - bot.setControlState('jump', true); + // Drowning prevention: swim up when underwater or low on air + const isSubmerged = blockAbove.name === 'water'; + const oxygenLevel = bot.oxygenLevel != null ? bot.oxygenLevel : 20; + if (isSubmerged && oxygenLevel < 12) { + // Low on air — interrupt everything and swim to surface + bot.setControlState('jump', true); + bot.setControlState('sprint', false); + if (oxygenLevel < 6) { + // Critical — also try to navigate up + await execute(this, agent, async () => { + const pos = bot.entity.position; + await skills.goToPosition(bot, pos.x, pos.y + 10, pos.z, 1); + }); } } + else if (isSubmerged) { + // Underwater but air is okay — keep jumping to stay afloat + bot.setControlState('jump', true); + } else if (this.fall_blocks.some(name => blockAbove.name.includes(name))) { - execute(this, agent, async () => { + await execute(this, agent, async () => { await skills.moveAway(bot, 2); }); } else if (block.name === 'lava' || block.name === 'fire' || blockAbove.name === 'lava' || blockAbove.name === 'fire') { - say(agent, 'I\'m on fire!'); + await say(agent, 'I\'m on fire!'); // if you have a water bucket, use it let waterBucket = bot.inventory.items().find(item => item.name === 'water_bucket'); if (waterBucket) { - execute(this, agent, async () => { + await execute(this, agent, async () => { let success = await skills.placeBlock(bot, 'water_bucket', block.position.x, block.position.y, block.position.z); - if (success) say(agent, 'Placed some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Placed some water, ahhhh that\'s better!'); }); } else { - execute(this, agent, async () => { + await execute(this, agent, async () => { let waterBucket = bot.inventory.items().find(item => item.name === 'water_bucket'); if (waterBucket) { let success = await skills.placeBlock(bot, 'water_bucket', block.position.x, block.position.y, block.position.z); - if (success) say(agent, 'Placed some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Placed some water, ahhhh that\'s better!'); return; } let nearestWater = world.getNearestBlock(bot, 'water', 20); if (nearestWater) { const pos = nearestWater.position; let success = await skills.goToPosition(bot, pos.x, pos.y, pos.z, 0.2); - if (success) say(agent, 'Found some water, ahhhh that\'s better!'); + if (success) void say(agent, 'Found some water, ahhhh that\'s better!'); return; } await skills.moveAway(bot, 5); @@ -77,8 +90,8 @@ const modes_list = [ } } else if (Date.now() - bot.lastDamageTime < 3000 && (bot.health < 5 || bot.lastDamageTaken >= bot.health)) { - say(agent, 'I\'m dying!'); - execute(this, agent, async () => { + await say(agent, 'I\'m dying!'); + await execute(this, agent, async () => { await skills.moveAway(bot, 20); }); } @@ -120,13 +133,28 @@ const modes_list = [ } const max_stuck_time = cur_dig_block?.name === 'obsidian' ? this.max_stuck_time * 2 : this.max_stuck_time; if (this.stuck_time > max_stuck_time) { - say(agent, 'I\'m stuck!'); + await say(agent, 'I\'m stuck!'); this.stuck_time = 0; - execute(this, agent, async () => { - const crashTimeout = setTimeout(() => { agent.cleanKill("Got stuck and couldn't get unstuck") }, 10000); - await skills.moveAway(bot, 5); - clearTimeout(crashTimeout); - say(agent, 'I\'m free.'); + await execute(this, agent, async () => { + const crashTimeout = setTimeout(() => { agent.cleanKill("Got stuck and couldn't get unstuck") }, 30000); + try { + await skills.moveAway(bot, 5); + clearTimeout(crashTimeout); + void say(agent, 'I\'m free.'); + } catch (moveErr) { + console.warn(`[Unstuck] moveAway failed: ${moveErr.message}. Brute-force walking...`); + // Brute-force fallback: random yaw + forward + jump for 3s + const randomYaw = Math.random() * Math.PI * 2; + const randomPitch = 0; + bot.look(randomYaw, randomPitch, true); + bot.setControlState('forward', true); + bot.setControlState('jump', true); + bot.setControlState('sprint', true); + await new Promise(resolve => setTimeout(resolve, 3000)); + bot.clearControlStates(); + clearTimeout(crashTimeout); + void say(agent, 'Broke free by brute force.'); + } }); } this.last_time = Date.now(); @@ -146,8 +174,8 @@ const modes_list = [ update: async function (agent) { const enemy = world.getNearestEntityWhere(agent.bot, entity => mc.isHostile(entity), 16); if (enemy && await world.isClearPath(agent.bot, enemy)) { - say(agent, `Aaa! A ${enemy.name.replace("_", " ")}!`); - execute(this, agent, async () => { + await say(agent, `Aaa! A ${enemy.name.replace("_", " ")}!`); + await execute(this, agent, async () => { await skills.avoidEnemies(agent.bot, 24); }); } @@ -160,10 +188,12 @@ const modes_list = [ on: true, active: false, update: async function (agent) { - const enemy = world.getNearestEntityWhere(agent.bot, entity => mc.isHostile(entity), 8); - if (enemy && await world.isClearPath(agent.bot, enemy)) { - say(agent, `Fighting ${enemy.name}!`); - execute(this, agent, async () => { + const bot = agent.bot; + if (Date.now() - (bot.respawnTime || 0) < 5000) return; + const enemy = world.getNearestEntityWhere(bot, entity => mc.isHostile(entity), 8); + if (enemy && await world.isClearPath(bot, enemy)) { + await say(agent, `Fighting ${enemy.name}!`); + await execute(this, agent, async () => { await skills.defendSelf(agent.bot, 8); }); } @@ -178,8 +208,8 @@ const modes_list = [ update: async function (agent) { const huntable = world.getNearestEntityWhere(agent.bot, entity => mc.isHuntable(entity), 8); if (huntable && await world.isClearPath(agent.bot, huntable)) { - execute(this, agent, async () => { - say(agent, `Hunting ${huntable.name}!`); + await execute(this, agent, async () => { + void say(agent, `Hunting ${huntable.name}!`); await skills.attackEntity(agent.bot, huntable); }); } @@ -203,9 +233,9 @@ const modes_list = [ this.noticed_at = Date.now(); } if (Date.now() - this.noticed_at > this.wait * 1000) { - say(agent, `Picking up item!`); + await say(agent, `Picking up item!`); this.prev_item = item; - execute(this, agent, async () => { + await execute(this, agent, async () => { await skills.pickupNearbyItems(agent.bot); }); this.noticed_at = -1; @@ -224,10 +254,10 @@ const modes_list = [ active: false, cooldown: 5, last_place: Date.now(), - update: function (agent) { + update: async function (agent) { if (world.shouldPlaceTorch(agent.bot)) { if (Date.now() - this.last_place < this.cooldown * 1000) return; - execute(this, agent, async () => { + await execute(this, agent, async () => { const pos = agent.bot.entity.position; await skills.placeBlock(agent.bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); }); @@ -235,6 +265,91 @@ const modes_list = [ } } }, + { + // RC26: Auto-navigate to bed and sleep at night + name: 'night_bed', + description: 'Automatically find and sleep in a bed at night. Crafts and places a bed if none nearby.', + interrupts: ['action:followPlayer'], + on: true, + active: false, + cooldown: 30, // seconds between attempts + lastAttempt: 0, + update: async function (agent) { + const bot = agent.bot; + const time = bot.time?.timeOfDay ?? 0; + + // Only trigger at night (12500 = dusk, can sleep at 12542+) + if (time < 12500 || time > 23000) return; + // Already sleeping + if (bot.isSleeping) return; + // Cooldown to avoid spamming + if (Date.now() - this.lastAttempt < this.cooldown * 1000) return; + // Don't interrupt Nether/End (no night cycle) + const dim = bot.game?.dimension; + if (dim === 'the_nether' || dim === 'the_end') return; + + this.lastAttempt = Date.now(); + + await execute(this, agent, async () => { + // Phase 1: Look for existing bed within 64 blocks + const beds = bot.findBlocks({ + matching: (block) => block.name.includes('bed'), + maxDistance: 64, + count: 1 + }); + + if (beds.length > 0) { + void say(agent, 'Night time — heading to bed.'); + const success = await skills.goToBed(bot); + if (success) return; + // goToBed failed — fall through to craft attempt + } + + // Phase 2: No bed found (or sleep failed) — try to craft and place one + const inv = world.getInventoryCounts(bot); + const woolTypes = [ + 'white_wool', 'orange_wool', 'magenta_wool', 'light_blue_wool', + 'yellow_wool', 'lime_wool', 'pink_wool', 'gray_wool', + 'light_gray_wool', 'cyan_wool', 'purple_wool', 'blue_wool', + 'brown_wool', 'green_wool', 'red_wool', 'black_wool' + ]; + const plankTypes = [ + 'oak_planks', 'spruce_planks', 'birch_planks', 'jungle_planks', + 'acacia_planks', 'dark_oak_planks', 'mangrove_planks', + 'cherry_planks', 'bamboo_planks', 'crimson_planks', 'warped_planks' + ]; + + const woolCount = woolTypes.reduce((sum, w) => sum + (inv[w] || 0), 0); + const plankCount = plankTypes.reduce((sum, p) => sum + (inv[p] || 0), 0); + + if (woolCount >= 3 && plankCount >= 3) { + void say(agent, 'No bed nearby — crafting one.'); + try { + // Find which wool and plank type we actually have + const woolType = woolTypes.find(w => (inv[w] || 0) >= 3) + || woolTypes.find(w => (inv[w] || 0) > 0); + // craftRecipe will try the default bed recipe + const bedName = woolType ? woolType.replace('_wool', '_bed') : 'white_bed'; + await skills.craftRecipe(bot, bedName); + + // Place the bed + const bedItem = bot.inventory.items().find(i => i.name.includes('bed')); + if (bedItem) { + const pos = bot.entity.position; + await skills.placeBlock(bot, bedItem.name, pos.x + 1, pos.y, pos.z, 'bottom'); + // Now sleep in it + await skills.goToBed(bot); + } + } catch (err) { + console.log(`[night_bed] Craft/place failed: ${err.message}`); + } + } else { + // Not enough materials — just log once quietly + console.log('[night_bed] No bed nearby and insufficient materials to craft one.'); + } + }); + } + }, { name: 'elbow_room', description: 'Move away from nearby players when idle.', @@ -245,7 +360,7 @@ const modes_list = [ update: async function (agent) { const player = world.getNearestEntityWhere(agent.bot, entity => entity.type === 'player', this.distance); if (player) { - execute(this, agent, async () => { + await execute(this, agent, async () => { // wait a random amount of time to avoid identical movements with other bots const wait_time = Math.random() * 1000; await new Promise(resolve => setTimeout(resolve, wait_time)); @@ -293,13 +408,84 @@ const modes_list = [ } } }, + { + name: 'auto_eat', + description: 'Automatically eat food when hunger drops below 14 or health drops below 50%. Prioritizes golden apples at critical health. Interrupts non-critical actions.', + interrupts: ['all'], + on: true, + active: false, + lastEat: 0, + update: async function (agent) { + const bot = agent.bot; + // RC30: Hunger safety net — force eat when health < 50% regardless of hunger + const healthCritical = bot.health < 10; + if (bot.food >= 14 && !healthCritical) return; + if (Date.now() - this.lastEat < (healthCritical ? 3000 : 10000)) return; // faster cooldown when health critical + + // RC30: Prioritize golden apples when health is critical + const criticalFoodPriority = [ + 'enchanted_golden_apple', 'golden_apple', + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'cooked_salmon', 'cooked_cod', 'cooked_rabbit', 'bread', 'baked_potato', + 'apple', 'carrot', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'dried_kelp', 'rotten_flesh' + ]; + const normalFoodPriority = [ + 'cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'cooked_salmon', 'cooked_cod', 'cooked_rabbit', 'bread', 'baked_potato', + 'golden_apple', 'apple', 'carrot', 'melon_slice', 'sweet_berries', + 'beef', 'porkchop', 'mutton', 'chicken', 'dried_kelp', 'rotten_flesh' + ]; + const foodPriority = healthCritical ? criticalFoodPriority : normalFoodPriority; + + const inv = world.getInventoryCounts(bot); + let foodItem = null; + for (const f of foodPriority) { + if ((inv[f] || 0) > 0) { foodItem = f; break; } + } + + if (foodItem) { + this.lastEat = Date.now(); + await say(agent, healthCritical + ? `Emergency eating ${foodItem}! (health: ${bot.health.toFixed(1)}, hunger: ${bot.food})` + : `Eating ${foodItem} (hunger: ${bot.food}).`); + await execute(this, agent, async () => { + await skills.consume(agent.bot, foodItem); + }); + } + } + }, + { + name: 'panic_defense', + description: 'Build emergency cobblestone shelter when health is critically low (< 6) and under attack.', + interrupts: ['all'], + on: true, + active: false, + lastPanic: 0, + update: async function (agent) { + const bot = agent.bot; + if (bot.health >= 6) return; + if (Date.now() - this.lastPanic < 60000) return; // 60s cooldown + if (Date.now() - bot.lastDamageTime > 5000) return; // only if recently damaged + + const inv = world.getInventoryCounts(bot); + const cobble = (inv['cobblestone'] || 0); + if (cobble < 12) return; // not enough to bother + + this.lastPanic = Date.now(); + await say(agent, 'Critical health! Building emergency shelter!'); + await execute(this, agent, async () => { + await skills.buildPanicRoom(agent.bot); + }); + } + }, { name: 'cheat', description: 'Use cheats to instantly place blocks and teleport.', interrupts: [], on: false, active: false, - update: function (agent) { /* do nothing */ } + update: function (_agent) { /* do nothing */ } } ]; @@ -403,7 +589,12 @@ class ModeController { for (let mode of modes_list) { let interruptible = mode.interrupts.some(i => i === 'all') || mode.interrupts.some(i => i === _agent.actions.currentActionLabel); if (mode.on && !mode.paused && !mode.active && (_agent.isIdle() || interruptible)) { - await mode.update(_agent); + try { + await mode.update(_agent); + } catch (err) { + console.error(`Mode ${mode.name} error:`, err.message); + mode.active = false; + } } if (mode.active) break; } diff --git a/src/agent/npc/build_goal.js b/src/agent/npc/build_goal.js index ebca78f80..db12c0d53 100644 --- a/src/agent/npc/build_goal.js +++ b/src/agent/npc/build_goal.js @@ -1,7 +1,6 @@ import { Vec3 } from 'vec3'; import * as skills from '../library/skills.js'; import * as world from '../library/world.js'; -import * as mc from '../../utils/mcdata.js'; import { blockSatisfied, getTypeOfGeneric, rotateXZ } from './utils.js'; diff --git a/src/agent/npc/controller.js b/src/agent/npc/controller.js index 9af3f3e40..1c35bc548 100644 --- a/src/agent/npc/controller.js +++ b/src/agent/npc/controller.js @@ -1,11 +1,10 @@ import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; import { NPCData } from './data.js'; import { ItemGoal } from './item_goal.js'; import { BuildGoal } from './build_goal.js'; import { itemSatisfied, rotateXZ } from './utils.js'; import * as skills from '../library/skills.js'; -import * as world from '../library/world.js'; -import * as mc from '../../utils/mcdata.js'; export class NPCContoller { @@ -40,12 +39,15 @@ export class NPCContoller { init() { try { - for (let file of readdirSync('src/agent/npc/construction')) { + const constructionDir = path.resolve('src/agent/npc/construction'); + for (let file of readdirSync(constructionDir)) { if (file.endsWith('.json')) { - this.constructions[file.slice(0, -5)] = JSON.parse(readFileSync('src/agent/npc/construction/' + file, 'utf8')); + const filePath = path.resolve(constructionDir, file); + if (!filePath.startsWith(constructionDir + path.sep)) continue; + this.constructions[file.slice(0, -5)] = JSON.parse(readFileSync(filePath, 'utf8')); } } - } catch (e) { + } catch (_e) { console.log('Error reading construction file'); } @@ -151,7 +153,7 @@ export class NPCContoller { // If we need more blocks to complete a building, get those first let goals = this.temp_goals.concat(this.data.goals); if (this.data.curr_goal) - goals = goals.concat([this.data.curr_goal]) + goals = goals.concat([this.data.curr_goal]); this.temp_goals = []; let acted = false; @@ -170,7 +172,7 @@ export class NPCContoller { // Build construction goal else { let res = null; - if (this.data.built.hasOwnProperty(goal.name)) { + if (Object.prototype.hasOwnProperty.call(this.data.built, goal.name)) { res = await this.build_goal.executeNext( this.constructions[goal.name], this.data.built[goal.name].position, @@ -191,7 +193,7 @@ export class NPCContoller { this.temp_goals.push({ name: block_name, quantity: res.missing[block_name] - }) + }); } if (res.acted) { acted = true; diff --git a/src/agent/npc/item_goal.js b/src/agent/npc/item_goal.js index 9055f54a0..48639fc23 100644 --- a/src/agent/npc/item_goal.js +++ b/src/agent/npc/item_goal.js @@ -17,7 +17,7 @@ const blacklist = [ 'crimson', 'warped', 'dye' -] +]; class ItemNode { @@ -204,7 +204,7 @@ class ItemWrapper { } createChildren() { - let recipes = mc.getItemCraftingRecipes(this.name).map(([recipe, craftedCount]) => recipe); + let recipes = mc.getItemCraftingRecipes(this.name).map(([recipe, _craftedCount]) => recipe); if (recipes) { for (let recipe of recipes) { let includes_blacklisted = false; @@ -218,7 +218,7 @@ class ItemWrapper { if (includes_blacklisted) break; } if (includes_blacklisted) continue; - this.add_method(new ItemNode(this.manager, this, this.name).setRecipe(recipe)) + this.add_method(new ItemNode(this.manager, this, this.name).setRecipe(recipe)); } } @@ -263,7 +263,7 @@ class ItemWrapper { best_method = method; } } - return best_method + return best_method; } isDone(q=1) { diff --git a/src/agent/self_prompter.js b/src/agent/self_prompter.js index 3251f0ee6..7be70ea4c 100644 --- a/src/agent/self_prompter.js +++ b/src/agent/self_prompter.js @@ -1,6 +1,6 @@ -const STOPPED = 0 -const ACTIVE = 1 -const PAUSED = 2 +const STOPPED = 0; +const ACTIVE = 1; +const PAUSED = 2; export class SelfPrompter { constructor(agent) { this.agent = agent; @@ -58,7 +58,7 @@ export class SelfPrompter { console.warn('Self-prompt loop is already active. Ignoring request.'); return; } - console.log('starting self-prompt loop') + console.log('starting self-prompt loop'); this.loop_active = true; let no_command_count = 0; const MAX_NO_COMMAND = 3; @@ -81,7 +81,7 @@ export class SelfPrompter { await new Promise(r => setTimeout(r, this.cooldown)); } } - console.log('self prompt loop stopped') + console.log('self prompt loop stopped'); this.loop_active = false; this.interrupt = false; } @@ -109,7 +109,7 @@ export class SelfPrompter { // you can call this without await if you don't need to wait for it to finish if (this.interrupt) return; - console.log('stopping self-prompt loop') + console.log('stopping self-prompt loop'); this.interrupt = true; while (this.loop_active) { await new Promise(r => setTimeout(r, 500)); diff --git a/src/agent/speak.js b/src/agent/speak.js index 003655ea8..6d176178b 100644 --- a/src/agent/speak.js +++ b/src/agent/speak.js @@ -1,4 +1,4 @@ -import { exec, spawn } from 'child_process'; +import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -8,7 +8,7 @@ import { TTSConfig as geminiTTSConfig } from '../models/gemini.js'; let speakingQueue = []; // each item: {text, model, audioData, ready} let isSpeaking = false; -export function speak(text, speak_model) { +export async function speak(text, speak_model) { const model = speak_model || 'system'; const item = { text, model, audioData: null, ready: null }; @@ -23,7 +23,7 @@ export function speak(text, speak_model) { } speakingQueue.push(item); - if (!isSpeaking) processQueue(); + if (!isSpeaking) await processQueue(); } async function fetchRemoteAudio(txt, model) { @@ -61,10 +61,10 @@ async function processQueue() { return; } const item = speakingQueue.shift(); - const { text: txt, model, audioData } = item; + const { text: txt, model, audioData: _audioData } = item; if (txt.trim() === '') { isSpeaking = false; - processQueue(); + await processQueue(); return; } @@ -78,25 +78,36 @@ async function processQueue() { } catch (err) { console.error('[TTS] preprocess error', err); isSpeaking = false; - processQueue(); + await processQueue(); return; } if (model === 'system') { - // system TTS - const cmd = isWin - ? `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; \ - $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate=2; \ - $s.Speak('${txt.replace(/'/g,"''")}'); $s.Dispose()"` - : isMac - ? `say "${txt.replace(/"/g,'\\"')}"` - : `espeak "${txt.replace(/"/g,'\\"')}"`; - - exec(cmd, err => { - if (err) console.error('TTS error', err); - isSpeaking = false; - processQueue(); - }); + // system TTS — use spawn with argument arrays to prevent command injection + let proc; + if (isWin) { + // Pass text via stdin so it never touches the shell command line + const psScript = `Add-Type -AssemblyName System.Speech +$s = New-Object System.Speech.Synthesis.SpeechSynthesizer +$s.Rate = 2 +$txt = [System.Console]::In.ReadToEnd() +$s.Speak($txt) +$s.Dispose()`; + proc = spawn('powershell', ['-NoProfile', '-Command', psScript], + { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true }); + proc.stdin.write(txt, 'utf8'); + proc.stdin.end(); + } else if (isMac) { + // Guard against text starting with '-' which 'say' would treat as a flag. + // macOS 'say' does not support '--' as end-of-options, so prepend a space instead. + const safeTxt = txt.startsWith('-') ? ` ${txt}` : txt; + proc = spawn('say', [safeTxt], { stdio: 'ignore' }); + } else { + // '--' signals end of options so leading '-' in LLM text is not a flag. + proc = spawn('espeak', ['--', txt], { stdio: 'ignore' }); + } + proc.on('error', err => console.error('TTS error', err)); + proc.on('exit', async () => { isSpeaking = false; await processQueue(); }); } else { @@ -106,7 +117,7 @@ async function processQueue() { if (!audioData) { console.error('[TTS] No audio data ready'); isSpeaking = false; - processQueue(); + await processQueue(); return; } @@ -122,12 +133,12 @@ async function processQueue() { console.error('[TTS] ffplay error', err); try { await fs.unlink(tmpPath); } catch {} isSpeaking = false; - processQueue(); + await processQueue(); }); player.on('exit', async () => { try { await fs.unlink(tmpPath); } catch {} isSpeaking = false; - processQueue(); + await processQueue(); }); } else { @@ -136,15 +147,15 @@ async function processQueue() { }); player.stdin.write(Buffer.from(audioData, 'base64')); player.stdin.end(); - player.on('exit', () => { + player.on('exit', async () => { isSpeaking = false; - processQueue(); + await processQueue(); }); } } catch (e) { console.error('[TTS] Audio error', e); isSpeaking = false; - processQueue(); + await processQueue(); } } } diff --git a/src/agent/tasks/construction_tasks.js b/src/agent/tasks/construction_tasks.js index 0fcd3da81..28a97f963 100644 --- a/src/agent/tasks/construction_tasks.js +++ b/src/agent/tasks/construction_tasks.js @@ -140,7 +140,7 @@ export class Blueprint { return explanation; } check(bot) { - if (!bot || typeof bot !== 'object' || !bot.hasOwnProperty('blockAt')) { + if (!bot || typeof bot !== 'object' || !Object.prototype.hasOwnProperty.call(bot, 'blockAt')) { throw new Error('Invalid bot object. Expected a mineflayer bot.'); } const levels = this.data.levels; @@ -215,14 +215,14 @@ export class Blueprint { */ autoBuild() { const commands = []; - let blueprint = this.data + let blueprint = this.data; let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; let minZ = Infinity, maxZ = -Infinity; for (const level of blueprint.levels) { - console.log(level.level) + console.log(level.level); const baseX = level.coordinates[0]; const baseY = level.coordinates[1]; const baseZ = level.coordinates[2]; @@ -264,9 +264,9 @@ export class Blueprint { * */ autoDelete() { - console.log("auto delete called!") + console.log("auto delete called!"); const commands = []; - let blueprint = this.data + let blueprint = this.data; let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; @@ -348,7 +348,7 @@ export function proceduralGeneration(m = 20, ); // todo: extrapolate into another param? then have set materials be dynamic? - let roomMaterials = ["stone", "terracotta", "quartz_block", "copper_block", "purpur_block"] + let roomMaterials = ["stone", "terracotta", "quartz_block", "copper_block", "purpur_block"]; if (complexity < roomMaterials.length) { roomMaterials = roomMaterials.slice(0, complexity + 1); @@ -504,9 +504,9 @@ export function proceduralGeneration(m = 20, const matrixDepth = matrix.length; const matrixLength = matrix[0].length; const matrixWidth = matrix[0][0].length; - const windowX = Math.ceil(minRoomWidth / 2) - const windowY = Math.ceil(minRoomLength / 2) - const windowZ = Math.ceil(minRoomDepth / 2) + const windowX = Math.ceil(minRoomWidth / 2); + const windowY = Math.ceil(minRoomLength / 2); + const windowZ = Math.ceil(minRoomDepth / 2); // Helper function to check if coordinates are within bounds function isInBounds(z, x, y) { @@ -700,7 +700,7 @@ export function proceduralGeneration(m = 20, } } - function addLadder(matrix, x, y, z) { + function _addLadder(matrix, x, y, z) { let currentZ = z + 1; // turn the floor into air where person would go up @@ -735,10 +735,10 @@ export function proceduralGeneration(m = 20, case 0: break; case 1: - addWindowsAsSquares(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material) + addWindowsAsSquares(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material); break; case 2: - addWindowsAsPlane(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material) + addWindowsAsPlane(matrix, newZ, newY, newZ, newLength, newWidth, newDepth, material); } @@ -749,7 +749,7 @@ export function proceduralGeneration(m = 20, addCarpet(0.3, matrix, newX, newY, newZ, newLength, newWidth, material); break; case 2: - addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth, material) + addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth, material); break; } @@ -795,7 +795,7 @@ export function proceduralGeneration(m = 20, // Back side addDoor(matrix, newX + Math.floor(newLength / 2), newY + newWidth - 1, newZ, material); - addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth) + addCarpet(0.7, matrix, newX, newY, newZ, newLength, newWidth); } break; @@ -809,13 +809,13 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z + lastRoom.depth - 1; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); // addLadder(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), // lastRoom.y + Math.floor(lastRoom.width / 2), // newZ); // Adding the ladder - addStairs(matrix, newX, newY, newZ, newLength, newWidth, material) + addStairs(matrix, newX, newY, newZ, newLength, newWidth, material); lastRoom = {x: newX, y: newY, z: newZ, length: newLength, width: newWidth, depth: newDepth}; @@ -832,7 +832,7 @@ export function proceduralGeneration(m = 20, if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x, lastRoom.y + Math.floor(lastRoom.width / 2), lastRoom.z, material); @@ -851,7 +851,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + lastRoom.length - 1, @@ -872,7 +872,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), @@ -893,7 +893,7 @@ export function proceduralGeneration(m = 20, newZ = lastRoom.z; if (validateAndBuildBorder(matrix, newX, newY, newZ, newLength, newWidth, newDepth, m, n, p, material)) { - embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material) + embellishments(carpetStyle, windowStyle, matrix, newX, newY, newZ, newLength, newWidth, newDepth, material); addDoor(matrix, lastRoom.x + Math.floor(lastRoom.length / 2), @@ -924,7 +924,7 @@ export function proceduralGeneration(m = 20, // uncomment to visualize blueprint output // printMatrix(matrix) - return matrixToBlueprint(matrix, startCoord) + return matrixToBlueprint(matrix, startCoord); } @@ -934,7 +934,7 @@ export function proceduralGeneration(m = 20, * for cutesy output * @param matrix */ -function printMatrix(matrix) { +function _printMatrix(matrix) { matrix.forEach((layer, layerIndex) => { console.log(`Layer ${layerIndex}:`); layer.forEach(row => { @@ -949,7 +949,7 @@ function printMatrix(matrix) { case 'oak_stairs[facing=east]': return 'S'; // Stairs case 'oak_stairs[facing=south]': return 'S'; // Stairs case 'oak_stairs[facing=west]': return 'S'; // Stairs - case 'glass': return 'W' + case 'glass': return 'W'; default: return '?'; // Unknown or unmarked space @@ -970,7 +970,7 @@ function printMatrix(matrix) { function matrixToBlueprint(matrix, startCoord) { // Validate inputs if (!Array.isArray(matrix) || !Array.isArray(startCoord) || startCoord.length !== 3) { - console.log(matrix) + console.log(matrix); throw new Error('Invalid input format'); } @@ -1038,24 +1038,24 @@ export async function worldToBlueprint(startCoord, y_amount, x_amount, z_amount, level: y, coordinates: coordinates, placement: placement - }) + }); } console.log(levels); const blueprint_data = { materials: materials, levels: levels - } - return blueprint_data + }; + return blueprint_data; } export function blueprintToTask(blueprint_data, num_agents) { - let initialInventory = {} + let initialInventory = {}; for (let j = 0; j < num_agents; j++) { initialInventory[JSON.stringify(j)] = {"diamond_pickaxe": 1, "diamond_axe": 1, "diamond_shovel": 1}; } let give_agent = 0; - console.log("materials", blueprint_data.materials) + console.log("materials", blueprint_data.materials); for (const key of Object.keys(blueprint_data.materials)) { initialInventory[JSON.stringify(give_agent)][key] = blueprint_data.materials[key]; give_agent = (give_agent + 1) % num_agents; diff --git a/src/agent/tasks/cooking_tasks.js b/src/agent/tasks/cooking_tasks.js index a88e3ac23..95a229958 100644 --- a/src/agent/tasks/cooking_tasks.js +++ b/src/agent/tasks/cooking_tasks.js @@ -154,7 +154,7 @@ export class CookingTaskInitiator { // // Place the chest // await bot.chat(`/setblock ${x} ${y} ${z} chest`); - const cookingItems = [ + const _cookingItems = [ ['minecraft:milk_bucket', 1], // Non-stackable ['minecraft:egg', 16], // Stacks to 16 ['minecraft:dandelion', 64], // Stacks to 64 @@ -348,10 +348,10 @@ export class CookingTaskInitiator { await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 3} crafting_table`); await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 5} furnace`); // Add fuel to the furnace - await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 5} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`) + await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 5} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`); await this.bot.chat(`/setblock ${startX + 4} ${startY + 1} ${startZ + 7} smoker`); // Add fuel to the smoker - await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 7} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`) + await this.bot.chat(`/data merge block ${startX + 4} ${startY + 1} ${startZ + 7} {Items:[{Slot:1b,id:"minecraft:coal",Count:64b}]}`); await this.bot.chat(`/setblock ${startX + depth - 3} ${startY + 1} ${startZ + 2} bed`); await new Promise(resolve => setTimeout(resolve, 300)); } diff --git a/src/agent/tasks/tasks.js b/src/agent/tasks/tasks.js index b82540e1b..41c67263d 100644 --- a/src/agent/tasks/tasks.js +++ b/src/agent/tasks/tasks.js @@ -298,11 +298,11 @@ export class Task { } this.name = this.agent.name; - this.available_agents = [] + this.available_agents = []; } updateAvailableAgents(agents) { - this.available_agents = agents + this.available_agents = agents; } // Add this method if you want to manually reset the hells_kitchen progress @@ -344,7 +344,7 @@ export class Task { if (this.task_type === 'techtree') { if (this.data.agent_count > 2) { - add_string = '\nMake sure to share resources among all agents and to talk to all the agents using startConversation command to coordinate the task instead of talking to just one agent. You can even end current conversation with any agent using endConversation command and then talk to a new agent using startConversation command.' + add_string = '\nMake sure to share resources among all agents and to talk to all the agents using startConversation command to coordinate the task instead of talking to just one agent. You can even end current conversation with any agent using endConversation command and then talk to a new agent using startConversation command.'; } } @@ -374,7 +374,7 @@ export class Task { // this.agent.bot.chat(`/clear @a`); return {"message": 'Task successful', "score": res.score}; } - let other_names = this.available_agents.filter(n => n !== this.name); + let _other_names = this.available_agents.filter(n => n !== this.name); const elapsedTime = (Date.now() - this.taskStartTime) / 1000; if (elapsedTime >= 30 && this.available_agents.length !== this.data.agent_count) { @@ -439,14 +439,13 @@ export class Task { initialInventory = this.data.initial_inventory[this.agent.count_id.toString()] || {}; console.log("Initial inventory for agent", this.agent.count_id, ":", initialInventory); - console.log("") + console.log(""); if (this.data.human_count > 0 && this.agent.count_id === 0) { // this.num_humans = num_keys - this.data.num_agents; if (this.data.human_count !== this.data.usernames.length) { console.log(`Number of human players ${this.human_count} does not match the number of usernames provided. ${this.data.usernames.length}`); throw new Error(`Number of human players ${this.human_count} does not match the number of usernames provided. ${this.data.usernames.length}`); - return; } const starting_idx = this.data.agent_count; @@ -522,18 +521,18 @@ export class Task { const player = bot.players[playerName]; if (!this.available_agents.some((n) => n === playerName)) { console.log('Found human player:', player.username); - human_player_name = player.username + human_player_name = player.username; break; } } // go the human if there is one and not required for the task if (human_player_name && this.data.human_count === 0) { - console.log(`Teleporting ${this.name} to human ${human_player_name}`) - bot.chat(`/tp ${this.name} ${human_player_name}`) + console.log(`Teleporting ${this.name} to human ${human_player_name}`); + bot.chat(`/tp ${this.name} ${human_player_name}`); } else { - console.log(`Teleporting ${this.name} to ${this.available_agents[0]}`) + console.log(`Teleporting ${this.name} to ${this.available_agents[0]}`); bot.chat(`/tp ${this.name} ${this.available_agents[0]}`); } @@ -587,7 +586,7 @@ export class Task { } } else{ - console.log('no construction blueprint?') + console.log('no construction blueprint?'); } } } diff --git a/src/agent/vision/browser_viewer.js b/src/agent/vision/browser_viewer.js index 6cce3ed03..617f6c1b4 100644 --- a/src/agent/vision/browser_viewer.js +++ b/src/agent/vision/browser_viewer.js @@ -1,8 +1,13 @@ import settings from '../settings.js'; -import prismarineViewer from 'prismarine-viewer'; -const mineflayerViewer = prismarineViewer.mineflayer; export function addBrowserViewer(bot, count_id) { - if (settings.render_bot_view) - mineflayerViewer(bot, { port: 3000+count_id, firstPerson: true, }); + if (settings.render_bot_view) { + import('prismarine-viewer').then(({ default: prismarineViewer }) => { + const mineflayerViewer = prismarineViewer.mineflayer; + mineflayerViewer(bot, { host: '0.0.0.0', port: 3000+count_id, firstPerson: true }); + }).catch((err) => { + console.warn(`[BrowserViewer] Failed to load prismarine-viewer: ${err.message}`); + console.warn('[BrowserViewer] render_bot_view disabled — canvas native module not available.'); + }); + } } \ No newline at end of file diff --git a/src/agent/vision/camera.js b/src/agent/vision/camera.js index 6074b1d77..370ceb9b6 100644 --- a/src/agent/vision/camera.js +++ b/src/agent/vision/camera.js @@ -23,22 +23,36 @@ export class Camera extends EventEmitter { this.canvas = createCanvas(this.width, this.height); this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas }); this.viewer = new Viewer(this.renderer); + this.ready = false; this._init().then(() => { + this.ready = true; this.emit('ready'); - }) + }).catch((err) => { + console.warn(`[Camera] Async init failed: ${err.message}`); + this.emit('error', err); + }); } async _init () { const botPos = this.bot.entity.position; const center = new Vec3(botPos.x, botPos.y+this.bot.entity.height, botPos.z); this.viewer.setVersion(this.bot.version); - // Load world + // Init worldView and scene before hooking entity events — + // listenToBot immediately emits entitySpawn for existing entities, + // which crashes if the viewer scene isn't initialized yet const worldView = new WorldView(this.bot.world, this.viewDistance, center); + await worldView.init(center); this.viewer.listen(worldView); worldView.listenToBot(this.bot); - await worldView.init(center); this.worldView = worldView; } + + destroy() { + if (this.worldView) { + this.worldView.removeListenersFromBot(this.bot); + this.worldView = null; + } + } async capture() { const center = new Vec3(this.bot.entity.position.x, this.bot.entity.position.y+this.bot.entity.height, this.bot.entity.position.z); @@ -68,7 +82,7 @@ export class Camera extends EventEmitter { let stats; try { stats = await fs.stat(this.fp); - } catch (e) { + } catch (_e) { if (!stats?.isDirectory()) { await fs.mkdir(this.fp); } diff --git a/src/agent/vision/vision_interpreter.js b/src/agent/vision/vision_interpreter.js index a43acd208..777007dc0 100644 --- a/src/agent/vision/vision_interpreter.js +++ b/src/agent/vision/vision_interpreter.js @@ -1,5 +1,4 @@ import { Vec3 } from 'vec3'; -import { Camera } from "./camera.js"; import fs from 'fs'; export class VisionInterpreter { @@ -8,13 +7,34 @@ export class VisionInterpreter { this.allow_vision = allow_vision; this.fp = './bots/'+agent.name+'/screenshots/'; if (allow_vision) { - this.camera = new Camera(agent.bot, this.fp); + import("./camera.js").then(({ Camera }) => { + try { + this.camera = new Camera(agent.bot, this.fp); + this.camera.on('error', (err) => { + console.warn(`[Vision] Camera async init failed: ${err.message}`); + console.warn('[Vision] Vision disabled — bots will continue without screenshot capability.'); + this.allow_vision = false; + if (this.camera) this.camera.destroy(); + this.camera = null; + }); + } catch (err) { + console.warn(`[Vision] Camera init failed (WebGL not available): ${err.message}`); + console.warn('[Vision] Vision disabled — bots will continue without screenshot capability.'); + this.allow_vision = false; + this.camera = null; + } + }).catch((err) => { + console.warn(`[Vision] Failed to load camera module: ${err.message}`); + console.warn('[Vision] Vision disabled — prismarine-viewer/canvas not available.'); + this.allow_vision = false; + this.camera = null; + }); } } async lookAtPlayer(player_name, direction) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + if (!this.allow_vision || !this.camera || !this.agent.prompter.vision_model?.sendVisionRequest) { + return "Vision is disabled or camera not ready. Use other methods to describe the environment."; } let result = ""; const bot = this.agent.bot; @@ -39,8 +59,8 @@ export class VisionInterpreter { } async lookAtPosition(x, y, z) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + if (!this.allow_vision || !this.camera || !this.agent.prompter.vision_model?.sendVisionRequest) { + return "Vision is disabled or camera not ready. Use other methods to describe the environment."; } let result = ""; const bot = this.agent.bot; diff --git a/src/ensemble/arbiter.js b/src/ensemble/arbiter.js new file mode 100644 index 000000000..245528a4c --- /dev/null +++ b/src/ensemble/arbiter.js @@ -0,0 +1,156 @@ +import { commandExists } from '../agent/commands/index.js'; + +export class Arbiter { + /** + * @param {Object} config + * @param {string} config.strategy - "heuristic" (Phase 1) or "llm_judge" (Phase 2) + * @param {number} config.majority_bonus - score boost for majority command (default 0.2) + * @param {number} config.latency_penalty_per_sec - penalty per second of latency (default 0.02) + */ + constructor(config = {}) { + this.strategy = config.strategy || 'heuristic'; + this.majorityBonus = config.majority_bonus ?? 0.2; + this.latencyPenalty = config.latency_penalty_per_sec ?? 0.02; + this._confidenceThreshold = config.confidence_threshold ?? 0.08; + this._lastConfidence = 1.0; // set after each pick() + } + + /** + * Confidence threshold for triggering LLM judge. + * If top 2 scores are within this margin, it's "low confidence". + */ + get confidenceThreshold() { + return this._confidenceThreshold ?? 0.08; + } + + /** + * Pick the best proposal from the panel's responses. + * Also sets `this._lastConfidence` for the controller to check. + * @param {Proposal[]} proposals - all proposals (may include failures) + * @returns {Proposal} - the winning proposal with `score` and `winReason` set + */ + pick(proposals) { + const successful = proposals.filter(p => p.status === 'success'); + + if (successful.length === 0) { + return { + agentId: 'none', + modelName: 'none', + response: "I'm having trouble thinking right now. Let me try again in a moment.", + command: null, + commandArgs: null, + preCommandText: '', + latencyMs: 0, + status: 'error', + error: 'All panel members failed', + score: 0, + winReason: 'fallback' + }; + } + + // Score each proposal + for (const p of successful) { + p.score = this._scoreProposal(p); + } + + // Find majority command and apply bonus + const majorityCommand = this._findMajorityCommand(successful); + if (majorityCommand) { + for (const p of successful) { + if (p.command === majorityCommand) { + p.score += this.majorityBonus; + } + } + } + + // Apply latency penalty (tiebreaker) + for (const p of successful) { + p.score -= this.latencyPenalty * (p.latencyMs / 1000); + } + + // Sort: highest score first, then fastest (lowest latency) + successful.sort((a, b) => { + if (Math.abs(b.score - a.score) > 0.001) return b.score - a.score; + return a.latencyMs - b.latencyMs; + }); + + const winner = successful[0]; + winner.winReason = majorityCommand && winner.command === majorityCommand + ? 'majority+highest_score' + : 'highest_score'; + + // Compute confidence: margin between top 2 scores + this._lastConfidence = successful.length >= 2 + ? successful[0].score - successful[1].score + : 1.0; + + return winner; + } + + /** + * Returns true if the last pick() result had low confidence + * and an LLM judge should be consulted. + */ + isLowConfidence() { + return this._lastConfidence < this._confidenceThreshold; + } + + /** + * Compute heuristic score for a proposal. + * @param {Proposal} proposal + * @returns {number} score between 0.0 and ~1.0 + */ + _scoreProposal(proposal) { + let score = 0; + const r = proposal.response || ''; + + // Non-empty response + if (r.trim().length > 0) score += 0.10; + + // Contains a command + if (proposal.command) score += 0.25; + + // Command exists in the game's registry + if (proposal.command && commandExists(proposal.command)) score += 0.15; + + // No hallucination markers + const hallucinations = ['(FROM OTHER BOT)', 'My brain disconnected', 'Error:']; + if (!hallucinations.some(h => r.includes(h))) score += 0.15; + + // Reasonable length (not too short, not too long) + if (r.length > 5 && r.length < 2000) score += 0.10; + + // Not a tab-only or whitespace-only response + if (r.trim().length > 1) score += 0.10; + + // Has pre-command reasoning text (shows the model "thought") + if (proposal.preCommandText && proposal.preCommandText.trim().length > 0) score += 0.05; + + // Response contains actual content words (not just a command) + if (r.replace(/![a-zA-Z]+\(.*?\)/g, '').trim().length > 3) score += 0.10; + + return score; + } + + /** + * Find the command that appears most among proposals. + * @param {Proposal[]} proposals - successful proposals only + * @returns {string|null} majority command or null + */ + _findMajorityCommand(proposals) { + const commands = proposals.map(p => p.command).filter(Boolean); + if (commands.length === 0) return null; + + const counts = {}; + for (const c of commands) { + counts[c] = (counts[c] || 0) + 1; + } + + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); + // Majority: top command appears more than once AND strictly more than runner-up + if (sorted[0][1] > 1 && (sorted.length === 1 || sorted[0][1] > sorted[1][1])) { + return sorted[0][0]; + } + return null; + } +} diff --git a/src/ensemble/controller.js b/src/ensemble/controller.js new file mode 100644 index 000000000..3479793c6 --- /dev/null +++ b/src/ensemble/controller.js @@ -0,0 +1,194 @@ +import { Panel } from './panel.js'; +import { Arbiter } from './arbiter.js'; +import { LLMJudge } from './judge.js'; +import { EnsembleLogger } from './logger.js'; +import { FeedbackCollector } from './feedback.js'; + +/** + * EnsembleModel — implements the same interface as any single model class + * (Gemini, Grok, etc.) so it can be used as a drop-in replacement for chat_model + * in the Prompter class. + * + * Instead of a single LLM call, it queries a panel of models in parallel, + * runs an arbiter to pick the best response, and returns the winning string. + */ +export class EnsembleModel { + static prefix = 'ensemble'; + + /** + * @param {Object} ensembleConfig - the profile.ensemble configuration block + * @param {Object} profile - the full bot profile (for context/name) + */ + constructor(ensembleConfig, profile) { + this.model_name = 'ensemble'; + this.profile = profile; + + this.panel = new Panel( + ensembleConfig.panel, + ensembleConfig.timeout_ms || 15000 + ); + this.arbiter = new Arbiter(ensembleConfig.arbiter || {}); + this.judge = ensembleConfig.judge !== false + ? new LLMJudge(ensembleConfig.judge || {}) + : null; + this.logger = new EnsembleLogger(profile.name); + this.feedback = new FeedbackCollector(); + + this.minResponses = ensembleConfig.min_responses || 2; + this.logDecisions = ensembleConfig.log_decisions !== false; + + // Usage tracking compatibility (Prompter reads this after each call) + this._lastUsage = null; + this._lastUsageByModel = null; + + console.log(`[Ensemble] Initialized for ${profile.name}: ${this.panel.members.length} panel members`); + } + + /** + * Phase 3: inject the shared embedding model into FeedbackCollector. + * Called by Prompter after both chat_model and embedding_model are ready. + */ + setEmbeddingModel(embeddingModel) { + this.feedback.setEmbeddingModel(embeddingModel); + } + + /** + * Standard model interface — called by Prompter.promptConvo(). + * Queries all panel members, arbitrates, returns winning response. + * + * @param {Array<{role:string, content:string}>} turns - conversation history + * @param {string} systemMessage - the built system prompt + * @returns {Promise} - the winning response text + */ + async sendRequest(turns, systemMessage) { + const startTime = Date.now(); + + // Phase 3: retrieve similar past experiences to augment context + let augmentedSystem = systemMessage; + if (this.feedback.isReady) { + try { + const situationText = turns.filter(t => t.role === 'user').slice(-2) + .map(t => t.content).join(' '); + const experiences = await this.feedback.getSimilar(situationText, 3); + if (experiences.length > 0) { + const memBlock = experiences.map(e => { + const m = e.metadata; + const outcome = m.outcome && m.outcome !== 'pending' ? ` (outcome: ${m.outcome})` : ''; + return `- Situation: "${e.document.slice(0, 120)}" → action: ${m.winner_command || 'chat'}${outcome}`; + }).join('\n'); + augmentedSystem = systemMessage + `\n\n[PAST EXPERIENCE - similar situations]\n${memBlock}`; + console.log(`[Ensemble] Injected ${experiences.length} past experience(s) into context`); + } + } catch (err) { + console.warn(`[Ensemble] Failed to retrieve past experiences: ${err.message}`); + } + } + + // Query all panel members in parallel + const proposals = await this.panel.queryAll(turns, augmentedSystem); + + const successful = proposals.filter(p => p.status === 'success'); + const failed = proposals.filter(p => p.status !== 'success'); + + if (failed.length > 0) { + const failSummary = failed.map(p => `${p.agentId}:${p.status}`).join(', '); + console.log(`[Ensemble] Panel failures: ${failSummary}`); + } + + if (successful.length < this.minResponses) { + console.warn(`[Ensemble] Only ${successful.length}/${this.panel.members.length} responses (need ${this.minResponses})`); + if (successful.length === 0) { + this._lastUsage = null; + return "I'm having trouble processing right now. Let me try again."; + } + } + + // Heuristic arbiter — always runs first + let winner = this.arbiter.pick(proposals); + let judgeUsed = false; + + // Phase 2: LLM judge fallback when heuristic confidence is low + if (this.judge && this.arbiter.isLowConfidence() && successful.length >= 2) { + console.log(`[Ensemble] Low confidence (margin=${this.arbiter._lastConfidence.toFixed(3)}), consulting LLM judge...`); + try { + const judgeId = await this.judge.judge(successful, systemMessage, turns); + if (judgeId) { + const judgeWinner = successful.find(p => p.agentId === judgeId); + if (judgeWinner) { + judgeWinner.winReason = 'llm_judge'; + winner = judgeWinner; + judgeUsed = true; + console.log(`[Ensemble] Judge overruled heuristic: winner=${judgeId}`); + } + } + } catch (err) { + console.warn(`[Ensemble] Judge error, keeping heuristic winner: ${err.message}`); + } + } + + const totalMs = Date.now() - startTime; + console.log( + `[Ensemble] Decision in ${totalMs}ms: ` + + `${successful.length}/${this.panel.members.length} responded, ` + + `winner=${winner.agentId} (${winner.command || 'chat'}, score=${winner.score?.toFixed(2)})` + + (judgeUsed ? ' [judge]' : '') + ); + + // Log decision + if (this.logDecisions) { + this.logger.logDecision(proposals, winner); + } + + // Phase 3: Record decision in ChromaDB for continuous learning + const situationText = turns.filter(t => t.role === 'user').slice(-2) + .map(t => t.content).join(' '); + this.feedback.recordDecision({ + winner, + proposals, + timestamp: Date.now(), + situationText + }); + + // Aggregate usage from all successful members + this._lastUsage = this._aggregateUsage(successful); + this._lastUsageByModel = this._buildUsageBreakdown(successful); + + return winner.response; + } + + /** + * Embeddings are not supported by the ensemble — the Prompter uses + * a separate embedding model configured in the profile. + */ + async embed(_text) { + throw new Error('Embeddings not supported by EnsembleModel. Configure a separate embedding model in the profile.'); + } + + /** + * Sum token usage across all panel members for cost tracking. + */ + _aggregateUsage(proposals) { + let prompt = 0, completion = 0; + for (const p of proposals) { + if (p.usage) { + prompt += p.usage.prompt_tokens || 0; + completion += p.usage.completion_tokens || 0; + } + } + if (prompt === 0 && completion === 0) return null; + return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion }; + } + + _buildUsageBreakdown(proposals) { + const breakdown = []; + for (const p of proposals) { + if (!p.usage) continue; + breakdown.push({ + modelName: p.modelName || 'unknown', + provider: p.provider || 'unknown', + usage: p.usage + }); + } + return breakdown.length > 0 ? breakdown : null; + } +} diff --git a/src/ensemble/feedback.js b/src/ensemble/feedback.js new file mode 100644 index 000000000..cb8e653b2 --- /dev/null +++ b/src/ensemble/feedback.js @@ -0,0 +1,179 @@ +import { ChromaClient } from 'chromadb'; + +const COLLECTION_NAME = 'ensemble_memory'; +const CHROMADB_URL = process.env.CHROMADB_URL || 'http://localhost:8000'; + +/** Ensure embedding is a flat array of numbers */ +function flattenEmbedding(raw) { + if (!raw) return null; + // Already a flat number array + if (Array.isArray(raw) && (raw.length === 0 || typeof raw[0] === 'number')) return raw; + // Array of objects with values (e.g. [{values:[...]}]) + if (Array.isArray(raw) && raw[0]?.values) return raw[0].values; + // Object with values + if (raw.values && Array.isArray(raw.values)) return raw.values; + // Object with embedding.values + if (raw.embedding?.values) return raw.embedding.values; + return null; +} + +export class FeedbackCollector { + constructor() { + this._client = null; + this._collection = null; + this._ready = false; + this._embedFn = null; + this._decisionCount = 0; + this._lastDecisionId = null; + this._initAsync(); + } + + setEmbeddingModel(model) { + this._embedFn = async (text) => { + const raw = await model.embed(text); + return flattenEmbedding(raw); + }; + } + + async _initAsync() { + try { + this._client = new ChromaClient({ path: CHROMADB_URL }); + this._collection = await this._client.getOrCreateCollection({ + name: COLLECTION_NAME, + metadata: { 'hnsw:space': 'cosine' } + }); + this._ready = true; + console.log(`[Feedback] ChromaDB ready at ${CHROMADB_URL}, collection: ${COLLECTION_NAME}`); + } catch (err) { + console.warn(`[Feedback] ChromaDB unavailable (${err.message}). Running without vector memory.`); + this._ready = false; + } + } + + async recordDecision(decision) { + if (!this._ready || !this._embedFn) return; + + // Hoist variables so the catch block can access them for retry + let id = null; + let cleanEmb = null; + let text = ''; + let meta = null; + + try { + const { winner, proposals, situationText } = decision; + text = situationText || ''; + if (text.trim().length < 5) return; + + const embedding = await this._embedFn(text.slice(0, 512)); + if (!embedding || !Array.isArray(embedding) || embedding.length === 0) { + console.warn('[Feedback] Invalid embedding, skipping storage'); + return; + } + // Ensure all values are numbers + cleanEmb = embedding.map(v => Number(v)); + if (cleanEmb.some(v => !isFinite(v))) { + console.warn('[Feedback] Embedding contains non-finite values, skipping'); + return; + } + + this._decisionCount++; + const ts = Date.now(); + id = 'dec_' + ts + '_' + this._decisionCount; + this._lastDecisionId = id; + + const successful = proposals.filter(p => p.status === 'success'); + const rawCmd = winner.command; + const rawScore = winner.score; + + meta = { + winner_id: String(winner.agentId || 'unknown'), + winner_command: (rawCmd && typeof rawCmd === 'string') ? rawCmd : '', + winner_score: (typeof rawScore === 'number' && isFinite(rawScore)) ? rawScore : 0, + win_reason: String(winner.winReason || 'highest_score'), + panel_size: Number(proposals.length), + responders: Number(successful.length), + timestamp: Number(ts), + outcome: 'pending' + }; + + await this._collection.add({ + ids: [id], + embeddings: [cleanEmb], + documents: [text.slice(0, 512)], + metadatas: [meta] + }); + console.log('[Feedback] Decision stored in ChromaDB:', id); + } catch (err) { + if (err.message?.includes('already exists')) { + // Skip duplicate IDs + } else if (err.message?.includes('dimension') || err.message?.includes('shape') || err.message?.includes('mismatch')) { + // Dimension mismatch: delete and recreate collection + console.warn('[Feedback] Embedding dimension mismatch, recreating collection'); + try { + if (!this._client) throw new Error('ChromaDB client is null'); + await this._client.deleteCollection({ name: COLLECTION_NAME }); + this._collection = await this._client.createCollection({ + name: COLLECTION_NAME, + metadata: { 'hnsw:space': 'cosine' } + }); + // Retry the add + await this._collection.add({ + ids: [id], + embeddings: [cleanEmb], + documents: [text.slice(0, 512)], + metadatas: [meta] + }); + console.log('[Feedback] Decision stored after collection recreation:', id); + } catch (retryErr) { + console.warn('[Feedback] Failed to recreate collection and store decision:', retryErr.message); + } + } else { + console.warn('[Feedback] Failed to record decision:', err.message); + } + } + } + + async recordOutcome(outcome, details) { + if (!this._ready || !this._lastDecisionId) return; + try { + await this._collection.update({ + ids: [this._lastDecisionId], + metadatas: [{ outcome: String(outcome), outcome_detail: String(details || '').slice(0, 200) }] + }); + } catch (err) { + console.warn('[Feedback] Failed to update outcome:', err.message); + } + } + + async getSimilar(situationText, topK) { + if (!this._ready || !this._embedFn) return []; + if (!situationText || situationText.trim().length < 5) return []; + try { + const k = topK || 3; + const embedding = await this._embedFn(situationText.slice(0, 512)); + if (!embedding || !Array.isArray(embedding)) return []; + const cleanEmb = embedding.map(v => Number(v)); + + const results = await this._collection.query({ + queryEmbeddings: [cleanEmb], + nResults: Math.min(k, 10), + include: ['documents', 'metadatas', 'distances'] + }); + + const docs = results.documents?.[0] || []; + const metas = results.metadatas?.[0] || []; + const dists = results.distances?.[0] || []; + + return docs.map((doc, i) => ({ + document: doc, + metadata: metas[i] || {}, + similarity: 1 - (dists[i] || 0) + })).filter(r => r.similarity > 0.6); + } catch (err) { + console.warn('[Feedback] Failed to query similar:', err.message); + return []; + } + } + + get isReady() { return this._ready; } +} diff --git a/src/ensemble/judge.js b/src/ensemble/judge.js new file mode 100644 index 000000000..ac8660541 --- /dev/null +++ b/src/ensemble/judge.js @@ -0,0 +1,95 @@ +import { selectAPI, createModel } from '../models/_model_map.js'; + +/** + * LLM-as-Judge: when the heuristic arbiter has low confidence, + * a fast judge model reviews all proposals and picks the best one. + */ +export class LLMJudge { + /** + * @param {Object} config + * @param {string} config.model - model name to use as judge (e.g. "gemini-2.5-flash") + * @param {number} config.timeout_ms - max ms to wait for judge (default 10000) + */ + constructor(config = {}) { + this.modelName = config.model || 'gemini-2.5-flash'; + this.timeoutMs = config.timeout_ms || 10000; + this._model = null; + } + + _getModel() { + if (!this._model) { + const profile = selectAPI(this.modelName); + this._model = createModel(profile); + } + return this._model; + } + + /** + * Ask the judge to pick the best proposal. + * @param {Proposal[]} proposals - successful proposals only + * @param {string} systemMessage - the original system prompt (abbreviated) + * @param {Array} turns - last few conversation turns for context + * @returns {Promise} winning agentId, or null if judge fails + */ + async judge(proposals, systemMessage, turns) { + if (proposals.length === 0) return null; + if (proposals.length === 1) return proposals[0].agentId; + + const model = this._getModel(); + + // Build a concise judgment prompt + const lastUserMsg = [...turns].reverse().find(t => t.role === 'user')?.content || ''; + + const proposalText = proposals.map((p, _i) => + `[${p.agentId}] (${p.modelName})\n${p.response}` + ).join('\n\n---\n\n'); + + const judgeSystem = [ + 'You are an expert judge evaluating Minecraft bot AI responses.', + 'Pick the SINGLE best response for the current game situation.', + 'Consider: command correctness, relevance to context, clarity, and safety.', + 'Respond with ONLY the agent ID (e.g. "gemini_a"). No explanation.' + ].join('\n'); + + const judgePrompt = [ + `Current situation: ${lastUserMsg.slice(0, 300)}`, + '', + 'Responses to evaluate:', + proposalText, + '', + `Valid agent IDs: ${proposals.map(p => p.agentId).join(', ')}`, + 'Which response is best? Reply with only the agent ID.' + ].join('\n'); + + const judgeTurns = [{ role: 'user', content: judgePrompt }]; + + let timer = null; + try { + const result = await Promise.race([ + model.sendRequest(judgeTurns, judgeSystem), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('judge timeout')), this.timeoutMs); + }) + ]); + clearTimeout(timer); + + // Parse: extract first matching agent ID from the response + const validIds = proposals.map(p => p.agentId); + const trimmedResult = result.trim(); + // Try exact match first (model responded with just the ID) + if (validIds.includes(trimmedResult)) return trimmedResult; + // Fall back to word-boundary regex match + for (const id of validIds) { + const pattern = new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); + if (pattern.test(result)) return id; + } + + console.warn(`[Judge] Could not parse agent ID from response: "${result.slice(0, 100)}"`); + return null; + } catch (err) { + clearTimeout(timer); + console.warn(`[Judge] Failed: ${err.message}`); + return null; + } + } +} diff --git a/src/ensemble/logger.js b/src/ensemble/logger.js new file mode 100644 index 000000000..50b67bc67 --- /dev/null +++ b/src/ensemble/logger.js @@ -0,0 +1,114 @@ +import { writeFile, readFile, mkdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; + +const MAX_ENTRIES = 500; +const TRIM_TO = 400; + +export class EnsembleLogger { + constructor(agentName) { + this.agentName = agentName; + this.dir = `./bots/${agentName}`; + this.filePath = path.join(this.dir, 'ensemble_log.json'); + this.decisionCount = 0; + + this._ready = !existsSync(this.dir) + ? mkdir(this.dir, { recursive: true }) + : Promise.resolve(); + } + + async logDecision(allProposals, winner) { + this.decisionCount++; + + const successful = allProposals.filter(p => p.status === 'success'); + const commands = successful.map(p => p.command).filter(Boolean); + const uniqueCommands = [...new Set(commands)]; + const agreement = uniqueCommands.length <= 1 && commands.length > 0 + ? 1.0 + : commands.length > 0 + ? Math.max(...uniqueCommands.map(c => commands.filter(x => x === c).length)) / commands.length + : 0; + + const entry = { + timestamp: new Date().toISOString(), + decision_id: this.decisionCount, + proposals: allProposals.map(p => ({ + agent_id: p.agentId, + model: p.modelName, + status: p.status, + command: p.command || null, + pre_text: p.preCommandText ? p.preCommandText.slice(0, 100) : '', + score: p.score ?? null, + latency_ms: p.latencyMs, + error: p.error || null + })), + winner: winner ? { + agent_id: winner.agentId, + command: winner.command, + score: winner.score, + reason: winner.winReason || 'highest_score' + } : null, + majority_command: this._findMajority(commands), + panel_agreement: Math.round(agreement * 100) / 100 + }; + + await this._ready; + let log = await this._readLog(); + log.push(entry); + + if (log.length > MAX_ENTRIES) { + log = log.slice(log.length - TRIM_TO); + } + + try { + await writeFile(this.filePath, JSON.stringify(log, null, 2)); + } catch (err) { + console.error(`[Ensemble] Failed to write log: ${err.message}`); + } + } + + async getStats() { + const log = await this._readLog(); + const wins = {}; + let totalLatency = 0; + let latencyCount = 0; + + for (const entry of log) { + if (entry.winner?.agent_id) { + wins[entry.winner.agent_id] = (wins[entry.winner.agent_id] || 0) + 1; + } + for (const p of entry.proposals) { + if (p.status === 'success' && p.latency_ms) { + totalLatency += p.latency_ms; + latencyCount++; + } + } + } + + return { + total_decisions: log.length, + per_member_wins: wins, + avg_latency_ms: latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0 + }; + } + + async _readLog() { + try { + const raw = await readFile(this.filePath, 'utf8'); + return JSON.parse(raw); + } catch { + // file missing or corrupted — start fresh + } + return []; + } + + _findMajority(commands) { + if (commands.length === 0) return null; + const counts = {}; + for (const c of commands) { + counts[c] = (counts[c] || 0) + 1; + } + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); + return sorted[0][1] > 1 ? sorted[0][0] : null; + } +} diff --git a/src/ensemble/panel.js b/src/ensemble/panel.js new file mode 100644 index 000000000..4a7732722 --- /dev/null +++ b/src/ensemble/panel.js @@ -0,0 +1,146 @@ +import { selectAPI, createModel } from '../models/_model_map.js'; +import { containsCommand } from '../agent/commands/index.js'; + +/** + * @typedef {Object} Proposal + * @property {string} agentId - Panel member ID (e.g., "gemini_a") + * @property {string} modelName - Model name (e.g., "gemini-2.5-pro") + * @property {string} provider - Provider prefix (e.g., "gemini", "xai") + * @property {string} response - Raw response string from the model + * @property {string|null} command - Extracted command (e.g., "!attackEntity") or null + * @property {string} preCommandText - Text before the first command + * @property {number} latencyMs - Time taken for this model's response + * @property {string} status - "success" | "error" | "timeout" + * @property {string|null} error - Error message if status !== "success" + * @property {number|null} score - Set by Arbiter + */ + +export class Panel { + /** + * @param {Array<{id: string, model: string}>} memberConfigs - panel member definitions + * @param {number} timeoutMs - per-model timeout in ms (default 15000) + */ + constructor(memberConfigs, timeoutMs = 15000) { + this.timeoutMs = timeoutMs; + this.members = []; + + for (const config of memberConfigs) { + try { + const profile = selectAPI(config.model); + const model = createModel(profile); + this.members.push({ + id: config.id, + model: model, + modelName: config.model + }); + console.log(`[Ensemble Panel] Loaded: ${config.id} → ${config.model}`); + } catch (err) { + console.error(`[Ensemble Panel] Failed to load ${config.id} (${config.model}): ${err.message}`); + } + } + + if (this.members.length === 0) { + throw new Error('[Ensemble Panel] No panel members loaded. Check profile.ensemble.panel config.'); + } + + console.log(`[Ensemble Panel] Ready: ${this.members.length} members, ${this.timeoutMs}ms timeout`); + } + + /** + * Query all panel members in parallel with timeout. + * Uses Promise.allSettled — one failure won't block others. + * + * @param {Array} turns - conversation turns [{role, content}] + * @param {string} systemMessage - the system prompt + * @returns {Promise} - all proposals (includes failures) + */ + async queryAll(turns, systemMessage) { + const promises = this.members.map(member => this._queryMember(member, turns, systemMessage)); + const results = await Promise.allSettled(promises); + + return results.map((result, i) => { + if (result.status === 'fulfilled') { + return result.value; + } + // Promise rejected (shouldn't happen since _queryMember catches, but just in case) + return { + agentId: this.members[i].id, + modelName: this.members[i].modelName, + response: '', + command: null, + preCommandText: '', + latencyMs: this.timeoutMs, + status: 'error', + error: result.reason?.message || 'Unknown error', + score: null + }; + }); + } + + /** + * Query a single panel member with timeout protection. + * @param {Object} member - {id, model, modelName} + * @param {Array} turns + * @param {string} systemMessage + * @returns {Promise} + */ + async _queryMember(member, turns, systemMessage) { + const startTime = Date.now(); + let timer = null; + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timeout')), this.timeoutMs); + }); + + try { + const response = await Promise.race([ + member.model.sendRequest(turns, systemMessage), + timeoutPromise + ]); + + clearTimeout(timer); + const latencyMs = Date.now() - startTime; + const responseStr = typeof response === 'string' ? response : String(response || ''); + const command = containsCommand(responseStr); + + // Extract text before the command (pre-command reasoning) + let preCommandText = ''; + if (command) { + const cmdIndex = responseStr.indexOf(command); + if (cmdIndex > 0) { + preCommandText = responseStr.slice(0, cmdIndex).trim(); + } + } + + return { + agentId: member.id, + modelName: member.modelName, + provider: member.model.constructor?.prefix || 'unknown', + response: responseStr, + command: command, + preCommandText: preCommandText, + latencyMs: latencyMs, + status: 'success', + error: null, + score: null, + usage: member.model._lastUsage || null + }; + } catch (err) { + clearTimeout(timer); + const latencyMs = Date.now() - startTime; + const isTimeout = err.message === 'timeout'; + + return { + agentId: member.id, + modelName: member.modelName, + response: '', + command: null, + preCommandText: '', + latencyMs: latencyMs, + status: isTimeout ? 'timeout' : 'error', + error: err.message, + score: null + }; + } + } +} diff --git a/src/mindcraft/mindcraft.js b/src/mindcraft/mindcraft.js index 7fe7f9e37..d5d8e67b8 100644 --- a/src/mindcraft/mindcraft.js +++ b/src/mindcraft/mindcraft.js @@ -2,8 +2,10 @@ import { createMindServer, registerAgent, numStateListeners } from './mindserver import { AgentProcess } from '../process/agent_process.js'; import { getServer } from './mcserver.js'; import open from 'open'; +import path from 'path'; +import { writeFileSync } from 'fs'; -let mindserver; +let _mindserver; let connected = false; let agent_processes = {}; let agent_count = 0; @@ -14,7 +16,7 @@ export async function init(host_public=false, port=8080, auto_open_ui=true) { console.error('Already initiliazed!'); return; } - mindserver = createMindServer(host_public, port); + _mindserver = createMindServer(host_public, port); mindserver_port = port; connected = true; if (auto_open_ui) { @@ -74,6 +76,45 @@ export async function createAgent(settings) { }; } +export async function createRemoteAgent(settings, remoteUrl) { + settings = JSON.parse(JSON.stringify(settings)); + let agent_name = settings.profile.name; + if (!agent_name) { + console.error('Agent name is required in profile'); + return { success: false, error: 'Agent name is required in profile' }; + } + const agentIndex = agent_count++; + + try { + try { + const server = await getServer(settings.host, settings.port, settings.minecraft_version); + settings.host = server.host; + settings.port = server.port; + settings.minecraft_version = server.version; + } catch (error) { + console.warn(`Error getting server:`, error); + if (settings.minecraft_version === "auto") settings.minecraft_version = null; + console.warn(`Attempting to connect anyway...`); + } + + // RC30: Sanitize agent_name to prevent path traversal + const safeName = path.basename(agent_name).replace(/[^a-zA-Z0-9_-]/g, '_'); + if (!safeName) { + return { success: false, error: 'Invalid agent name after sanitization' }; + } + const settingsPath = `/tmp/mindcraft_${safeName}_settings.json`; + writeFileSync(settingsPath, JSON.stringify(settings)); + + const agentProcess = new AgentProcess(agent_name, null, remoteUrl, settingsPath); + agentProcess.start(settings.load_memory || false, settings.init_message || null, agentIndex); + agent_processes[agent_name] = agentProcess; + } catch (error) { + console.error(`Error creating remote agent ${agent_name}:`, error); + return { success: false, error: error.message }; + } + return { success: true, error: null }; +} + export function getAgentProcess(agentName) { return agent_processes[agentName]; } diff --git a/src/mindcraft/mindserver.js b/src/mindcraft/mindserver.js index 1397553ec..4fcf02bd8 100644 --- a/src/mindcraft/mindserver.js +++ b/src/mindcraft/mindserver.js @@ -4,7 +4,7 @@ import http from 'http'; import path from 'path'; import { fileURLToPath } from 'url'; import * as mindcraft from './mindcraft.js'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync } from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Mindserver is: @@ -19,6 +19,21 @@ const agent_listeners = []; const settings_spec = JSON.parse(readFileSync(path.join(__dirname, 'public/settings_spec.json'), 'utf8')); +function readUsageFromDisk(agentName) { + try { + const filePath = path.join(__dirname, `../../bots/${agentName}/usage.json`); + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, 'utf8'); + const data = JSON.parse(raw); + // Disk data won't have live RPM/TPM + data.rpm = 0; + data.tpm = 0; + return data; + } catch { + return null; + } +} + class AgentConnection { constructor(settings, viewer_port) { this.socket = null; @@ -26,6 +41,7 @@ class AgentConnection { this.in_game = false; this.full_state = null; this.viewer_port = viewer_port; + this.loginTime = null; } setSettings(settings) { this.settings = settings; @@ -40,6 +56,7 @@ export function registerAgent(settings, viewer_port) { export function logoutAgent(agentName) { if (agent_connections[agentName]) { agent_connections[agentName].in_game = false; + agent_connections[agentName].loginTime = null; agentsStatusUpdate(); } } @@ -114,10 +131,35 @@ export function createMindServer(host_public = false, port = 8080) { } }); + // Remote agent registration: allows an agent process running on another + // machine to register itself and appear in the MindServer UI. + socket.on('register-remote-agent', (agentSettings, callback) => { + const name = agentSettings?.profile?.name; + if (!name) { + callback({ error: 'Agent name is required in profile' }); + return; + } + if (agent_connections[name]) { + // Already registered — update settings from remote agent + // (remote agent may have different host/port than the server's own) + agent_connections[name].setSettings(agentSettings); + console.log(`Remote agent '${name}' re-registered (settings updated)`); + callback({ settings: agent_connections[name].settings }); + agentsStatusUpdate(); + return; + } + const viewerPort = 3000 + Object.keys(agent_connections).length; + registerAgent(agentSettings, viewerPort); + console.log(`Remote agent '${name}' registered on MindServer`); + callback({ settings: agent_connections[name].settings }); + agentsStatusUpdate(); + }); + socket.on('login-agent', (agentName) => { if (agent_connections[agentName]) { agent_connections[agentName].socket = socket; agent_connections[agentName].in_game = true; + agent_connections[agentName].loginTime = Date.now(); curAgentName = agentName; agentsStatusUpdate(); } @@ -130,6 +172,7 @@ export function createMindServer(host_public = false, port = 8080) { if (agent_connections[curAgentName]) { console.log(`Agent ${curAgentName} disconnected`); agent_connections[curAgentName].in_game = false; + agent_connections[curAgentName].loginTime = null; agent_connections[curAgentName].socket = null; agentsStatusUpdate(); } @@ -143,6 +186,10 @@ export function createMindServer(host_public = false, port = 8080) { console.warn(`Agent ${agentName} tried to send a message but is not logged in`); return; } + if (!agent_connections[agentName].socket) { + console.warn(`Agent ${agentName} has no socket connection`); + return; + } console.log(`${curAgentName} sending message to ${agentName}: ${json.message}`); agent_connections[agentName].socket.emit('chat-message', curAgentName, json); }); @@ -151,12 +198,20 @@ export function createMindServer(host_public = false, port = 8080) { const agent = agent_connections[agentName]; if (agent) { agent.setSettings(settings); + if (!agent.socket) { + console.warn(`Cannot restart agent ${agentName} after settings update: no socket connection`); + return; + } agent.socket.emit('restart-agent'); } }); socket.on('restart-agent', (agentName) => { console.log(`Restarting agent: ${agentName}`); + if (!agent_connections[agentName]?.socket) { + console.warn(`Cannot restart agent ${agentName}: no socket connection`); + return; + } agent_connections[agentName].socket.emit('restart-agent'); }); @@ -199,10 +254,10 @@ export function createMindServer(host_public = false, port = 8080) { socket.on('send-message', (agentName, data) => { if (!agent_connections[agentName]) { console.warn(`Agent ${agentName} not in game, cannot send message via MindServer.`); - return + return; } try { - agent_connections[agentName].socket.emit('send-message', data) + agent_connections[agentName].socket.emit('send-message', data); } catch (error) { console.error('Error: ', error); } @@ -215,6 +270,68 @@ export function createMindServer(host_public = false, port = 8080) { socket.on('listen-to-agents', () => { addListener(socket); }); + + socket.on('get-agent-usage', (agentName, callback) => { + const conn = agent_connections[agentName]; + // If agent is in-game, query live data with disk fallback on timeout + if (conn && conn.socket && conn.in_game) { + const timeout = setTimeout(() => { + const diskData = readUsageFromDisk(agentName); + callback(diskData ? { usage: diskData } : { error: 'Timeout' }); + }, 5000); + conn.socket.emit('get-usage', (data) => { + clearTimeout(timeout); + callback({ usage: data }); + }); + return; + } + // Agent offline or not registered — try reading from disk + const diskData = readUsageFromDisk(agentName); + if (diskData) { + callback({ usage: diskData }); + } else { + callback({ error: `No usage data for '${agentName}'.` }); + } + }); + + socket.on('get-all-usage', (callback) => { + const results = {}; + const promises = []; + for (const agentName in agent_connections) { + const conn = agent_connections[agentName]; + if (conn.socket && conn.in_game) { + // Live agent — query via socket + promises.push(new Promise((resolve) => { + const timeout = setTimeout(() => { + // Fallback to disk on timeout + const diskData = readUsageFromDisk(agentName); + if (diskData) results[agentName] = diskData; + resolve(); + }, 3000); + conn.socket.emit('get-usage', (data) => { + clearTimeout(timeout); + results[agentName] = data; + resolve(); + }); + })); + } else { + // Offline agent — read from disk + const diskData = readUsageFromDisk(agentName); + if (diskData) results[agentName] = diskData; + } + } + Promise.all(promises).then(() => callback(results)); + }); + }); + + // Health check endpoint + app.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + agents: Object.keys(agent_connections).length + }); }); let host = host_public ? '0.0.0.0' : 'localhost'; @@ -233,10 +350,11 @@ function agentsStatusUpdate(socket) { for (let agentName in agent_connections) { const conn = agent_connections[agentName]; agents.push({ - name: agentName, + name: agentName, in_game: conn.in_game, viewerPort: conn.viewer_port, - socket_connected: !!conn.socket + socket_connected: !!conn.socket, + loginTime: conn.loginTime || null }); }; socket.emit('agents-status', agents); diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index e233108da..13d81fdf5 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -1,6 +1,8 @@ - + + + Mindcraft
-

Mindcraft

+

Mindcraft

mindserver offline
@@ -320,11 +497,11 @@

Mindcraft