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>
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user