feat(perception): vendor mindcraft skills/world library + Pi tool wrappers

The bot was acting blind: it knew its own coordinates but nothing about
what was around it. mc_goto's over-strict safety guards refused every
real path. mc_dig was too low-level to drive a coherent farming loop.
The result: 4+ hours of "trying" with zero physical achievement.

This commit reframes the bridge around a perceive→decide→act loop using
proven primitives from Mindcraft (github.com/kolbytn/mindcraft, MIT —
LICENSE-MINDCRAFT vendored beside the library files).

Changes:

- extensions/lib/  (new, vendored from Mindcraft with attribution)
  - world.js  (431 LoC) — 21 perception functions: getNearbyBlockTypes,
    getNearbyEntities, getInventoryCounts, getNearestBlock, getPosition,
    getBiomeName, etc.
  - skills.js (2093 LoC) — 30+ action primitives: collectBlock, placeBlock,
    goToPosition, goToNearestBlock, craftRecipe, equip, consume,
    defendSelf, avoidEnemies, pickupNearbyItems, stay, etc.
  - mcdata.js (~600 LoC) — Mindcraft's mc-data adapter. Imports patched
    to local paths; mineflayer-auto-eat removed (our installed 5.x has
    a divergent API; skills.consume() falls back to bot.consume()).
    Added attachPluginsAndInit(bot) export so mineflayer-bridge.ts can
    wire plugins onto its externally-created bot.
  - settings.js — minimal stub with farmer-bot defaults.

- extensions/mindcraft-skills.ts (new, 406 LoC) — Pi extension registering
  15 high-level tools on top of the vendored library:
  - Perception: mc_observe, mc_inventory, mc_nearby_blocks, mc_nearby_entities
  - Action: mc_collect_block, mc_place_block, mc_go_to, mc_go_to_block,
    mc_craft, mc_equip, mc_consume, mc_defend_self, mc_avoid_enemies,
    mc_stay, mc_pickup_nearby
  ES modules from extensions/lib/ are loaded via dynamic import() at
  extension init so the cross-extension require()-race resolves cleanly.

- extensions/mineflayer-bridge.ts
  - Expose the live Mineflayer bot on globalThis.__pepaPiBot so the
    mindcraft-skills extension can use it (set on connect, cleared on
    error/end/manual disconnect).
  - Call attachPluginsAndInit(nextBot) right after createBot to load
    pathfinder, pvp, collectblock, armorManager and prime
    minecraft-data once login completes.

- package.json — new runtime deps: minecraft-data, vec3,
  mineflayer-pvp, prismarine-item.

- AGENTS.md — new "Perception → decision → action" section before
  "Your tools right now" with full tool catalog and a deprecation
  note for the broken mc_goto / mc_build_pyramid_5x5 / low-level
  mc_dig from the old bridge.

