feat(runtime): observability + no-progress detector (Phase 1) #13
@@ -59,3 +59,9 @@ OPERATOR_USERNAMES=
|
|||||||
# --- Optional: Telegram bridge (future skill, not wired yet) ------------------
|
# --- Optional: Telegram bridge (future skill, not wired yet) ------------------
|
||||||
# TELEGRAM_BOT_TOKEN=
|
# TELEGRAM_BOT_TOKEN=
|
||||||
# TELEGRAM_OPERATOR_CHAT_ID=
|
# TELEGRAM_OPERATOR_CHAT_ID=
|
||||||
|
|
||||||
|
# --- Optional: prismarine-viewer for local visual debugging -------------------
|
||||||
|
# Set to a port to launch the in-browser viewer alongside the bot
|
||||||
|
# (http://localhost:<port>). Requires `npm i prismarine-viewer` — kept out of
|
||||||
|
# the default deps to keep the runtime light. Set to 0 or leave empty to skip.
|
||||||
|
VIEWER_PORT=0
|
||||||
|
|||||||
@@ -150,6 +150,31 @@ should only suggest what to do *with the existing tools*. If a deeper
|
|||||||
problem is happening, the failure-tracker (see Self-improvement) will
|
problem is happening, the failure-tracker (see Self-improvement) will
|
||||||
file a proposal instead.
|
file a proposal instead.
|
||||||
|
|
||||||
|
## Observability (Phase 1 — survival-bot pivot)
|
||||||
|
|
||||||
|
Every STATUS snapshot now carries fields the TUI uses to answer
|
||||||
|
"what is the bot doing and why isn't it doing more?" without
|
||||||
|
parsing the log stream:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `runtimeState` | finite-state classification: `emergency` / `working` / `recovering` / `planning` / `social` / `idle` (see `runtime/state.js`). |
|
||||||
|
| `activeSkill` | current dispatched action label, or the last one if idle. |
|
||||||
|
| `currentMilestone` | first uncompleted line from `state/<host>/plan.md` (cached 30 s). |
|
||||||
|
| `lastResult` | `{ label, ok, code, detail, ts }` of the most recent dispatched action. |
|
||||||
|
| `noProgressReason` | one of `waiting_for_day`, `night_hostile_nearby`, `no_food_source`, `inventory_full`, `no_reachable_target`, `planner_empty`, `awaiting_action_cooldown`, … emitted when position + inventory have not changed for ≥60 s (see `runtime/no-progress.js`). |
|
||||||
|
| `failuresByCode` | rolling counts of recent failures grouped by class (`bug` / `timeout` / `feature-gap` / `other`). |
|
||||||
|
| `lastEscalation` | `{ ts, ageMs }` of the most recent Pi auto-escalation. |
|
||||||
|
| `reflexPaused` | mirror of the local pause flag (so TUI shows the right state immediately). |
|
||||||
|
|
||||||
|
### Optional: prismarine-viewer
|
||||||
|
|
||||||
|
Set `VIEWER_PORT=<port>` in `.env` to launch
|
||||||
|
[`prismarine-viewer`](https://github.com/PrismarineJS/prismarine-viewer)
|
||||||
|
in-process. The package is **not** a default dep — install it explicitly
|
||||||
|
(`npm i prismarine-viewer`) before enabling. If missing, the runtime
|
||||||
|
logs a warning and continues.
|
||||||
|
|
||||||
## In-game chat (dialog-only)
|
## In-game chat (dialog-only)
|
||||||
|
|
||||||
As of the Phase 0 survival-bot pivot, MC chat does **not** drive bot
|
As of the Phase 0 survival-bot pivot, MC chat does **not** drive bot
|
||||||
|
|||||||
+92
-3
@@ -34,7 +34,10 @@ import {
|
|||||||
approveProposal,
|
approveProposal,
|
||||||
} from "./state-store.js";
|
} from "./state-store.js";
|
||||||
import { startAutoImprover } from "./auto-improve.js";
|
import { startAutoImprover } from "./auto-improve.js";
|
||||||
import { startPlanner } from "./planner.js";
|
import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./planner.js";
|
||||||
|
import { computeState, STATES } from "./state.js";
|
||||||
|
import { createNoProgressDetector } from "./no-progress.js";
|
||||||
|
import { maybeStartViewer } from "./viewer.js";
|
||||||
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||||
@@ -55,6 +58,17 @@ let lastSnapshot = { connected: false };
|
|||||||
let consecutiveNoops = 0;
|
let consecutiveNoops = 0;
|
||||||
let lastEscalationAt = 0;
|
let lastEscalationAt = 0;
|
||||||
|
|
||||||
|
// Observability state — surfaced in every STATUS snapshot so the TUI (and
|
||||||
|
// future Telegram/diary surfaces) can answer "what is the bot doing and why
|
||||||
|
// isn't it doing more?" without parsing the log stream.
|
||||||
|
const noProgress = createNoProgressDetector();
|
||||||
|
let lastResult = null; // { label, ok, code, detail, ts }
|
||||||
|
let lastFailureAt = 0;
|
||||||
|
let lastPlanReadAt = 0;
|
||||||
|
let cachedMilestone = null;
|
||||||
|
let cachedPlanExists = false;
|
||||||
|
const MILESTONE_CACHE_MS = 30_000;
|
||||||
|
|
||||||
// Reflex context — passed into reflex.js every tick. Mutable across ticks.
|
// Reflex context — passed into reflex.js every tick. Mutable across ticks.
|
||||||
const reflexCtx = {
|
const reflexCtx = {
|
||||||
bot: null,
|
bot: null,
|
||||||
@@ -150,8 +164,19 @@ function dispatchAction(fn, label, opts = {}) {
|
|||||||
`← ${label} ${ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`,
|
`← ${label} ${ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`,
|
||||||
);
|
);
|
||||||
writeCurrentTask({ label, status: ok ? "completed" : "failed", detail: res?.detail });
|
writeCurrentTask({ label, status: ok ? "completed" : "failed", detail: res?.detail });
|
||||||
if (!ok) recordFailure(label, res?.detail);
|
lastResult = {
|
||||||
else clearRecentFailures(label);
|
label,
|
||||||
|
ok,
|
||||||
|
code: res?.code ?? (ok ? "done" : classifyFailure(res?.detail)),
|
||||||
|
detail: res?.detail,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
if (!ok) {
|
||||||
|
lastFailureAt = lastResult.ts;
|
||||||
|
recordFailure(label, res?.detail);
|
||||||
|
} else {
|
||||||
|
clearRecentFailures(label);
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.onComplete) {
|
if (opts.onComplete) {
|
||||||
try {
|
try {
|
||||||
@@ -164,6 +189,14 @@ function dispatchAction(fn, label, opts = {}) {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
warn("dispatch", `${label} threw: ${e?.message ?? e}`);
|
warn("dispatch", `${label} threw: ${e?.message ?? e}`);
|
||||||
writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) });
|
writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) });
|
||||||
|
lastResult = {
|
||||||
|
label,
|
||||||
|
ok: false,
|
||||||
|
code: "threw",
|
||||||
|
detail: String(e?.message ?? e),
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
lastFailureAt = lastResult.ts;
|
||||||
recordFailure(label, String(e?.message ?? e));
|
recordFailure(label, String(e?.message ?? e));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -391,6 +424,7 @@ function connect() {
|
|||||||
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
|
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
|
||||||
appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`);
|
appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`);
|
||||||
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
|
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
|
||||||
|
maybeStartViewer(bot).catch((e) => warn("viewer", `start threw: ${e?.message ?? e}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.on("messagestr", (text) => {
|
bot.on("messagestr", (text) => {
|
||||||
@@ -481,8 +515,29 @@ function maybeAutoEscalate() {
|
|||||||
|
|
||||||
// ---- tick ------------------------------------------------------------------
|
// ---- tick ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function failuresByCode() {
|
||||||
|
const counts = {};
|
||||||
|
for (const f of reflexCtx.recentFailures) {
|
||||||
|
const k = f.kind || "other";
|
||||||
|
counts[k] = (counts[k] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMilestoneCache(now) {
|
||||||
|
if (now - lastPlanReadAt < MILESTONE_CACHE_MS) return;
|
||||||
|
lastPlanReadAt = now;
|
||||||
|
try {
|
||||||
|
cachedMilestone = readNextMilestone();
|
||||||
|
cachedPlanExists = planExists();
|
||||||
|
} catch (e) {
|
||||||
|
warn("planner", `milestone read failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
|
const now = Date.now();
|
||||||
if (bot && bot.entity) {
|
if (bot && bot.entity) {
|
||||||
lastSnapshot = buildSnapshot(bot);
|
lastSnapshot = buildSnapshot(bot);
|
||||||
lastSnapshot.pendingProposals = listProposals().length;
|
lastSnapshot.pendingProposals = listProposals().length;
|
||||||
@@ -502,6 +557,40 @@ function tick() {
|
|||||||
consecutiveNoops = 0;
|
consecutiveNoops = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observability: compute runtime state + no-progress reason and
|
||||||
|
// stamp them on the snapshot so the TUI / future surfaces can show
|
||||||
|
// one concrete answer to "why is the bot idle?".
|
||||||
|
refreshMilestoneCache(now);
|
||||||
|
const plannerInFlight = isPlannerBusy();
|
||||||
|
const runtimeState = computeState({
|
||||||
|
snapshot: lastSnapshot,
|
||||||
|
ctx: reflexCtx,
|
||||||
|
plannerInFlight,
|
||||||
|
lastChatReplyAt,
|
||||||
|
lastFailureAt,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
const noProgressReason = noProgress.detect({
|
||||||
|
snapshot: lastSnapshot,
|
||||||
|
ctx: reflexCtx,
|
||||||
|
planExists: cachedPlanExists,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSnapshot.runtimeState = runtimeState;
|
||||||
|
lastSnapshot.activeSkill = reflexCtx.busy
|
||||||
|
? reflexCtx.currentActionLabel
|
||||||
|
: reflexCtx.lastReflex?.label ?? null;
|
||||||
|
lastSnapshot.currentMilestone = cachedMilestone;
|
||||||
|
lastSnapshot.lastResult = lastResult;
|
||||||
|
lastSnapshot.noProgressReason = noProgressReason;
|
||||||
|
lastSnapshot.failuresByCode = failuresByCode();
|
||||||
|
lastSnapshot.lastEscalation = lastEscalationAt
|
||||||
|
? { ts: lastEscalationAt, ageMs: now - lastEscalationAt }
|
||||||
|
: null;
|
||||||
|
lastSnapshot.reflexPaused = reflexPaused;
|
||||||
|
|
||||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||||
} else {
|
} else {
|
||||||
lastSnapshot = { connected: false };
|
lastSnapshot = { connected: false };
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export const config = Object.freeze({
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
|
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
|
||||||
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
|
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
|
||||||
|
// Optional prismarine-viewer port for local visual debugging. 0/empty = off.
|
||||||
|
viewerPort: (() => {
|
||||||
|
const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10);
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||||
|
})(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const serverKey = `${host}_${port}`;
|
export const serverKey = `${host}_${port}`;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// No-progress detector. Independent from exception-style failures: this
|
||||||
|
// watches whether the bot is producing any *observable* world change
|
||||||
|
// (movement or inventory change) over time, and, if not, emits a single
|
||||||
|
// stable reason code so TUI + diary + future Pi escalation can show one
|
||||||
|
// concrete answer to "why is the bot standing still?".
|
||||||
|
//
|
||||||
|
// Reason codes are deliberately stable strings so they can be diffed
|
||||||
|
// across versions, counted, and used by future skill triggers.
|
||||||
|
|
||||||
|
export const REASONS = Object.freeze({
|
||||||
|
WAITING_FOR_DAY: "waiting_for_day",
|
||||||
|
NIGHT_HOSTILE_NEARBY: "night_hostile_nearby",
|
||||||
|
NO_FOOD_SOURCE: "no_food_source",
|
||||||
|
NO_SAFE_PATH: "no_safe_path",
|
||||||
|
NO_KNOWN_BASE: "no_known_base",
|
||||||
|
INVENTORY_FULL: "inventory_full",
|
||||||
|
PLANNER_EMPTY: "planner_empty",
|
||||||
|
NO_REACHABLE_TARGET: "no_reachable_target",
|
||||||
|
AWAITING_ACTION_COOLDOWN: "awaiting_action_cooldown",
|
||||||
|
});
|
||||||
|
|
||||||
|
const STILL_THRESHOLD_MS = 60_000;
|
||||||
|
const POS_EPSILON = 1.5;
|
||||||
|
const INVENTORY_FULL_SLOTS = 32; // 36 main slots; treat >=32 distinct stacks as full-ish
|
||||||
|
|
||||||
|
// Conservative food list — items the bot can safely consume right now via
|
||||||
|
// the eat reflex / mineflayer auto-eat lineage. Kept short on purpose: if
|
||||||
|
// the bot is "hungry but no food", we want to detect that even when the
|
||||||
|
// inventory has dirt and sticks.
|
||||||
|
const FOOD_NAMES = new Set([
|
||||||
|
"bread",
|
||||||
|
"cooked_beef",
|
||||||
|
"cooked_chicken",
|
||||||
|
"cooked_porkchop",
|
||||||
|
"cooked_mutton",
|
||||||
|
"cooked_rabbit",
|
||||||
|
"cooked_salmon",
|
||||||
|
"cooked_cod",
|
||||||
|
"baked_potato",
|
||||||
|
"apple",
|
||||||
|
"golden_apple",
|
||||||
|
"carrot",
|
||||||
|
"beetroot",
|
||||||
|
"melon_slice",
|
||||||
|
"sweet_berries",
|
||||||
|
"glow_berries",
|
||||||
|
"mushroom_stew",
|
||||||
|
"rabbit_stew",
|
||||||
|
"beetroot_soup",
|
||||||
|
"suspicious_stew",
|
||||||
|
"cooked_chicken",
|
||||||
|
"dried_kelp",
|
||||||
|
"pumpkin_pie",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function hasFood(inventory) {
|
||||||
|
for (const name of Object.keys(inventory ?? {})) {
|
||||||
|
if (FOOD_NAMES.has(name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryKey(inventory) {
|
||||||
|
const entries = Object.entries(inventory ?? {});
|
||||||
|
if (entries.length === 0) return "";
|
||||||
|
entries.sort(([a], [b]) => (a < b ? -1 : 1));
|
||||||
|
return entries.map(([k, v]) => `${k}:${v}`).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinctStacks(inventory) {
|
||||||
|
return Object.keys(inventory ?? {}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify({ snapshot, ctx, planExists }) {
|
||||||
|
if (!snapshot.isDay) {
|
||||||
|
if (snapshot.closestHostile && snapshot.closestHostile.distance <= 16) {
|
||||||
|
return REASONS.NIGHT_HOSTILE_NEARBY;
|
||||||
|
}
|
||||||
|
return REASONS.WAITING_FOR_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const food = snapshot.food ?? 20;
|
||||||
|
if (food <= 6 && !hasFood(snapshot.inventory)) {
|
||||||
|
return REASONS.NO_FOOD_SOURCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distinctStacks(snapshot.inventory) >= INVENTORY_FULL_SLOTS) {
|
||||||
|
return REASONS.INVENTORY_FULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx?.noTreesUntil && Date.now() < ctx.noTreesUntil) {
|
||||||
|
return REASONS.NO_REACHABLE_TARGET;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!planExists) return REASONS.PLANNER_EMPTY;
|
||||||
|
|
||||||
|
return REASONS.AWAITING_ACTION_COOLDOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNoProgressDetector({ stillThresholdMs = STILL_THRESHOLD_MS } = {}) {
|
||||||
|
let stillSince = null;
|
||||||
|
let lastPos = null;
|
||||||
|
let lastInvKey = "";
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stillSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detect({ snapshot, ctx, planExists = true, now = Date.now() }) {
|
||||||
|
if (!snapshot?.connected) {
|
||||||
|
reset();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ctx?.busy) {
|
||||||
|
reset();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = snapshot.position;
|
||||||
|
const ik = inventoryKey(snapshot.inventory);
|
||||||
|
const moved =
|
||||||
|
!lastPos || !pos
|
||||||
|
? lastPos !== pos
|
||||||
|
: Math.hypot(pos.x - lastPos.x, pos.z - lastPos.z) > POS_EPSILON ||
|
||||||
|
Math.abs(pos.y - lastPos.y) > POS_EPSILON;
|
||||||
|
const invChanged = ik !== lastInvKey;
|
||||||
|
|
||||||
|
if (moved || invChanged) {
|
||||||
|
lastPos = pos ? { ...pos } : null;
|
||||||
|
lastInvKey = ik;
|
||||||
|
stillSince = now;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stillSince == null) {
|
||||||
|
stillSince = now;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - stillSince < stillThresholdMs) return null;
|
||||||
|
|
||||||
|
return classify({ snapshot, ctx, planExists });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { detect, reset };
|
||||||
|
}
|
||||||
@@ -170,3 +170,30 @@ export function stopPlanner() {
|
|||||||
export function readCurrentPlan() {
|
export function readCurrentPlan() {
|
||||||
return readOr(PLAN_PATH);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Finite-state classifier for the runtime. Each tick the bot is in exactly
|
||||||
|
// one of:
|
||||||
|
//
|
||||||
|
// emergency — survival is at risk (low HP, hostile in melee, drowning…)
|
||||||
|
// working — an async action is in flight (reflexCtx.busy === true)
|
||||||
|
// recovering — last action failed recently and we are in its cooldown
|
||||||
|
// planning — the LLM planner is computing a fresh plan.md
|
||||||
|
// social — we just produced a chat reply (cools down social activity)
|
||||||
|
// idle — none of the above; the bot is up but nothing is happening
|
||||||
|
//
|
||||||
|
// Pure function: takes the current observed inputs and returns a string.
|
||||||
|
// No side effects; the caller decides what to do with the classification.
|
||||||
|
|
||||||
|
export const STATES = Object.freeze({
|
||||||
|
EMERGENCY: "emergency",
|
||||||
|
WORKING: "working",
|
||||||
|
RECOVERING: "recovering",
|
||||||
|
PLANNING: "planning",
|
||||||
|
SOCIAL: "social",
|
||||||
|
IDLE: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMERGENCY_HP = 8;
|
||||||
|
const EMERGENCY_HOSTILE_DISTANCE = 4;
|
||||||
|
const RECOVERING_WINDOW_MS = 30_000;
|
||||||
|
const SOCIAL_WINDOW_MS = 5_000;
|
||||||
|
|
||||||
|
export function computeState({
|
||||||
|
snapshot,
|
||||||
|
ctx,
|
||||||
|
plannerInFlight = false,
|
||||||
|
lastChatReplyAt = 0,
|
||||||
|
lastFailureAt = 0,
|
||||||
|
now = Date.now(),
|
||||||
|
}) {
|
||||||
|
if (!snapshot?.connected) return STATES.IDLE;
|
||||||
|
|
||||||
|
const hp = snapshot.health ?? 20;
|
||||||
|
if (hp <= EMERGENCY_HP) return STATES.EMERGENCY;
|
||||||
|
if (snapshot.closestHostile && snapshot.closestHostile.distance <= EMERGENCY_HOSTILE_DISTANCE) {
|
||||||
|
return STATES.EMERGENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx?.busy) return STATES.WORKING;
|
||||||
|
|
||||||
|
if (lastFailureAt && now - lastFailureAt < RECOVERING_WINDOW_MS) {
|
||||||
|
return STATES.RECOVERING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plannerInFlight) return STATES.PLANNING;
|
||||||
|
|
||||||
|
if (lastChatReplyAt && now - lastChatReplyAt < SOCIAL_WINDOW_MS) {
|
||||||
|
return STATES.SOCIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return STATES.IDLE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Optional prismarine-viewer launch — local visual debugging surface.
|
||||||
|
// Activated by setting VIEWER_PORT in .env (e.g. 3007). The dependency is
|
||||||
|
// not required at runtime: if `prismarine-viewer` is not installed, this
|
||||||
|
// module logs once and returns, so production deploys aren't forced to
|
||||||
|
// carry the extra dep.
|
||||||
|
|
||||||
|
import { info, warn } from "./log.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
export async function maybeStartViewer(bot) {
|
||||||
|
if (started) return;
|
||||||
|
const port = config.viewerPort;
|
||||||
|
if (!port) return;
|
||||||
|
let mineflayerViewer;
|
||||||
|
try {
|
||||||
|
({ mineflayer: mineflayerViewer } = await import("prismarine-viewer"));
|
||||||
|
} catch (e) {
|
||||||
|
warn("viewer", `VIEWER_PORT=${port} requested but prismarine-viewer is not installed (npm i prismarine-viewer)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mineflayerViewer(bot, { port, firstPerson: false });
|
||||||
|
started = true;
|
||||||
|
info("viewer", `prismarine-viewer listening on http://localhost:${port}`);
|
||||||
|
} catch (e) {
|
||||||
|
warn("viewer", `failed to start: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-6
@@ -100,8 +100,26 @@ function reducer(state: State, action: Action): State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATE_COLOR: Record<string, string> = {
|
||||||
|
emergency: "red",
|
||||||
|
working: "cyan",
|
||||||
|
recovering: "yellow",
|
||||||
|
planning: "magenta",
|
||||||
|
social: "blue",
|
||||||
|
idle: "gray",
|
||||||
|
};
|
||||||
|
|
||||||
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
|
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
|
||||||
const tone = snapshot.connected ? "green" : "red";
|
const tone = snapshot.connected ? "green" : "red";
|
||||||
|
const stateName: string = snapshot.runtimeState ?? "?";
|
||||||
|
const stateColor = STATE_COLOR[stateName] ?? "white";
|
||||||
|
const reason: string | null = snapshot.noProgressReason ?? null;
|
||||||
|
const lastResult: any = snapshot.lastResult ?? null;
|
||||||
|
const milestone: string | null = snapshot.currentMilestone ?? null;
|
||||||
|
const failuresByCode: Record<string, number> = snapshot.failuresByCode ?? {};
|
||||||
|
const failuresStr = Object.entries(failuresByCode)
|
||||||
|
.map(([k, v]) => `${k}:${v}`)
|
||||||
|
.join(" ");
|
||||||
return (
|
return (
|
||||||
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
|
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
|
||||||
<Text>
|
<Text>
|
||||||
@@ -112,6 +130,10 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
|
|||||||
<Text color={connectedToBot ? "green" : "red"}>{connectedToBot ? "IPC ok" : "IPC down"}</Text>
|
<Text color={connectedToBot ? "green" : "red"}>{connectedToBot ? "IPC ok" : "IPC down"}</Text>
|
||||||
{" "}
|
{" "}
|
||||||
{paused ? <Text color="yellow">⏸ reflex paused</Text> : <Text color="green">▶ reflex live</Text>}
|
{paused ? <Text color="yellow">⏸ reflex paused</Text> : <Text color="green">▶ reflex live</Text>}
|
||||||
|
{" "}
|
||||||
|
<Text color={stateColor} bold>
|
||||||
|
state={stateName}
|
||||||
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "}
|
user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "}
|
||||||
@@ -125,17 +147,43 @@ function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; p
|
|||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{snapshot.busy ? (
|
{snapshot.busy ? (
|
||||||
<Text color="cyan">▸ busy: {snapshot.busy.label}</Text>
|
<Text color="cyan">▸ skill: {snapshot.busy.label}</Text>
|
||||||
) : snapshot.lastReflex ? (
|
) : snapshot.activeSkill ? (
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
last reflex: {snapshot.lastReflex.name}
|
last skill: {snapshot.activeSkill}
|
||||||
{snapshot.lastReflex.label ? ` (${snapshot.lastReflex.label})` : ""}{" "}
|
{snapshot.lastReflex?.ts ? ` (${formatAge(snapshot.lastReflex.ts)})` : ""}
|
||||||
{snapshot.lastReflex.ts ? formatAge(snapshot.lastReflex.ts) : ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text dimColor>no reflex action yet</Text>
|
<Text dimColor>no skill yet</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text dimColor>milestone: </Text>
|
||||||
|
<Text>{milestone ?? <Text dimColor>(none — planner_empty?)</Text>}</Text>
|
||||||
|
</Text>
|
||||||
|
{reason ? (
|
||||||
|
<Text>
|
||||||
|
<Text color="yellow" bold>
|
||||||
|
▲ no-progress:
|
||||||
|
</Text>{" "}
|
||||||
|
<Text color="yellow">{reason}</Text>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{lastResult ? (
|
||||||
|
<Text>
|
||||||
|
<Text dimColor>last result: </Text>
|
||||||
|
<Text color={lastResult.ok ? "green" : "red"}>
|
||||||
|
{lastResult.label} → {lastResult.code ?? (lastResult.ok ? "ok" : "fail")}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>{lastResult.ts ? ` ${formatAge(lastResult.ts)}` : ""}</Text>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{failuresStr ? (
|
||||||
|
<Text dimColor>failures by class: {failuresStr}</Text>
|
||||||
|
) : null}
|
||||||
|
{snapshot.lastEscalation?.ts ? (
|
||||||
|
<Text dimColor>last Pi escalation: {formatAge(snapshot.lastEscalation.ts)}</Text>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user