Phase 1 of plans/autonomous-survival-bot-prd.md. The bot must always be able to answer "what am I doing and why am I not doing more?" without parsing the log stream. New modules: - runtime/state.js: pure FSM classifier emitting emergency / working / recovering / planning / social / idle from snapshot + reflex context. - runtime/no-progress.js: sliding-window detector that watches position and inventory; when both are unchanged for 60 s+, emits one stable reason code from REASONS (waiting_for_day, night_hostile_nearby, no_food_source, inventory_full, no_reachable_target, planner_empty, awaiting_action_cooldown). - runtime/viewer.js: optional prismarine-viewer launcher behind VIEWER_PORT. Lazy import so the dep is not required by default. Wiring: - runtime/bot.js: tick() now computes runtimeState + noProgressReason every tick and stamps them on the snapshot along with activeSkill, currentMilestone (read from plan.md, cached 30 s), lastResult, failuresByCode and lastEscalation. - runtime/bot.js: dispatchAction records lastResult and lastFailureAt for the recovering-state classifier. - runtime/planner.js: exports isPlannerBusy(), readNextMilestone() and planExists() so the runtime can show planning state + current milestone without spawning extra Pi calls. - runtime/config.js: adds VIEWER_PORT support. TUI: - tui/tui.tsx: StatusBar gains a state badge, current-skill row, milestone row, no-progress reason warning, last-result line with ok/fail color, failures-by-class summary and last-escalation age. Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
200 lines
6.1 KiB
JavaScript
200 lines
6.1 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);
|
|
}
|
|
|
|
export function isPlannerBusy() {
|
|
return planInFlight;
|
|
}
|
|
|
|
// Returns the first uncompleted milestone in plan.md (a line that starts
|
|
// with a list marker but is NOT preceded by "✓ "), or null if the plan is
|
|
// empty or every milestone is done. Plain text, leading list markers
|
|
// stripped, capped at 200 chars to keep it TUI-friendly.
|
|
export function readNextMilestone() {
|
|
const text = readOr(PLAN_PATH);
|
|
if (!text.trim()) return null;
|
|
for (const raw of text.split("\n")) {
|
|
const line = raw.trim();
|
|
if (!line) continue;
|
|
const m = line.match(/^(?:[-*]|\d+[.)])\s+(.*)$/);
|
|
if (!m) continue;
|
|
const content = m[1];
|
|
if (content.startsWith("✓ ") || content.startsWith("[x] ") || content.startsWith("[X] ")) continue;
|
|
return content.slice(0, 200);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function planExists() {
|
|
return readOr(PLAN_PATH).trim().length > 0;
|
|
}
|