Smoke test (medium thinking): bot called mc_observe and received a
real JSON snapshot — nearbyBlocks listed coal_ore, oak_log, water,
sand; nearbyEntityTypes listed creeper, zombie, pillager, skeleton.
The bot can finally see what it could not see this morning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 14:24:23 +03:00
co-authored by Claude Opus 4.7
parent 7060daac46
commit 3e61b87f19
10 changed files with 3816 additions and 7 deletions
+40
View File
@@ -30,6 +30,46 @@ The two files that shape your day-to-day choices:
When no operator task is active and chat is quiet, you work towards the goal. See Operating principle #5 below for the priority order.
## Perception → decision → action (read FIRST before any world action)
You used to be blind: you knew your coords but nothing about what was around you. Now you have proper perception. The cycle is:
1. **Observe.** Call `mc_observe` first. You get a JSON snapshot of position, health, food, time of day, weather, biome, nearby block types, nearby entities (mobs + players + items), inventory counts.
2. **Decide.** Based on the snapshot, pick the **single** most relevant next action. Don't plan 5 steps deep; the snapshot will change after you act.
3. **Act.** Call ONE high-level tool from the catalog below.
4. **Loop.** Observe again. Append one short diary line if a milestone shifted.
### Tool catalog — mindcraft-skills.ts (use these)
**Perception (cheap, read-only):**
- `mc_observe(radius?)` — one-shot full snapshot. **Use this first in autonomous mode.**
- `mc_inventory()` — items as `{name: count}`.
- `mc_nearby_blocks(radius?)` — distinct block types within radius (default 16).
- `mc_nearby_entities(radius?)` — players, mobs, dropped items with approximate distances.
**Actions (write to world, may take seconds-to-minutes):**
- `mc_collect_block(blockType, count?)` — walk to a block of that type, equip the right tool, mine N of them, pick them up. The all-in-one "gather wood / stone / iron" primitive.
- `mc_place_block(blockType, x, y, z)` — place one block from inventory at exact coords.
- `mc_go_to(x, y, z, minDistance?)` — pathfinder navigation with permission to dig soft obstacles (leaves) and jump.
- `mc_go_to_block(blockType, minDistance?, range?)` — find nearest block of type within `range` and walk there.
- `mc_craft(itemName, num?)` — craft from inventory. Uses a nearby crafting table when needed.
- `mc_equip(itemName)` — hold a tool or wear armor.
- `mc_consume(itemName?)` — eat food. Empty arg = first food in inventory.
- `mc_defend_self(range?)` — attack hostile mobs within range until clear. Uses best weapon.
- `mc_avoid_enemies(distance?)` — run away from nearest hostiles by ~N blocks.
- `mc_stay(seconds?)` — stand still N seconds (default 30). Use to wait out night or regen.
- `mc_pickup_nearby()` — collect dropped items in vicinity.
### Deprecated (do not use)
- `mc_build_pyramid_5x5` — narrow-purpose, pre-perception era. Build via `mc_place_block` loops if needed.
- `mc_dig(x, y, z)` — too low-level. Use `mc_collect_block(blockType, n)` which handles the whole cycle.
- `mc_goto(x, y, z)` — had over-strict safety guards that refused legitimate paths. Replaced by `mc_go_to` from mindcraft-skills.
The deprecated tools may still appear in your registry for now; ignore them. They will be removed in a follow-up cleanup.
## Your tools right now
When you start, you have:
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kolby Nottingham
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+596
View File
@@ -0,0 +1,596 @@
import minecraftData from 'minecraft-data';
import settings from './settings.js';
import { createBot } from 'mineflayer';
import prismarine_items from 'prismarine-item';
import { pathfinder } from 'mineflayer-pathfinder';
import { plugin as pvp } from 'mineflayer-pvp';
import { plugin as collectblock } from 'mineflayer-collectblock';
// mineflayer-auto-eat removed: our installed 5.x exports a default plugin
// instead of a named one; we skip it entirely because skills.consume()
// works via bot.consume() on plain Mineflayer.
// import { plugin as autoEat } from 'mineflayer-auto-eat';
import plugin from 'mineflayer-armor-manager';
const armorManager = plugin;
let mc_version = settings.minecraft_version;
let mcdata = null;
let Item = null;
/**
* @typedef {string} ItemName
* @typedef {string} BlockName
*/
export const WOOD_TYPES = ['oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', 'mangrove', 'cherry'];
export const MATCHING_WOOD_BLOCKS = [
'log',
'planks',
'sign',
'boat',
'fence_gate',
'door',
'fence',
'slab',
'stairs',
'button',
'pressure_plate',
'trapdoor'
]
export const WOOL_COLORS = [
'white',
'orange',
'magenta',
'light_blue',
'yellow',
'lime',
'pink',
'gray',
'light_gray',
'cyan',
'purple',
'blue',
'brown',
'green',
'red',
'black'
]
export function initBot(username) {
const options = {
username: username,
host: settings.host,
port: settings.port,
auth: settings.auth,
version: mc_version,
checkTimeoutInterval: 60000, // 60s keep-alive check (default 30s) — reduces disconnects on slow servers
}
if (!mc_version || mc_version === "auto") {
delete options.version;
}
const bot = createBot(options);
// Throttle position packets to avoid kicks on Paper/Spigot servers
// Paper enforces stricter packet rate limits than vanilla, causing ECONNRESET
// when mineflayer sends position updates faster than 50ms apart
let lastPositionUpdate = 0;
let pendingPositionPacket = null;
const POSITION_THROTTLE_MS = 50;
const originalWrite = bot._client.write.bind(bot._client);
bot._client.write = function(name, data) {
if (name === 'position' || name === 'position_look' || name === 'look') {
const now = Date.now();
if (now - lastPositionUpdate < POSITION_THROTTLE_MS) {
// Queue this packet so the last position update is never lost
if (!pendingPositionPacket) {
pendingPositionPacket = setTimeout(() => {
pendingPositionPacket = null;
lastPositionUpdate = Date.now();
originalWrite(name, data);
}, POSITION_THROTTLE_MS - (now - lastPositionUpdate));
}
return;
}
lastPositionUpdate = now;
if (pendingPositionPacket) {
clearTimeout(pendingPositionPacket);
pendingPositionPacket = null;
}
}
return originalWrite(name, data);
};
// Suppress PartialReadError for non-critical packets
// Paper servers sometimes send packets that node-minecraft-protocol
// can't fully parse (scoreboard, resource_pack, custom_payload, etc.)
// These errors crash the bot but the packets aren't needed for gameplay
const originalEmit = bot._client.emit.bind(bot._client);
bot._client.emit = function(event, ...args) {
if (event === 'error' && args[0]) {
const err = args[0];
const errStr = err instanceof Error ? err.message : String(err);
if (errStr.includes('PartialReadError')) {
console.warn('[mcdata] Suppressed PartialReadError:', errStr.substring(0, 120));
return true; // Swallow the error
}
}
return originalEmit(event, ...args);
};
bot.loadPlugin(pathfinder);
bot.loadPlugin(pvp);
bot.loadPlugin(collectblock);
// bot.loadPlugin(autoEat); // removed — see import comment
bot.loadPlugin(armorManager); // auto equip armor
bot.once('resourcePack', () => {
bot.acceptResourcePack();
});
bot.once('login', () => {
mc_version = bot.version;
mcdata = minecraftData(mc_version);
Item = prismarine_items(mc_version);
});
return bot;
}
// pepa-pi-bot addition: attach pathing/combat/collect plugins to an
// EXTERNALLY-created bot (we use our own mineflayer.createBot() in
// mineflayer-bridge.ts). Mindcraft's initBot() above creates the bot itself;
// this exposes the same plugin+login wiring for our case.
//
// auto-eat is intentionally NOT loaded — our installed version is 5.x while
// Mindcraft's 3.x has diverged. skills.consume() falls back to bot.consume()
// which works on plain Mineflayer without the plugin.
export function attachPluginsAndInit(bot) {
bot.loadPlugin(pathfinder);
bot.loadPlugin(pvp);
bot.loadPlugin(collectblock);
bot.loadPlugin(armorManager);
bot.once('login', () => {
mc_version = bot.version;
mcdata = minecraftData(mc_version);
Item = prismarine_items(mc_version);
});
return bot;
}
export function isHuntable(mob) {
if (!mob || !mob.name) return false;
const animals = ['chicken', 'cow', 'llama', 'mooshroom', 'pig', 'rabbit', 'sheep'];
return animals.includes(mob.name.toLowerCase()) && !mob.metadata[16]; // metadata 16 is not baby
}
export function isHostile(mob) {
if (!mob || !mob.name) return false;
return (mob.type === 'mob' || mob.type === 'hostile') && mob.name !== 'iron_golem' && mob.name !== 'snow_golem';
}
// blocks that don't work with collectBlock, need to be manually collected
export function mustCollectManually(blockName) {
// all crops (that aren't normal blocks), torches, buttons, levers, redstone,
const full_names = ['wheat', 'carrots', 'potatoes', 'beetroots', 'nether_wart', 'cocoa', 'sugar_cane', 'kelp', 'short_grass', 'fern', 'tall_grass', 'bamboo',
'poppy', 'dandelion', 'blue_orchid', 'allium', 'azure_bluet', 'oxeye_daisy', 'cornflower', 'lilac', 'wither_rose', 'lily_of_the_valley', 'wither_rose',
'lever', 'redstone_wire', 'lantern']
const partial_names = ['sapling', 'torch', 'button', 'carpet', 'pressure_plate', 'mushroom', 'tulip', 'bush', 'vines', 'fern']
return full_names.includes(blockName.toLowerCase()) || partial_names.some(partial => blockName.toLowerCase().includes(partial));
}
export function getItemId(itemName) {
let item = mcdata.itemsByName[itemName];
if (item) {
return item.id;
}
return null;
}
export function getItemName(itemId) {
let item = mcdata.items[itemId]
if (item) {
return item.name;
}
return null;
}
export function getBlockId(blockName) {
let block = mcdata.blocksByName[blockName];
if (block) {
return block.id;
}
return null;
}
export function getBlockName(blockId) {
let block = mcdata.blocks[blockId]
if (block) {
return block.name;
}
return null;
}
export function getEntityId(entityName) {
let entity = mcdata.entitiesByName[entityName];
if (entity) {
return entity.id;
}
return null;
}
export function getAllItems(ignore) {
if (!ignore) {
ignore = [];
}
let items = []
for (const itemId in mcdata.items) {
const item = mcdata.items[itemId];
if (!ignore.includes(item.name)) {
items.push(item);
}
}
return items;
}
export function getAllItemIds(ignore) {
const items = getAllItems(ignore);
let itemIds = [];
for (const item of items) {
itemIds.push(item.id);
}
return itemIds;
}
export function getAllBlocks(ignore) {
if (!ignore) {
ignore = [];
}
let blocks = []
for (const blockId in mcdata.blocks) {
const block = mcdata.blocks[blockId];
if (!ignore.includes(block.name)) {
blocks.push(block);
}
}
return blocks;
}
export function getAllBlockIds(ignore) {
const blocks = getAllBlocks(ignore);
let blockIds = [];
for (const block of blocks) {
blockIds.push(block.id);
}
return blockIds;
}
export function getAllBiomes() {
return mcdata.biomes;
}
export function getItemCraftingRecipes(itemName) {
let itemId = getItemId(itemName);
if (!mcdata.recipes[itemId]) {
return null;
}
let recipes = [];
for (let r of mcdata.recipes[itemId]) {
let recipe = {};
let ingredients = [];
if (r.ingredients) {
ingredients = r.ingredients;
} else if (r.inShape) {
ingredients = r.inShape.flat();
}
for (let ingredient of ingredients) {
let ingredientName = getItemName(ingredient);
if (ingredientName === null) continue;
if (!recipe[ingredientName])
recipe[ingredientName] = 0;
recipe[ingredientName]++;
}
recipes.push([
recipe,
{craftedCount : r.result.count}
]);
}
// sort recipes by if their ingredients include common items
const commonItems = ['oak_planks', 'oak_log', 'coal', 'cobblestone'];
recipes.sort((a, b) => {
let commonCountA = Object.keys(a[0]).filter(key => commonItems.includes(key)).reduce((acc, key) => acc + a[0][key], 0);
let commonCountB = Object.keys(b[0]).filter(key => commonItems.includes(key)).reduce((acc, key) => acc + b[0][key], 0);
return commonCountB - commonCountA;
});
return recipes;
}
export function isSmeltable(itemName) {
const misc_smeltables = ['beef', 'chicken', 'cod', 'mutton', 'porkchop', 'rabbit', 'salmon', 'tropical_fish', 'potato', 'kelp', 'sand', 'cobblestone', 'clay_ball'];
return itemName.includes('raw') || itemName.includes('log') || misc_smeltables.includes(itemName);
}
export function getSmeltingFuel(bot) {
let fuel = bot.inventory.items().find(i => i.name === 'coal' || i.name === 'charcoal' || i.name === 'blaze_rod')
if (fuel)
return fuel;
fuel = bot.inventory.items().find(i => i.name.includes('log') || i.name.includes('planks'))
if (fuel)
return fuel;
return bot.inventory.items().find(i => i.name === 'coal_block' || i.name === 'lava_bucket');
}
export function getFuelSmeltOutput(fuelName) {
if (fuelName === 'coal' || fuelName === 'charcoal')
return 8;
if (fuelName === 'blaze_rod')
return 12;
if (fuelName.includes('log') || fuelName.includes('planks'))
return 1.5
if (fuelName === 'coal_block')
return 80;
if (fuelName === 'lava_bucket')
return 100;
return 0;
}
export function getItemSmeltingIngredient(itemName) {
return {
baked_potato: 'potato',
steak: 'raw_beef',
cooked_chicken: 'raw_chicken',
cooked_cod: 'raw_cod',
cooked_mutton: 'raw_mutton',
cooked_porkchop: 'raw_porkchop',
cooked_rabbit: 'raw_rabbit',
cooked_salmon: 'raw_salmon',
dried_kelp: 'kelp',
iron_ingot: 'raw_iron',
gold_ingot: 'raw_gold',
copper_ingot: 'raw_copper',
glass: 'sand'
}[itemName];
}
export function getItemBlockSources(itemName) {
let itemId = getItemId(itemName);
let sources = [];
for (let block of getAllBlocks()) {
if (block.drops.includes(itemId)) {
sources.push(block.name);
}
}
return sources;
}
export function getItemAnimalSource(itemName) {
return {
raw_beef: 'cow',
raw_chicken: 'chicken',
raw_cod: 'cod',
raw_mutton: 'sheep',
raw_porkchop: 'pig',
raw_rabbit: 'rabbit',
raw_salmon: 'salmon',
leather: 'cow',
wool: 'sheep'
}[itemName];
}
export function getBlockTool(blockName) {
let block = mcdata.blocksByName[blockName];
if (!block || !block.harvestTools) {
return null;
}
return getItemName(Object.keys(block.harvestTools)[0]); // Double check first tool is always simplest
}
export function makeItem(name, amount=1) {
return new Item(getItemId(name), amount);
}
/**
* Returns the number of ingredients required to use the recipe once.
*
* @param {Recipe} recipe
* @returns {Object<mc.ItemName, number>} an object describing the number of each ingredient.
*/
export function ingredientsFromPrismarineRecipe(recipe) {
let requiredIngedients = {};
if (recipe.inShape)
for (const ingredient of recipe.inShape.flat()) {
if(ingredient.id<0) continue; //prismarine-recipe uses id -1 as an empty crafting slot
const ingredientName = getItemName(ingredient.id);
requiredIngedients[ingredientName] ??=0;
requiredIngedients[ingredientName] += ingredient.count;
}
if (recipe.ingredients)
for (const ingredient of recipe.ingredients) {
if(ingredient.id<0) continue;
const ingredientName = getItemName(ingredient.id);
requiredIngedients[ingredientName] ??=0;
requiredIngedients[ingredientName] -= ingredient.count;
//Yes, the `-=` is intended.
//prismarine-recipe uses positive numbers for the shaped ingredients but negative for unshaped.
//Why this is the case is beyond my understanding.
}
return requiredIngedients;
}
/**
* Calculates the number of times an action, such as a crafing recipe, can be completed before running out of resources.
* @template T - doesn't have to be an item. This could be any resource.
* @param {Object.<T, number>} availableItems - The resources available; e.g, `{'cobble_stone': 7, 'stick': 10}`
* @param {Object.<T, number>} requiredItems - The resources required to complete the action once; e.g, `{'cobble_stone': 3, 'stick': 2}`
* @param {boolean} discrete - Is the action discrete?
* @returns {{num: number, limitingResource: (T | null)}} the number of times the action can be completed and the limmiting resource; e.g `{num: 2, limitingResource: 'cobble_stone'}`
*/
export function calculateLimitingResource(availableItems, requiredItems, discrete=true) {
let limitingResource = null;
let num = Infinity;
for (const itemType in requiredItems) {
if (availableItems[itemType] < requiredItems[itemType] * num) {
limitingResource = itemType;
num = availableItems[itemType] / requiredItems[itemType];
}
}
if(discrete) num = Math.floor(num);
return {num, limitingResource}
}
let loopingItems = new Set();
export function initializeLoopingItems() {
loopingItems = new Set(['coal',
'wheat',
'bone_meal',
'diamond',
'emerald',
'raw_iron',
'raw_gold',
'redstone',
'blue_wool',
'packed_mud',
'raw_copper',
'iron_ingot',
'dried_kelp',
'gold_ingot',
'slime_ball',
'black_wool',
'quartz_slab',
'copper_ingot',
'lapis_lazuli',
'honey_bottle',
'rib_armor_trim_smithing_template',
'eye_armor_trim_smithing_template',
'vex_armor_trim_smithing_template',
'dune_armor_trim_smithing_template',
'host_armor_trim_smithing_template',
'tide_armor_trim_smithing_template',
'wild_armor_trim_smithing_template',
'ward_armor_trim_smithing_template',
'coast_armor_trim_smithing_template',
'spire_armor_trim_smithing_template',
'snout_armor_trim_smithing_template',
'shaper_armor_trim_smithing_template',
'netherite_upgrade_smithing_template',
'raiser_armor_trim_smithing_template',
'sentry_armor_trim_smithing_template',
'silence_armor_trim_smithing_template',
'wayfinder_armor_trim_smithing_template']);
}
/**
* Gets a detailed plan for crafting an item considering current inventory
*/
export function getDetailedCraftingPlan(targetItem, count = 1, current_inventory = {}) {
initializeLoopingItems();
if (!targetItem || count <= 0 || !getItemId(targetItem)) {
return "Invalid input. Please provide a valid item name and positive count.";
}
if (isBaseItem(targetItem)) {
const available = current_inventory[targetItem] || 0;
if (available >= count) return "You have all required items already in your inventory!";
return `${targetItem} is a base item, you need to find ${count - available} more in the world`;
}
const inventory = { ...current_inventory };
const leftovers = {};
const plan = craftItem(targetItem, count, inventory, leftovers);
return formatPlan(targetItem, plan);
}
function isBaseItem(item) {
return loopingItems.has(item) || getItemCraftingRecipes(item) === null;
}
function craftItem(item, count, inventory, leftovers, crafted = { required: {}, steps: [], leftovers: {} }) {
// Check available inventory and leftovers first
const availableInv = inventory[item] || 0;
const availableLeft = leftovers[item] || 0;
const totalAvailable = availableInv + availableLeft;
if (totalAvailable >= count) {
// Use leftovers first, then inventory
const useFromLeft = Math.min(availableLeft, count);
leftovers[item] = availableLeft - useFromLeft;
const remainingNeeded = count - useFromLeft;
if (remainingNeeded > 0) {
inventory[item] = availableInv - remainingNeeded;
}
return crafted;
}
// Use whatever is available
const stillNeeded = count - totalAvailable;
if (availableLeft > 0) leftovers[item] = 0;
if (availableInv > 0) inventory[item] = 0;
if (isBaseItem(item)) {
crafted.required[item] = (crafted.required[item] || 0) + stillNeeded;
return crafted;
}
const recipe = getItemCraftingRecipes(item)?.[0];
if (!recipe) {
crafted.required[item] = stillNeeded;
return crafted;
}
const [ingredients, result] = recipe;
const craftedPerRecipe = result.craftedCount;
const batchCount = Math.ceil(stillNeeded / craftedPerRecipe);
const totalProduced = batchCount * craftedPerRecipe;
// Add excess to leftovers
if (totalProduced > stillNeeded) {
leftovers[item] = (leftovers[item] || 0) + (totalProduced - stillNeeded);
}
// Process each ingredient
for (const [ingredientName, ingredientCount] of Object.entries(ingredients)) {
const totalIngredientNeeded = ingredientCount * batchCount;
craftItem(ingredientName, totalIngredientNeeded, inventory, leftovers, crafted);
}
// Add crafting step
const stepIngredients = Object.entries(ingredients)
.map(([name, amount]) => `${amount * batchCount} ${name}`)
.join(' + ');
crafted.steps.push(`Craft ${stepIngredients} -> ${totalProduced} ${item}`);
return crafted;
}
function formatPlan(targetItem, { required, steps, leftovers }) {
const lines = [];
if (Object.keys(required).length > 0) {
lines.push('You are missing the following items:');
Object.entries(required).forEach(([item, count]) =>
lines.push(`- ${count} ${item}`));
lines.push('\nOnce you have these items, here\'s your crafting plan:');
} else {
lines.push('You have all items required to craft this item!');
lines.push('Here\'s your crafting plan:');
}
lines.push('');
lines.push(...steps);
if (Object.keys(required).some(item => item.includes('oak')) && !targetItem.includes('oak')) {
lines.push('Note: Any varient of wood can be used for this recipe.');
}
if (Object.keys(leftovers).length > 0) {
lines.push('\nYou will have leftover:');
Object.entries(leftovers).forEach(([item, count]) =>
lines.push(`- ${count} ${item}`));
}
return lines.join('\n');
}
+11
View File
@@ -0,0 +1,11 @@
// Minimal settings stub adapted from Mindcraft for the pepa-pi-bot vendored
// world.js / skills.js / mcdata.js library. Mindcraft expects a settings module
// with these defaults; we use sane farmer-bot values.
//
// Source: github.com/kolbytn/mindcraft (MIT)
export default {
block_place_delay: 0,
minecraft_version: "auto",
allow_insecure_coding: false,
log_all_prompts: false,
};
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
import pf from 'mineflayer-pathfinder';
import * as mc from './mcdata.js';
export function getNearestFreeSpace(bot, size=1, distance=8) {
/**
* Get the nearest empty space with solid blocks beneath it of the given size.
* @param {Bot} bot - The bot to get the nearest free space for.
* @param {number} size - The (size x size) of the space to find, default 1.
* @param {number} distance - The maximum distance to search, default 8.
* @returns {Vec3} - The south west corner position of the nearest free space.
* @example
* let position = world.getNearestFreeSpace(bot, 1, 8);
**/
let empty_pos = bot.findBlocks({
matching: (block) => {
return block && block.name == 'air';
},
maxDistance: distance,
count: 1000
});
for (let i = 0; i < empty_pos.length; i++) {
let empty = true;
for (let x = 0; x < size; x++) {
for (let z = 0; z < size; z++) {
let top = bot.blockAt(empty_pos[i].offset(x, 0, z));
let bottom = bot.blockAt(empty_pos[i].offset(x, -1, z));
if (!top || !top.name == 'air' || !bottom || bottom.drops.length == 0 || !bottom.diggable) {
empty = false;
break;
}
}
if (!empty) break;
}
if (empty) {
return empty_pos[i];
}
}
}
export function getBlockAtPosition(bot, x=0, y=0, z=0) {
/**
* Get a block from the bot's relative position
* @param {Bot} bot - The bot to get the block for.
* @param {number} x - The relative x offset to serach, default 0.
* @param {number} y - The relative y offset to serach, default 0.
* @param {number} y - The relative z offset to serach, default 0.
* @returns {Block} - The nearest block.
* @example
* let blockBelow = world.getBlockAtPosition(bot, 0, -1, 0);
* let blockAbove = world.getBlockAtPosition(bot, 0, 2, 0); since minecraft position is at the feet
**/
let block = bot.blockAt(bot.entity.position.offset(x, y, z));
if (!block) block = {name: 'air'};
return block;
}
export function getSurroundingBlocks(bot) {
/**
* Get the surrounding blocks from the bot's environment.
* @param {Bot} bot - The bot to get the block for.
* @returns {string[]} - A list of block results as strings.
* @example
**/
// Create a list of block position results that can be unpacked.
let res = [];
res.push(`Block Below: ${getBlockAtPosition(bot, 0, -1, 0).name}`);
res.push(`Block at Legs: ${getBlockAtPosition(bot, 0, 0, 0).name}`);
res.push(`Block at Head: ${getBlockAtPosition(bot, 0, 1, 0).name}`);
return res;
}
export function getFirstBlockAboveHead(bot, ignore_types=null, distance=32) {
/**
* Searches a column from the bot's position for the first solid block above its head
* @param {Bot} bot - The bot to get the block for.
* @param {string[]} ignore_types - The names of the blocks to ignore.
* @param {number} distance - The maximum distance to search, default 32.
* @returns {string} - The fist block above head.
* @example
* let firstBlockAboveHead = world.getFirstBlockAboveHead(bot, null, 32);
**/
// if ignore_types is not a list, make it a list.
let ignore_blocks = [];
if (ignore_types === null) ignore_blocks = ['air', 'cave_air'];
else {
if (!Array.isArray(ignore_types))
ignore_types = [ignore_types];
for(let ignore_type of ignore_types) {
if (mc.getBlockId(ignore_type)) ignore_blocks.push(ignore_type);
}
}
// The block above, stops when it finds a solid block .
let block_above = {name: 'air'};
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'};
// Ignore and continue
if (ignore_blocks.includes(block.name)) continue;
// Defaults to any block
block_above = block;
height = i;
break;
}
if (ignore_blocks.includes(block_above.name)) return 'none';
return `${block_above.name} (${height} blocks up)`;
}
export function getNearestBlocks(bot, block_types=null, distance=8, count=10000) {
/**
* Get a list of the nearest blocks of the given types.
* @param {Bot} bot - The bot to get the nearest block for.
* @param {string[]} block_types - The names of the blocks to search for.
* @param {number} distance - The maximum distance to search, default 16.
* @param {number} count - The maximum number of blocks to find, default 10000.
* @returns {Block[]} - The nearest blocks of the given type.
* @example
* let woodBlocks = world.getNearestBlocks(bot, ['oak_log', 'birch_log'], 16, 1);
**/
// if blocktypes is not a list, make it a list
let block_ids = [];
if (block_types === null) {
block_ids = mc.getAllBlockIds(['air']);
}
else {
if (!Array.isArray(block_types))
block_types = [block_types];
for(let block_type of block_types) {
block_ids.push(mc.getBlockId(block_type));
}
}
return getNearestBlocksWhere(bot, block_ids, distance, count);
}
export function getNearestBlocksWhere(bot, predicate, distance=8, count=10000) {
/**
* Get a list of the nearest blocks that satisfy the given predicate.
* @param {Bot} bot - The bot to get the nearest blocks for.
* @param {function} predicate - The predicate to filter the blocks.
* @param {number} distance - The maximum distance to search, default 16.
* @param {number} count - The maximum number of blocks to find, default 10000.
* @returns {Block[]} - The nearest blocks that satisfy the given predicate.
* @example
* let waterBlocks = world.getNearestBlocksWhere(bot, block => block.name === 'water', 16, 10);
**/
let positions = bot.findBlocks({matching: predicate, maxDistance: distance, count: count});
let blocks = positions.map(position => bot.blockAt(position));
return blocks;
}
export function getNearestBlock(bot, block_type, distance=16) {
/**
* Get the nearest block of the given type.
* @param {Bot} bot - The bot to get the nearest block for.
* @param {string} block_type - The name of the block to search for.
* @param {number} distance - The maximum distance to search, default 16.
* @returns {Block} - The nearest block of the given type.
* @example
* let coalBlock = world.getNearestBlock(bot, 'coal_ore', 16);
**/
let blocks = getNearestBlocks(bot, block_type, distance, 1);
if (blocks.length > 0) {
return blocks[0];
}
return null;
}
export function getNearbyEntities(bot, maxDistance=16) {
let entities = [];
for (const entity of Object.values(bot.entities)) {
const distance = entity.position.distanceTo(bot.entity.position);
if (distance > maxDistance) continue;
entities.push({ entity: entity, distance: distance });
}
entities.sort((a, b) => a.distance - b.distance);
let res = [];
for (let i = 0; i < entities.length; i++) {
res.push(entities[i].entity);
}
return res;
}
export function getNearestEntityWhere(bot, predicate, maxDistance=16) {
return bot.nearestEntity(entity => predicate(entity) && bot.entity.position.distanceTo(entity.position) < maxDistance);
}
export function getNearbyPlayers(bot, maxDistance) {
if (maxDistance == null) maxDistance = 16;
let players = [];
for (const entity of Object.values(bot.entities)) {
const distance = entity.position.distanceTo(bot.entity.position);
if (distance > maxDistance) continue;
if (entity.type == 'player' && entity.username != bot.username) {
players.push({ entity: entity, distance: distance });
}
}
players.sort((a, b) => a.distance - b.distance);
let res = [];
for (let i = 0; i < players.length; i++) {
res.push(players[i].entity);
}
return res;
}
// Helper function to get villager profession from metadata
export function getVillagerProfession(entity) {
// Villager profession mapping based on metadata
const professions = {
0: 'Unemployed',
1: 'Armorer',
2: 'Butcher',
3: 'Cartographer',
4: 'Cleric',
5: 'Farmer',
6: 'Fisherman',
7: 'Fletcher',
8: 'Leatherworker',
9: 'Librarian',
10: 'Mason',
11: 'Nitwit',
12: 'Shepherd',
13: 'Toolsmith',
14: 'Weaponsmith'
};
if (entity.metadata && entity.metadata[18]) {
// Check if metadata[18] is an object with villagerProfession property
if (typeof entity.metadata[18] === 'object' && entity.metadata[18].villagerProfession !== undefined) {
const professionId = entity.metadata[18].villagerProfession;
const level = entity.metadata[18].level || 1;
const professionName = professions[professionId] || 'Unknown';
return `${professionName} L${level}`;
}
// Fallback for direct profession ID
else if (typeof entity.metadata[18] === 'number') {
const professionId = entity.metadata[18];
return professions[professionId] || 'Unknown';
}
}
// If we can't determine profession but it's an adult villager
if (entity.metadata && entity.metadata[16] !== 1) { // Not a baby
return 'Adult';
}
return 'Unknown';
}
export function getInventoryCounts(bot) {
/**
* Get an object representing the bot's inventory.
* @param {Bot} bot - The bot to get the inventory for.
* @returns {object} - An object with item names as keys and counts as values.
* @example
* let inventory = world.getInventoryCounts(bot);
* let oakLogCount = inventory['oak_log'];
* let hasWoodenPickaxe = inventory['wooden_pickaxe'] > 0;
**/
let inventory = {};
for (const slot of bot.inventory.slots) {
if (slot != null && slot.name) {
if (inventory[slot.name] == null) {
inventory[slot.name] = 0;
}
inventory[slot.name] += slot.count;
}
}
return inventory;
}
export function getCraftableItems(bot) {
/**
* Get a list of all items that can be crafted with the bot's current inventory.
* @param {Bot} bot - The bot to get the craftable items for.
* @returns {string[]} - A list of all items that can be crafted.
* @example
* let craftableItems = world.getCraftableItems(bot);
**/
let table = getNearestBlock(bot, 'crafting_table');
if (!table) {
for (const item of bot.inventory.items()) {
if (item != null && item.name === 'crafting_table') {
table = item;
break;
}
}
}
let res = [];
for (const item of mc.getAllItems()) {
let recipes = bot.recipesFor(item.id, null, 1, table);
if (recipes.length > 0)
res.push(item.name);
}
return res;
}
export function getPosition(bot) {
/**
* Get your position in the world (Note that y is vertical).
* @param {Bot} bot - The bot to get the position for.
* @returns {Vec3} - An object with x, y, and x attributes representing the position of the bot.
* @example
* let position = world.getPosition(bot);
* let x = position.x;
**/
return bot.entity.position;
}
export function getNearbyEntityTypes(bot) {
/**
* Get a list of all nearby mob types.
* @param {Bot} bot - The bot to get nearby mobs for.
* @returns {string[]} - A list of all nearby mobs.
* @example
* let mobs = world.getNearbyEntityTypes(bot);
**/
let mobs = getNearbyEntities(bot, 16);
let found = [];
for (let i = 0; i < mobs.length; i++) {
if (!found.includes(mobs[i].name)) {
found.push(mobs[i].name);
}
}
return found;
}
export function isEntityType(name) {
/**
* Check if a given name is a valid entity type.
* @param {string} name - The name of the entity type to check.
* @returns {boolean} - True if the name is a valid entity type, false otherwise.
*/
return mc.getEntityId(name) !== null;
}
export function getNearbyPlayerNames(bot) {
/**
* Get a list of all nearby player names.
* @param {Bot} bot - The bot to get nearby players for.
* @returns {string[]} - A list of all nearby players.
* @example
* let players = world.getNearbyPlayerNames(bot);
**/
let players = getNearbyPlayers(bot, 64);
let found = [];
for (let i = 0; i < players.length; i++) {
if (!found.includes(players[i].username) && players[i].username != bot.username) {
found.push(players[i].username);
}
}
return found;
}
export function getNearbyBlockTypes(bot, distance=16) {
/**
* Get a list of all nearby block names.
* @param {Bot} bot - The bot to get nearby blocks for.
* @param {number} distance - The maximum distance to search, default 16.
* @returns {string[]} - A list of all nearby blocks.
* @example
* let blocks = world.getNearbyBlockTypes(bot);
**/
let blocks = getNearestBlocks(bot, null, distance);
let found = [];
for (let i = 0; i < blocks.length; i++) {
if (!found.includes(blocks[i].name)) {
found.push(blocks[i].name);
}
}
return found;
}
export async function isClearPath(bot, target) {
/**
* Check if there is a path to the target that requires no digging or placing blocks.
* @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';
}
export function shouldPlaceTorch(bot) {
if (!bot.modes.isOn('torch_placing') || bot.interrupt_code) return false;
const pos = getPosition(bot);
// TODO: check light level instead of nearby torches, block.light is broken
let nearest_torch = getNearestBlock(bot, 'torch', 6);
if (!nearest_torch)
nearest_torch = getNearestBlock(bot, 'wall_torch', 6);
if (!nearest_torch) {
const block = bot.blockAt(pos);
let has_torch = bot.inventory.findInventoryItem('torch');
return has_torch && block?.name === 'air';
}
return false;
}
export function getBiomeName(bot) {
/**
* Get the name of the biome the bot is in.
* @param {Bot} bot - The bot to get the biome for.
* @returns {string} - The name of the biome.
* @example
* let biome = world.getBiomeName(bot);
**/
const biomeId = bot.world.getBiome(bot.entity.position);
return mc.getAllBiomes()[biomeId].name;
}
+406
View File
@@ -0,0 +1,406 @@
// Mindcraft-backed Pi tools for pepa-pi-bot.
//
// Wraps battle-tested skill primitives from Mindcraft (MIT) — see
// extensions/lib/world.js, extensions/lib/skills.js, extensions/lib/mcdata.js,
// and extensions/lib/LICENSE-MINDCRAFT.
//
// This extension complements mineflayer-bridge.ts (auth, memory, chat,
// escalation, trust). It does NOT replace it; both extensions run side-by-side,
// registering disjoint Pi tools. Old broken mc_goto / mc_build_pyramid_5x5
// in bridge.ts are deprecated in favour of the perception+action set below.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// Lazy access to the Mineflayer bot instance held by mineflayer-bridge.ts.
function getBot(): any {
const bot = (globalThis as any).__pepaPiBot;
if (!bot) throw new Error("Mineflayer bot is not connected (or bridge has not exposed __pepaPiBot yet).");
return bot;
}
// ---- parameter schemas ----------------------------------------------------
const EMPTY_PARAMS = {
type: "object",
properties: {},
additionalProperties: false,
} as const;
const SCAN_PARAMS = {
type: "object",
properties: {
radius: { type: "number", description: "Block radius (default 16, max 48)." },
},
additionalProperties: false,
} as const;
const BLOCK_NAME_COUNT_PARAMS = {
type: "object",
properties: {
blockType: { type: "string", description: "Minecraft block name, e.g. 'oak_log', 'cobblestone'." },
count: { type: "number", description: "How many to collect. Default 1." },
},
required: ["blockType"],
additionalProperties: false,
} as const;
const PLACE_PARAMS = {
type: "object",
properties: {
blockType: { type: "string", description: "Block name in inventory to place." },
x: { type: "number" },
y: { type: "number" },
z: { type: "number" },
},
required: ["blockType", "x", "y", "z"],
additionalProperties: false,
} as const;
const GOTO_PARAMS = {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
z: { type: "number" },
minDistance: { type: "number", description: "Stop when within this distance (default 2)." },
},
required: ["x", "y", "z"],
additionalProperties: false,
} as const;
const GOTO_BLOCK_PARAMS = {
type: "object",
properties: {
blockType: { type: "string" },
minDistance: { type: "number" },
range: { type: "number", description: "Search radius. Default 64." },
},
required: ["blockType"],
additionalProperties: false,
} as const;
const CONSUME_PARAMS = {
type: "object",
properties: {
itemName: { type: "string", description: "Food name. Empty = any food in inventory." },
},
additionalProperties: false,
} as const;
const CRAFT_PARAMS = {
type: "object",
properties: {
itemName: { type: "string" },
num: { type: "number" },
},
required: ["itemName"],
additionalProperties: false,
} as const;
const EQUIP_PARAMS = {
type: "object",
properties: {
itemName: { type: "string" },
},
required: ["itemName"],
additionalProperties: false,
} as const;
const DEFEND_PARAMS = {
type: "object",
properties: {
range: { type: "number", description: "Reaction range. Default 9." },
},
additionalProperties: false,
} as const;
const AVOID_PARAMS = {
type: "object",
properties: {
distance: { type: "number", description: "Move this many blocks away. Default 16." },
},
additionalProperties: false,
} as const;
const STAY_PARAMS = {
type: "object",
properties: {
seconds: { type: "number" },
},
additionalProperties: false,
} as const;
// ---- helpers --------------------------------------------------------------
function clampRadius(r: number | undefined): number {
const v = typeof r === "number" && isFinite(r) ? r : 16;
return Math.max(1, Math.min(48, Math.floor(v)));
}
function textResult(text: string, details?: Record<string, unknown>) {
return { content: [{ type: "text", text }], details };
}
// ---- tool registration ----------------------------------------------------
export default async function mindcraftSkills(pi: ExtensionAPI) {
// Load vendored ESM Mindcraft libs at extension init. Pi awaits the default
// export, so registerTool calls below see fully-loaded modules.
const [skillsMod, worldMod] = await Promise.all([
import("./lib/skills.js" as any),
import("./lib/world.js" as any),
]);
const skills = skillsMod as Record<string, (...args: any[]) => any>;
const world = worldMod as Record<string, (...args: any[]) => any>;
const safeCall = async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {
try {
return await fn();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`${label}: ${msg}`);
}
};
// 1. mc_observe — one-shot perception snapshot.
pi.registerTool({
name: "mc_observe",
label: "Observe Surroundings",
description: "Return a compact JSON summary of nearby blocks, entities, inventory, position, health, food, weather, time of day. Always call before deciding a non-trivial action in autonomous mode.",
promptSnippet: "Get a single JSON snapshot of everything around the bot.",
parameters: SCAN_PARAMS,
async execute(_id, params: { radius?: number }) {
const bot = getBot();
const radius = clampRadius(params?.radius);
const blockTypes = world.getNearbyBlockTypes(bot, radius);
const entities = world.getNearbyEntities(bot, radius);
const players = world.getNearbyPlayerNames ? world.getNearbyPlayerNames(bot) : [];
const inv = world.getInventoryCounts(bot);
const pos = world.getPosition(bot);
const biome = (() => {
try { return world.getBiomeName(bot); } catch { return "unknown"; }
})();
const summary = {
position: { x: Math.round(pos.x), y: Math.round(pos.y), z: Math.round(pos.z) },
biome,
time: bot.time?.timeOfDay ?? null,
isDay: bot.time?.isDay ?? null,
weather: { rain: !!bot.isRaining, thunder: !!bot.thunderState },
health: bot.health,
food: bot.food,
saturation: bot.foodSaturation,
inventory: inv,
nearbyBlocks: blockTypes,
nearbyEntityTypes: Array.from(new Set(entities.map((e: any) => e?.name ?? e?.type).filter(Boolean))),
nearbyPlayers: players,
entityCount: entities.length,
};
return textResult(JSON.stringify(summary, null, 2), summary);
},
});
// 2. mc_inventory
pi.registerTool({
name: "mc_inventory",
label: "Inventory",
description: "List items in the bot's inventory as {name: count}.",
parameters: EMPTY_PARAMS,
async execute() {
const bot = getBot();
const inv = world.getInventoryCounts(bot);
return textResult(JSON.stringify(inv), { inventory: inv });
},
});
// 3. mc_nearby_blocks
pi.registerTool({
name: "mc_nearby_blocks",
label: "Nearby Block Types",
description: "List distinct nearby block types within radius (default 16). Useful for 'is there water/lava/log close?'.",
parameters: SCAN_PARAMS,
async execute(_id, params: { radius?: number }) {
const bot = getBot();
const types = world.getNearbyBlockTypes(bot, clampRadius(params?.radius));
return textResult(JSON.stringify(types), { blockTypes: types });
},
});
// 4. mc_nearby_entities
pi.registerTool({
name: "mc_nearby_entities",
label: "Nearby Entities",
description: "List entities (players, mobs, items, etc.) within radius (default 16). Returns name/type and approximate distance.",
parameters: SCAN_PARAMS,
async execute(_id, params: { radius?: number }) {
const bot = getBot();
const radius = clampRadius(params?.radius);
const entities = world.getNearbyEntities(bot, radius);
const me = bot.entity?.position;
const out = entities.map((e: any) => ({
type: e.name ?? e.type ?? "unknown",
distance: me && e.position ? Math.round(me.distanceTo(e.position)) : null,
username: e.username ?? null,
position: e.position ? { x: Math.round(e.position.x), y: Math.round(e.position.y), z: Math.round(e.position.z) } : null,
}));
return textResult(JSON.stringify(out), { entities: out });
},
});
// 5. mc_collect_block
pi.registerTool({
name: "mc_collect_block",
label: "Collect Block",
description: "Move to and break N blocks of a given type, picking them up. Handles path, tool equip, and dig. Throws if the block can't be found in range.",
parameters: BLOCK_NAME_COUNT_PARAMS,
executionMode: "sequential",
async execute(_id, params: { blockType: string; count?: number }) {
const bot = getBot();
const count = Math.max(1, Math.min(64, Math.floor(params.count ?? 1)));
const ok = await safeCall("collectBlock", () => skills.collectBlock(bot, params.blockType, count));
return textResult(ok ? `Collected ${count} of ${params.blockType}.` : `collectBlock returned false for ${params.blockType}.`, { ok, blockType: params.blockType, count });
},
});
// 6. mc_place_block
pi.registerTool({
name: "mc_place_block",
label: "Place Block",
description: "Place one block from inventory at the given coordinates. Throws if you do not have the block or the target is not placeable.",
parameters: PLACE_PARAMS,
executionMode: "sequential",
async execute(_id, params: { blockType: string; x: number; y: number; z: number }) {
const bot = getBot();
const ok = await safeCall("placeBlock", () => skills.placeBlock(bot, params.blockType, params.x, params.y, params.z));
return textResult(ok ? `Placed ${params.blockType} at ${params.x},${params.y},${params.z}.` : `placeBlock returned false.`, { ok, ...params });
},
});
// 7. mc_go_to
pi.registerTool({
name: "mc_go_to",
label: "Go To Coords",
description: "Walk/swim/path to the given coordinates. Will dig through soft obstacles (leaves) and jump as needed. Stop when within minDistance (default 2). Throws on noPath.",
parameters: GOTO_PARAMS,
executionMode: "sequential",
async execute(_id, params: { x: number; y: number; z: number; minDistance?: number }) {
const bot = getBot();
const ok = await safeCall("goToPosition", () => skills.goToPosition(bot, params.x, params.y, params.z, params.minDistance ?? 2));
return textResult(ok ? `Arrived near ${params.x},${params.y},${params.z}.` : `goToPosition returned false.`, { ok, ...params });
},
});
// 8. mc_go_to_block
pi.registerTool({
name: "mc_go_to_block",
label: "Go To Nearest Block",
description: "Find the nearest block of a given type within `range` (default 64) and walk to it.",
parameters: GOTO_BLOCK_PARAMS,
executionMode: "sequential",
async execute(_id, params: { blockType: string; minDistance?: number; range?: number }) {
const bot = getBot();
const ok = await safeCall("goToNearestBlock", () =>
skills.goToNearestBlock(bot, params.blockType, params.minDistance ?? 2, params.range ?? 64),
);
return textResult(ok ? `Arrived near nearest ${params.blockType}.` : `goToNearestBlock returned false for ${params.blockType}.`, { ok, ...params });
},
});
// 9. mc_craft
pi.registerTool({
name: "mc_craft",
label: "Craft",
description: "Craft an item by name from inventory. Will use a nearby crafting table when required. Throws if recipe unknown or resources missing.",
parameters: CRAFT_PARAMS,
executionMode: "sequential",
async execute(_id, params: { itemName: string; num?: number }) {
const bot = getBot();
const ok = await safeCall("craftRecipe", () => skills.craftRecipe(bot, params.itemName, Math.max(1, Math.floor(params.num ?? 1))));
return textResult(ok ? `Crafted ${params.itemName}.` : `craftRecipe returned false.`, { ok, ...params });
},
});
// 10. mc_equip
pi.registerTool({
name: "mc_equip",
label: "Equip Item",
description: "Equip an item by name (tool, armor, food).",
parameters: EQUIP_PARAMS,
executionMode: "sequential",
async execute(_id, params: { itemName: string }) {
const bot = getBot();
const ok = await safeCall("equip", () => skills.equip(bot, params.itemName));
return textResult(ok ? `Equipped ${params.itemName}.` : `equip returned false for ${params.itemName}.`, { ok, ...params });
},
});
// 11. mc_consume
pi.registerTool({
name: "mc_consume",
label: "Consume Food",
description: "Eat food from inventory. If itemName empty, picks first food item.",
parameters: CONSUME_PARAMS,
executionMode: "sequential",
async execute(_id, params: { itemName?: string }) {
const bot = getBot();
const ok = await safeCall("consume", () => skills.consume(bot, params.itemName ?? ""));
return textResult(ok ? `Ate ${params.itemName ?? "food"}.` : `consume returned false.`, { ok, ...params });
},
});
// 12. mc_defend_self
pi.registerTool({
name: "mc_defend_self",
label: "Defend Self",
description: "Attack hostile mobs within range until clear. Uses best available weapon. Range default 9.",
parameters: DEFEND_PARAMS,
executionMode: "sequential",
async execute(_id, params: { range?: number }) {
const bot = getBot();
const ok = await safeCall("defendSelf", () => skills.defendSelf(bot, params.range ?? 9));
return textResult(ok ? `Defended against hostiles.` : `defendSelf returned false.`, { ok, ...params });
},
});
// 13. mc_avoid_enemies
pi.registerTool({
name: "mc_avoid_enemies",
label: "Avoid Enemies",
description: "Move away from the nearest hostile entities by approx N blocks. Default 16.",
parameters: AVOID_PARAMS,
executionMode: "sequential",
async execute(_id, params: { distance?: number }) {
const bot = getBot();
const ok = await safeCall("avoidEnemies", () => skills.avoidEnemies(bot, params.distance ?? 16));
return textResult(ok ? `Avoided enemies.` : `avoidEnemies returned false.`, { ok, ...params });
},
});
// 14. mc_stay
pi.registerTool({
name: "mc_stay",
label: "Stay In Place",
description: "Stand still for N seconds (default 30). Useful for waiting out night, regen, or letting world state change.",
parameters: STAY_PARAMS,
executionMode: "sequential",
async execute(_id, params: { seconds?: number }) {
const bot = getBot();
const secs = Math.max(1, Math.min(600, Math.floor(params.seconds ?? 30)));
await safeCall("stay", () => skills.stay(bot, secs));
return textResult(`Stood still for ${secs}s.`, { seconds: secs });
},
});
// 15. mc_pickup_nearby
pi.registerTool({
name: "mc_pickup_nearby",
label: "Pickup Nearby Items",
description: "Walk over and pick up any dropped item entities within ~8 blocks.",
parameters: EMPTY_PARAMS,
executionMode: "sequential",
async execute() {
const bot = getBot();
const ok = await safeCall("pickupNearbyItems", () => skills.pickupNearbyItems(bot));
return textResult(ok ? `Picked up nearby items.` : `pickupNearbyItems returned false.`);
},
});
}
+61 -2
View File
@@ -8,6 +8,15 @@ import type { Bot } from "mineflayer";
const require = createRequire(import.meta.url);
const dotenv = require("dotenv") as typeof import("dotenv");
const mineflayer = require("mineflayer") as typeof import("mineflayer");
// mcdata is an ES module; load it lazily via dynamic import on first use to
// avoid the "Cannot require() ES Module ... not yet fully loaded" race when
// Pi loads multiple extensions in parallel.
let mcdataPromise: Promise<{ attachPluginsAndInit: (bot: any) => any }> | null = null;
function loadMcdata() {
if (!mcdataPromise) mcdataPromise = import("./lib/mcdata.js" as any) as Promise<{ attachPluginsAndInit: (bot: any) => any }>;
return mcdataPromise;
}
const pathfinderModule = require("mineflayer-pathfinder") as {
pathfinder: (bot: Bot) => void;
Movements: new (bot: Bot) => any;
@@ -90,6 +99,12 @@ interface BuildPyramidInput {
dry_run?: boolean;
}
interface DigInput {
x: number;
y: number;
z: number;
}
type MemoryAction = "set_current_task" | "clear_current_task" | "append_diary" | "register_location";
interface MemoryInput {
@@ -228,6 +243,17 @@ const BUILD_PYRAMID_PARAMS = {
additionalProperties: false,
} as const;
const DIG_PARAMS = {
type: "object",
properties: {
x: { type: "number", description: "Block X coordinate to dig." },
y: { type: "number", description: "Block Y coordinate to dig." },
z: { type: "number", description: "Block Z coordinate to dig." },
},
required: ["x", "y", "z"],
additionalProperties: false,
} as const;
const MEMORY_PARAMS = {
type: "object",
properties: {
@@ -1081,6 +1107,13 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
}
bot = nextBot;
(globalThis as any).__pepaPiBot = nextBot;
// Attach Mindcraft-required plugins (pathfinder, pvp, collectblock,
// armorManager) and prime mc_version once login completes. Skill calls
// from mindcraft-skills.ts rely on this. Loaded lazily as ESM.
loadMcdata()
.then((m) => { try { m.attachPluginsAndInit(nextBot); } catch (e) { log("plugin-init-error", e); } })
.catch((e) => log("mcdata-load-error", e));
nextBot.once("login", () => {
setConnectionState("connected");
});
@@ -1122,7 +1155,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
lastDisconnectReason = `error: ${truncate(redact(stringifyUnknown(error), current), 200)}`;
activeWorldTask = undefined;
stopAuthTimer();
if (bot === nextBot) bot = undefined;
if (bot === nextBot) { bot = undefined; (globalThis as any).__pepaPiBot = undefined; }
setConnectionState("disconnected");
try {
nextBot.end("connection error");
@@ -1135,7 +1168,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
nextBot.on("end", (reasonText) => {
activeWorldTask = undefined;
stopAuthTimer();
if (bot === nextBot) bot = undefined;
if (bot === nextBot) { bot = undefined; (globalThis as any).__pepaPiBot = undefined; }
const reasonString = stringifyUnknown(reasonText || lastDisconnectReason || "end");
lastDisconnectReason = reasonString;
if (manualDisconnectRequested || shuttingDown) {
@@ -1161,6 +1194,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
}
const currentBot = bot as Bot & { quit?: (reason?: string) => void; end?: (reason?: string) => void };
bot = undefined;
(globalThis as any).__pepaPiBot = undefined;
setConnectionState("disconnected");
if (typeof currentBot.quit === "function") {
currentBot.quit();
@@ -1866,6 +1900,31 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
},
});
pi.registerTool({
name: "mc_dig",
label: "Minecraft Dig Block",
description: "Dig one block at exact coordinates using Mineflayer bot.dig(bot.blockAt(new Vec3(x,y,z))).",
promptSnippet: "Dig one block at exact Minecraft coordinates.",
parameters: DIG_PARAMS,
executionMode: "sequential",
async execute(_toolCallId, params: DigInput) {
const currentBot = activeBot();
const pos = new Vec3(
Math.floor(finiteNumber(params.x, "x")),
Math.floor(finiteNumber(params.y, "y")),
Math.floor(finiteNumber(params.z, "z")),
);
const block = currentBot.blockAt(pos);
if (!block) throw new Error(`No loaded block at ${pos.x},${pos.y},${pos.z}.`);
if (block.name === "air") throw new Error(`Block at ${pos.x},${pos.y},${pos.z} is air.`);
await currentBot.dig(block);
return {
content: [{ type: "text", text: `Dug ${block.name} at x=${pos.x}, y=${pos.y}, z=${pos.z}.` }],
details: { x: pos.x, y: pos.y, z: pos.z, block: block.name },
};
},
});
pi.registerTool({
name: "mc_goto",
label: "Minecraft Guarded Go To",
+152 -4
View File
@@ -10,12 +10,16 @@
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.5",
"minecraft-data": "^3.110.2",
"mineflayer": "^4.37.1",
"mineflayer-armor-manager": "^2.0.1",
"mineflayer-auto-eat": "^5.0.3",
"mineflayer-collectblock": "^1.6.0",
"mineflayer-pathfinder": "^2.4.5",
"mineflayer-tool": "^1.2.0"
"mineflayer-pvp": "^1.3.2",
"mineflayer-tool": "^1.2.0",
"prismarine-item": "^1.18.0",
"vec3": "^0.2.0"
},
"engines": {
"node": ">=20"
@@ -526,6 +530,23 @@
"vec3": "^0.1.7"
}
},
"node_modules/mineflayer-pathfinder/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/mineflayer-pvp": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/mineflayer-pvp/-/mineflayer-pvp-1.3.2.tgz",
"integrity": "sha512-CI2T5w4ceiQdQRCFdrCs3OYmvO9G0cOx2qovUt6f27gknpVI90Ihe1qYhf+zBZJCg9uP1oJ1Pemlzw46Mm3wcA==",
"license": "MIT",
"dependencies": {
"mineflayer": "^4.0.0",
"mineflayer-pathfinder": "^2.0.0",
"mineflayer-utils": "^0.1.4"
}
},
"node_modules/mineflayer-tool": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mineflayer-tool/-/mineflayer-tool-1.2.0.tgz",
@@ -537,6 +558,87 @@
"prismarine-nbt": "^2.0.0"
}
},
"node_modules/mineflayer-utils": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/mineflayer-utils/-/mineflayer-utils-0.1.4.tgz",
"integrity": "sha512-8+0dbGAjA6FO62/W80v5k44AuSAcwJUMdpMAAGhjI9AdCDz+UuyTnNkPBncF4EcLY7yUUdtA7m/OXQZKFdWOlg==",
"license": "MIT",
"dependencies": {
"@types/node": "^14.0.27",
"mineflayer": "^2.27.0",
"prismarine-entity": "^1.0.0",
"require-self": "^0.2.3",
"typescript": "^3.9.7"
}
},
"node_modules/mineflayer-utils/node_modules/@types/node": {
"version": "14.18.63",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/mineflayer-utils/node_modules/minecraft-data": {
"version": "2.221.0",
"resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-2.221.0.tgz",
"integrity": "sha512-0AhqzbIKb6WqPSF6qBevaPryeWOz545hLxt6q+gfJF8YIQX/YfkyX/nXWhl+pSIS2rTBcQ0RJkRCtTeRzQwHDA==",
"license": "MIT"
},
"node_modules/mineflayer-utils/node_modules/mineflayer": {
"version": "2.41.0",
"resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-2.41.0.tgz",
"integrity": "sha512-IFFy4NgF24FU2PkAwazJphl2F+3gpbpN578ex0sq1XfcBBRge3kCz1UC2KDMjKI+V/8vffOL+OEnug9jt3f7Vw==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^2.70.0",
"minecraft-protocol": "^1.17.0",
"prismarine-biome": "^1.1.0",
"prismarine-block": "^1.6.0",
"prismarine-chat": "^1.0.0",
"prismarine-chunk": "^1.20.3",
"prismarine-entity": "^1.0.0",
"prismarine-item": "^1.5.0",
"prismarine-physics": "^1.0.4",
"prismarine-recipe": "^1.1.0",
"prismarine-windows": "^1.5.0",
"prismarine-world": "^3.2.0",
"protodef": "^1.8.0",
"typed-emitter": "^1.2.0",
"vec3": "^0.1.6"
},
"engines": {
"node": ">=14"
}
},
"node_modules/mineflayer-utils/node_modules/prismarine-entity": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-1.2.0.tgz",
"integrity": "sha512-4dQ9LYl6HDJQrwZHjSKU4D5VNyHRnfrjcw7eVLlbRPkuR50utW5mmfPi4ys9U7tHNmGWHC/cwjH9xzT75LUovQ==",
"license": "MIT",
"dependencies": {
"vec3": "^0.1.4"
}
},
"node_modules/mineflayer-utils/node_modules/prismarine-windows": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-1.6.0.tgz",
"integrity": "sha512-026LG1yR76Xb62kM+W83IWT7Wy2yKplllbXNFBF2m0Lr4k4YpYKnpLb8tRft8MLOLRbYAt/KnxE/YKvRZul7kw==",
"license": "MIT",
"dependencies": {
"prismarine-item": "^1.4.0"
}
},
"node_modules/mineflayer-utils/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/mineflayer/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/mojangson": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz",
@@ -676,6 +778,12 @@
"node": ">=14"
}
},
"node_modules/prismarine-chunk/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/prismarine-entity": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz",
@@ -688,6 +796,12 @@
"vec3": "^0.1.4"
}
},
"node_modules/prismarine-entity/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/prismarine-item": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz",
@@ -718,6 +832,12 @@
"vec3": "^0.1.7"
}
},
"node_modules/prismarine-physics/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/prismarine-realms": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz",
@@ -780,6 +900,12 @@
"node": ">=8.0.0"
}
},
"node_modules/prismarine-world/node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
@@ -859,6 +985,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/require-self": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/require-self/-/require-self-0.2.3.tgz",
"integrity": "sha512-keGBWkK0PWJGFAd6IznpjM5zZzySbsrvzq0ElXpZ4G8hzymV9I+/OnC916MF5mRxHRWSZKGIyCFJ73BZFz3xaA==",
"license": "MIT",
"bin": {
"require-self": "bin/require-self"
}
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
@@ -948,6 +1083,19 @@
"integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz",
"integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/uint4": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz",
@@ -989,9 +1137,9 @@
}
},
"node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.2.0.tgz",
"integrity": "sha512-jOjU4zbCNWOKOGLHop1I0J2Ar4h5Zz76455LJhJKhiOJwI21QP6d5TnKklNZDKG+c5h71zWtPAbd9wh+Va57OQ==",
"license": "BSD"
},
"node_modules/webidl-conversions": {
+5 -1
View File
@@ -14,12 +14,16 @@
},
"dependencies": {
"dotenv": "^16.4.5",
"minecraft-data": "^3.110.2",
"mineflayer": "^4.37.1",
"mineflayer-armor-manager": "^2.0.1",
"mineflayer-auto-eat": "^5.0.3",
"mineflayer-collectblock": "^1.6.0",
"mineflayer-pathfinder": "^2.4.5",
"mineflayer-tool": "^1.2.0"
"mineflayer-pvp": "^1.3.2",
"mineflayer-tool": "^1.2.0",
"prismarine-item": "^1.18.0",
"vec3": "^0.2.0"
},
"repository": {
"type": "git",