Files
pepa-pi-bot/runtime/planner.js
T
313ef081d1 feat(runtime): village progression — crafting, tech-tree reflex, LLM planner (#11)
Closes the loop "стой и кидай proposals" → "копит ресурсы, строит,
работает к глобальной цели". Three pieces:

1. Crafting primitives (runtime/actions.js).

   craftPlanks (4 per log, any wood type), craftSticks (4 per 2 planks),
   placeCraftingTable (crafts a table from planks if needed + places at
   reference block + reuses an existing table within 4 m), craftWoodenAxe,
   craftWoodenPickaxe, craftWoodenSword. Each uses bot.recipesFor()
   + bot.craft() with a 15s timeout. Returns the same {ok, detail}
   contract as the other actions.

   inv.{getItemCount, getAnyPlanksCount, getAnyLogCount} helpers
   exported so the reflex layer can read inventory cheaply without
   pulling mineflayer state through every reducer.

2. Tech-tree reflex (runtime/reflex.js).

   New techTreeReflex between sleep and autonomous. Inventory-driven
   progression: log+0 planks → planks; planks+0 sticks → sticks;
   planks+sticks+no axe → wooden_axe; +no pickaxe → wooden_pickaxe;
   +no sword → wooden_sword. 5 s cooldown so we don't fire on every
   tick.

   Pure script, no LLM. The progression is exactly what a player
   does in the first 10 min on a new world; making it scripted means
   the bot never burns tokens on it.

3. LLM planner (runtime/planner.js).

   Background timer (every 15 min, with a 30 s warm-up after start).
   Reads goal.md + plan.md + a slim snapshot, prompts Pi to output a
   fresh plan.md to stdout. Stripped of code fences and written
   verbatim to state/<host>/plan.md. Capped at 16 KB.

   The plan is markdown the operator can read or edit by hand. Numbered
   milestones, ✓ prefix for completed ones, kept short. The reflex
   layer doesn't auto-execute LLM text — but the planner sets the
   long-horizon shape that future reflexes (build house, plant farm)
   can read.

   5 min timeout on the pi subprocess. If it crashes or times out, the
   next 15-min tick just retries — no propagation to the reflex loop.

The progression now looks like, roughly:

  chop log (autonomous) →
  craft planks → craft sticks → wooden_axe (tech-tree) →
  chop faster (autonomous, has axe now) →
  wooden_pickaxe + wooden_sword (tech-tree) →
  mine stone … (next PR: stone tools, farm site selection,
  house frame)

Smoke-tested: all three modules import cleanly, exports check out.

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:00:07 +03:00

173 lines
5.3 KiB
JavaScript

// LLM planner: every PLANNER_INTERVAL_MS (15 min) reads goal.md +
// state/<host>/plan.md + a slim snapshot and asks Pi to update plan.md
// with the current set of milestones. Reflex layer treats plan.md as
// advisory hints (we don't auto-execute LLM-written code from here).
//
// The plan is markdown for two reasons:
// 1. It's small enough to put in a prompt and read back from Pi.
// 2. The operator can edit it directly with $EDITOR if Pi drifts.
//
// We don't block on this. spawnPlanner runs as a detached promise; if Pi
// is slow or down, the reflex layer keeps doing what it was doing.
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { stateDir } from "./config.js";
import { info, warn } from "./log.js";
const GOAL_PATH = path.join(stateDir, "goal.md");
const PLAN_PATH = path.join(stateDir, "plan.md");
const PLANNER_INTERVAL_MS = 15 * 60_000;
const PLANNER_TIMEOUT_MS = 5 * 60_000;
const MAX_PLAN_BYTES = 16_000;
let planTimer = null;
let planInFlight = false;
function readOr(file, fallback = "") {
try {
return fs.readFileSync(file, "utf8");
} catch (e) {
if (e.code === "ENOENT") return fallback;
throw e;
}
}
function buildPrompt({ goal, plan, snapshot }) {
const slim = snapshot
? {
position: snapshot.position,
health: snapshot.health,
food: snapshot.food,
isDay: snapshot.isDay,
inventory: snapshot.inventory,
dimension: snapshot.dimension,
}
: null;
return [
"You are the long-horizon PLANNER for an autonomous Minecraft farmer bot.",
"",
"Read the goal.md, the current plan.md (if any), and the bot's latest snapshot.",
"Output an UPDATED plan.md to stdout. No markdown code fences, no preamble —",
"the entire stdout will be written verbatim to plan.md.",
"",
"## Constraints",
"",
"- Format: numbered milestones, each on one short line. Optional sub-bullets allowed.",
"- Each milestone must be concrete, measurable, and within reach of the current",
" inventory + reflex capabilities (chop, craft planks/sticks/wooden tools,",
" wander, defend, eat, sleep, goto, place a single block).",
"- Mark completed milestones with a leading '✓ '. Keep them in the list as history.",
"- The first uncompleted milestone is the NEXT thing the bot will work on.",
"- Cap the file at 80 lines / ~3 KB. If older milestones aren't useful to keep,",
" drop them.",
"- Do NOT propose anything outside the overworld; no nether, no end, no PvP.",
"- Do NOT propose anything that requires OP, /commands, or external services.",
"- If the goal already looks satisfied, write the next maintenance cycle.",
"",
"## goal.md",
"",
goal || "(empty — the operator has not seeded a long-term goal yet)",
"",
"## Current plan.md",
"",
plan || "(no plan yet — start from scratch)",
"",
"## Snapshot (slim)",
"",
"```json",
JSON.stringify(slim, null, 2),
"```",
"",
"Now output the new plan.md content. Plain markdown only.",
].join("\n");
}
function tick(getSnapshot) {
if (planInFlight) return;
const goal = readOr(GOAL_PATH);
if (!goal.trim()) {
// Nothing to plan toward. Skip silently.
return;
}
const plan = readOr(PLAN_PATH);
const snapshot = getSnapshot();
planInFlight = true;
info("planner", "asking Pi for an updated plan.md");
const prompt = buildPrompt({ goal, plan, snapshot });
const child = spawn("pi", ["-p", prompt], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CI: "1" },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (c) => {
stdout += c.toString();
if (stdout.length > MAX_PLAN_BYTES * 2) {
// Pi went off — truncate further reads.
stdout = stdout.slice(0, MAX_PLAN_BYTES * 2);
}
});
child.stderr.on("data", (c) => {
stderr += c.toString();
});
const killTimer = setTimeout(() => {
warn("planner", "Pi timed out — killing");
child.kill("SIGTERM");
}, PLANNER_TIMEOUT_MS);
child.on("exit", (code) => {
clearTimeout(killTimer);
planInFlight = false;
if (code !== 0) {
warn("planner", `pi exited ${code}: ${stderr.split("\n")[0]}`);
return;
}
const cleaned = stripCodeFences(stdout).trim();
if (!cleaned) {
warn("planner", "pi produced empty output");
return;
}
if (cleaned.length > MAX_PLAN_BYTES) {
warn("planner", `plan too large (${cleaned.length}B) — truncating`);
}
try {
fs.writeFileSync(PLAN_PATH, cleaned.slice(0, MAX_PLAN_BYTES));
info("planner", `plan.md updated (${cleaned.length}B)`);
} catch (e) {
warn("planner", `could not write plan.md: ${e.message}`);
}
});
}
function stripCodeFences(s) {
// Pi sometimes wraps the entire output in ```markdown … ``` even when
// told not to. Strip outer fences if present.
const trimmed = s.trim();
const m = trimmed.match(/^```[a-z]*\n([\s\S]*?)\n```$/);
return m ? m[1] : trimmed;
}
export function startPlanner(getSnapshot) {
if (planTimer) return;
info("planner", `scheduling every ${PLANNER_INTERVAL_MS / 60_000} min`);
// Run once on startup so we don't wait 15 min for the first plan.
setTimeout(() => tick(getSnapshot), 30_000);
planTimer = setInterval(() => tick(getSnapshot), PLANNER_INTERVAL_MS);
}
export function stopPlanner() {
if (planTimer) clearInterval(planTimer);
planTimer = null;
}
export function readCurrentPlan() {
return readOr(PLAN_PATH);
}