Files
f301529f42 feat(runtime): observability + no-progress detector (Phase 1) (#13)
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>
2026-05-25 22:11:13 +03:00

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;
}