feat(runtime): hybrid script reflex + Ink TUI + Pi-on-demand escalation (#4)
* fix(mindcraft-skills): hard timeout on every skill call mc_avoid_enemies (and 7 other tools) wrapped only in safeCall without a withTimeout. When mindcraft's underlying pathfinder/pvp goal couldn't be satisfied, the call never resolved — the Pi tick loop blocked forever. Observed live: mc_avoid_enemies pending >10 minutes after one mc_observe. safeCall now takes timeoutMs (default 30s) and wraps withTimeout itself, so every tool gets a hard ceiling. Per-tool overrides: - goToPosition / goToNearestBlock: 120s / 90s (unchanged from before) - defendSelf / avoidEnemies: 45s - stay: secs*1000 + 10s - craft / consume / pickup / place: 30s - equip: 15s collectBlock still uses its bespoke per-iter 75s loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(runtime): script-driven reflex daemon + Ink TUI dashboard Pure-Pi runtime had three failure modes in practice: - slow: 20-60s per decision because LLM was in the hot path - expensive: every tick (defend, eat, idle) paid for a reasoning pass - invisible: required tmux capture-pane to know what the bot was doing New runtime/ layer is a long-running Node daemon that owns the MC connection, ticks a priority-ordered reflex chain (defend > eat > sleep > idle) with NO LLM in the hot path, and exposes status + commands over a Unix-socket IPC. tui/ is an Ink dashboard that attaches over IPC and can detach freely — multiple TUI clients can connect at once. Pi/Codex are still available, but as on-demand escalation: TUI hotkey 'a' spawns `pi -p "<prompt>"` as a subprocess and streams stdout into the dashboard. The self-improvement loop (proposals → operator approval → Pi-driven patch → hot reload) is documented in docs/runtime.md but not yet wired. Reflex bodies are stubs today — they log decisions but don't drive Mineflayer actions yet. The priority chain, IPC contract, and TUI are fully working; subsequent commits will fill in defend/eat/sleep bodies and wire automatic escalation. Run with `npm run bot` + `npm run tui`. Pi-only fallback stays at `npm run agent`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- 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 #4.
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
// Long-running Mineflayer process. Owns:
|
||||
// - the MC TCP connection + reconnect policy
|
||||
// - the reflex tick loop (no LLM in hot path)
|
||||
// - the IPC server for TUI clients
|
||||
// - on-demand Pi-headless escalation
|
||||
//
|
||||
// Lifecycle: started by `npm run bot`. Connects to MC, spawns IPC server,
|
||||
// ticks every TICK_INTERVAL_SECONDS, broadcasts STATUS to clients each tick.
|
||||
// SIGINT / SIGTERM: graceful disconnect + socket cleanup + exit.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import mineflayer from "mineflayer";
|
||||
|
||||
import { config, stateDir, redactedConfig } from "./config.js";
|
||||
import { info, warn, error } from "./log.js";
|
||||
import { snapshot as buildSnapshot } from "./perceive.js";
|
||||
import { runTick } from "./reflex.js";
|
||||
import { createIpcServer } from "./ipc-server.js";
|
||||
import { askPi } from "./pi-bridge.js";
|
||||
import { COMMAND_TYPES, EVENT_TYPES } from "./ipc-protocol.js";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
|
||||
let bot = null;
|
||||
let reflexPaused = false;
|
||||
let tickTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let shuttingDown = false;
|
||||
let lastSnapshot = { connected: false };
|
||||
|
||||
const reflexCtx = { snapshot: lastSnapshot, idleCounter: 0 };
|
||||
|
||||
let chatTimestamps = [];
|
||||
const CHAT_WINDOW_MS = 60_000;
|
||||
|
||||
let ipc;
|
||||
|
||||
function chatRateAllowed() {
|
||||
const now = Date.now();
|
||||
chatTimestamps = chatTimestamps.filter((t) => now - t < CHAT_WINDOW_MS);
|
||||
if (chatTimestamps.length >= config.chatRateLimitPerMin) return false;
|
||||
chatTimestamps.push(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasJoinedBefore() {
|
||||
return fs.existsSync(JOINED_FLAG);
|
||||
}
|
||||
|
||||
function markJoinedBefore() {
|
||||
try {
|
||||
fs.writeFileSync(JOINED_FLAG, new Date().toISOString());
|
||||
} catch (e) {
|
||||
warn("auth", `could not write joined flag: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeHandleAuthPrompt(text) {
|
||||
if (!bot || !config.authmePassword) return;
|
||||
const lower = text.toLowerCase();
|
||||
const sawRegister = lower.includes("/register");
|
||||
const sawLogin = lower.includes("/login");
|
||||
if (!sawRegister && !sawLogin) return;
|
||||
|
||||
const cmd = sawLogin ? "login" : hasJoinedBefore() ? "login" : "register";
|
||||
if (cmd === "register") {
|
||||
bot.chat(`/register ${config.authmePassword} ${config.authmePassword}`);
|
||||
info("auth", "sent /register (password redacted)");
|
||||
} else {
|
||||
bot.chat(`/login ${config.authmePassword}`);
|
||||
info("auth", "sent /login (password redacted)");
|
||||
}
|
||||
markJoinedBefore();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (bot) return;
|
||||
info("mc", `connecting as ${config.username} → ${config.host}:${config.port} (v${config.version})`);
|
||||
bot = mineflayer.createBot({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
auth: config.authMode === "microsoft" ? "microsoft" : "offline",
|
||||
version: config.version,
|
||||
hideErrors: false,
|
||||
});
|
||||
|
||||
bot.once("spawn", () => {
|
||||
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
|
||||
});
|
||||
|
||||
bot.on("messagestr", (text, _position, _jsonMsg) => {
|
||||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: "server", text, kind: "system" });
|
||||
maybeHandleAuthPrompt(text);
|
||||
});
|
||||
|
||||
bot.on("chat", (username, message) => {
|
||||
if (username === bot.username) return;
|
||||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: username, text: message, kind: "player" });
|
||||
});
|
||||
|
||||
bot.on("death", () => {
|
||||
const pos = bot.entity?.position;
|
||||
warn("mc", `died at ${JSON.stringify(pos)}`);
|
||||
ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos });
|
||||
});
|
||||
|
||||
bot.on("kicked", (reason) => {
|
||||
warn("mc", `kicked: ${reason}`);
|
||||
});
|
||||
|
||||
bot.on("error", (err) => {
|
||||
error("mc", `bot error: ${err?.message ?? err}`);
|
||||
});
|
||||
|
||||
bot.on("end", (reason) => {
|
||||
warn("mc", `connection ended: ${reason}`);
|
||||
bot = null;
|
||||
lastSnapshot = { connected: false };
|
||||
if (!shuttingDown) scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer || shuttingDown) return;
|
||||
const delay = 5000;
|
||||
info("mc", `reconnecting in ${delay}ms`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (shuttingDown) return;
|
||||
if (bot && bot.entity) {
|
||||
lastSnapshot = buildSnapshot(bot);
|
||||
reflexCtx.snapshot = lastSnapshot;
|
||||
if (!reflexPaused) {
|
||||
runTick(reflexCtx);
|
||||
}
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
} else {
|
||||
lastSnapshot = { connected: false };
|
||||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function startTickLoop() {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = setInterval(tick, config.tickIntervalMs);
|
||||
}
|
||||
|
||||
function handleCommand(msg, send) {
|
||||
switch (msg.type) {
|
||||
case COMMAND_TYPES.PAUSE:
|
||||
reflexPaused = true;
|
||||
info("ipc", "reflex paused by client");
|
||||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "ipc", text: "reflex paused" });
|
||||
break;
|
||||
case COMMAND_TYPES.RESUME:
|
||||
reflexPaused = false;
|
||||
info("ipc", "reflex resumed by client");
|
||||
break;
|
||||
case COMMAND_TYPES.STOP:
|
||||
info("ipc", "stop requested by client");
|
||||
gracefulExit(0);
|
||||
break;
|
||||
case COMMAND_TYPES.CHAT: {
|
||||
const text = (msg.payload?.text || "").trim();
|
||||
if (!text || !bot) return;
|
||||
if (!chatRateAllowed()) {
|
||||
send(EVENT_TYPES.ERROR, { source: "chat", text: "rate-limited" });
|
||||
return;
|
||||
}
|
||||
bot.chat(text);
|
||||
break;
|
||||
}
|
||||
case COMMAND_TYPES.ASK_PI: {
|
||||
const prompt = msg.payload?.prompt;
|
||||
if (!prompt) return;
|
||||
askPi({
|
||||
prompt,
|
||||
onChunk: (chunk) => ipc?.broadcast(EVENT_TYPES.ASK_PI_CHUNK, chunk),
|
||||
onDone: (result) => ipc?.broadcast(EVENT_TYPES.ASK_PI_DONE, result),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case COMMAND_TYPES.SNAPSHOT:
|
||||
send(EVENT_TYPES.STATUS, lastSnapshot);
|
||||
break;
|
||||
default:
|
||||
warn("ipc", `unknown command type: ${msg.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function gracefulExit(code) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
info("runtime", "shutting down");
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
try {
|
||||
bot?.quit("shutdown");
|
||||
} catch {}
|
||||
ipc?.close();
|
||||
setTimeout(() => process.exit(code), 500);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => gracefulExit(0));
|
||||
process.on("SIGTERM", () => gracefulExit(0));
|
||||
|
||||
info("runtime", `pepa runtime starting; cfg=${JSON.stringify(redactedConfig())}`);
|
||||
ipc = createIpcServer({
|
||||
getStatusSnapshot: () => lastSnapshot,
|
||||
onCommand: handleCommand,
|
||||
});
|
||||
connect();
|
||||
startTickLoop();
|
||||
@@ -0,0 +1,48 @@
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
export const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
loadDotenv({ path: path.join(REPO_ROOT, ".env") });
|
||||
|
||||
function req(name) {
|
||||
const v = process.env[name]?.trim();
|
||||
if (!v) throw new Error(`Missing required env var: ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function opt(name, fallback = "") {
|
||||
return process.env[name]?.trim() || fallback;
|
||||
}
|
||||
|
||||
const host = req("MC_HOST");
|
||||
const port = Number.parseInt(opt("MC_PORT", "25565"), 10);
|
||||
const username = req("MC_USERNAME");
|
||||
|
||||
export const config = Object.freeze({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
version: opt("MC_VERSION", "1.21.5"),
|
||||
authMode: opt("MC_AUTH_MODE", "offline"),
|
||||
authmePassword: opt("MC_AUTHME_PASSWORD", ""),
|
||||
operators: opt("OPERATOR_USERNAMES", "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
|
||||
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
|
||||
});
|
||||
|
||||
export const serverKey = `${host}_${port}`;
|
||||
export const stateDir = path.join(REPO_ROOT, "state", serverKey);
|
||||
export const socketPath = path.join(stateDir, "bot.sock");
|
||||
|
||||
// Redacted env view for logs — never include the AuthMe password.
|
||||
export function redactedConfig() {
|
||||
const { authmePassword, ...rest } = config;
|
||||
return { ...rest, authmePassword: authmePassword ? "***" : "(unset)" };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared IPC contract between runtime/bot.js (server) and tui/tui.tsx (client).
|
||||
// Frame format: one JSON object per line over a Unix-domain socket.
|
||||
// Socket path: state/<server-key>/bot.sock (created by server, removed on shutdown).
|
||||
|
||||
export const SOCKET_BASENAME = "bot.sock";
|
||||
|
||||
// Server → client messages.
|
||||
export const EVENT_TYPES = Object.freeze({
|
||||
STATUS: "status", // periodic snapshot (HP/food/pos/task/connection)
|
||||
LOG: "log", // free-form log line { level, source, text }
|
||||
CHAT: "chat", // MC chat { from, text, kind: "player" | "system" }
|
||||
DEATH: "death", // death event { reason, position }
|
||||
ERROR: "error", // recoverable runtime error { source, text }
|
||||
ASK_PI_CHUNK: "ask-pi-chunk", // streamed stdout chunk from Pi subprocess
|
||||
ASK_PI_DONE: "ask-pi-done", // Pi subprocess exited { code, durationMs }
|
||||
HELLO: "hello", // sent on client connect with current snapshot
|
||||
});
|
||||
|
||||
// Client → server commands.
|
||||
export const COMMAND_TYPES = Object.freeze({
|
||||
PAUSE: "cmd:pause", // reflex loop stops ticking; connection stays
|
||||
RESUME: "cmd:resume", // reflex loop resumes
|
||||
STOP: "cmd:stop", // graceful disconnect + process exit
|
||||
CHAT: "cmd:chat", // { text } sent into MC as bot
|
||||
ASK_PI: "cmd:ask-pi", // { prompt } spawn `pi -p` and stream output
|
||||
SNAPSHOT: "cmd:snapshot", // request immediate STATUS event
|
||||
});
|
||||
|
||||
export function encodeFrame(obj) {
|
||||
return JSON.stringify(obj) + "\n";
|
||||
}
|
||||
|
||||
// Stateful line splitter — instance per socket.
|
||||
export function createLineParser(onObject) {
|
||||
let buf = "";
|
||||
return (chunk) => {
|
||||
buf += chunk.toString("utf8");
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
onObject(JSON.parse(line));
|
||||
} catch (e) {
|
||||
onObject({ __parseError: true, raw: line, err: String(e) });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Unix-socket server that exposes the bot to local TUI clients.
|
||||
// One server can hold N clients; events are broadcast to all of them.
|
||||
// All frames are JSON + newline. See ipc-protocol.js for the contract.
|
||||
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { createLineParser, encodeFrame, EVENT_TYPES } from "./ipc-protocol.js";
|
||||
import { socketPath } from "./config.js";
|
||||
import { info, warn, recentLogs, onLog } from "./log.js";
|
||||
|
||||
export function createIpcServer({ getStatusSnapshot, onCommand }) {
|
||||
// Clean stale socket if a previous run crashed before cleanup.
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") warn("ipc", `could not unlink stale socket: ${e.message}`);
|
||||
}
|
||||
|
||||
const clients = new Set();
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
clients.add(socket);
|
||||
info("ipc", `client connected (total=${clients.size})`);
|
||||
|
||||
const send = (type, payload) => {
|
||||
try {
|
||||
socket.write(encodeFrame({ type, ts: new Date().toISOString(), payload }));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// On hello: send current snapshot + recent logs so the TUI can render
|
||||
// immediately without waiting for the next tick.
|
||||
send(EVENT_TYPES.HELLO, {
|
||||
snapshot: getStatusSnapshot(),
|
||||
recentLogs: recentLogs(50),
|
||||
});
|
||||
|
||||
const parser = createLineParser((obj) => {
|
||||
if (obj.__parseError) {
|
||||
warn("ipc", `bad frame from client: ${obj.err}`);
|
||||
return;
|
||||
}
|
||||
onCommand?.(obj, send);
|
||||
});
|
||||
|
||||
socket.on("data", parser);
|
||||
socket.on("close", () => {
|
||||
clients.delete(socket);
|
||||
info("ipc", `client disconnected (total=${clients.size})`);
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
warn("ipc", `client error: ${err?.message ?? err}`);
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
warn("ipc", `server error: ${err?.message ?? err}`);
|
||||
});
|
||||
|
||||
server.listen(socketPath, () => {
|
||||
fs.chmodSync(socketPath, 0o600);
|
||||
info("ipc", `listening on ${socketPath}`);
|
||||
});
|
||||
|
||||
// Forward every log entry to all subscribed clients.
|
||||
const unsubLog = onLog((entry) => broadcast(EVENT_TYPES.LOG, entry));
|
||||
|
||||
function broadcast(type, payload) {
|
||||
const frame = encodeFrame({ type, ts: new Date().toISOString(), payload });
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.write(frame);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
unsubLog();
|
||||
for (const c of clients) c.destroy();
|
||||
clients.clear();
|
||||
server.close();
|
||||
try {
|
||||
fs.unlinkSync(socketPath);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { broadcast, close };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Ring-buffered log with fan-out: stdout + IPC broadcast + on-disk daily file.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { stateDir } from "./config.js";
|
||||
|
||||
const LOGS_DIR = path.join(stateDir, "logs");
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
|
||||
const RING_SIZE = 500;
|
||||
const ring = []; // newest at end
|
||||
|
||||
const subscribers = new Set(); // fn(entry)
|
||||
|
||||
function todayStamp() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dailyLogPath() {
|
||||
return path.join(LOGS_DIR, `${todayStamp()}.log`);
|
||||
}
|
||||
|
||||
function appendDisk(entry) {
|
||||
const line = `${entry.ts} [${entry.level}] ${entry.source}: ${entry.text}\n`;
|
||||
try {
|
||||
fs.appendFileSync(dailyLogPath(), line);
|
||||
} catch {
|
||||
// disk full or read-only — give up silently to avoid log-of-log loops
|
||||
}
|
||||
}
|
||||
|
||||
export function log(level, source, text, details) {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
source,
|
||||
text: typeof text === "string" ? text : JSON.stringify(text),
|
||||
details,
|
||||
};
|
||||
ring.push(entry);
|
||||
if (ring.length > RING_SIZE) ring.shift();
|
||||
|
||||
// stdout mirror
|
||||
const prefix = `[${entry.ts}] [${level}] ${source}:`;
|
||||
const line = `${prefix} ${entry.text}`;
|
||||
if (level === "error") console.error(line);
|
||||
else console.log(line);
|
||||
|
||||
appendDisk(entry);
|
||||
|
||||
for (const sub of subscribers) {
|
||||
try {
|
||||
sub(entry);
|
||||
} catch {}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export const info = (src, msg, d) => log("info", src, msg, d);
|
||||
export const warn = (src, msg, d) => log("warn", src, msg, d);
|
||||
export const error = (src, msg, d) => log("error", src, msg, d);
|
||||
export const debug = (src, msg, d) => log("debug", src, msg, d);
|
||||
|
||||
export function recentLogs(n = 50) {
|
||||
return ring.slice(-n);
|
||||
}
|
||||
|
||||
export function onLog(fn) {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Build a compact, JSON-safe snapshot of the world around the bot. Used both
|
||||
// for reflex decisions and for periodic IPC STATUS events.
|
||||
|
||||
function vec3ToObj(v) {
|
||||
if (!v) return null;
|
||||
return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 };
|
||||
}
|
||||
|
||||
const HOSTILE = new Set([
|
||||
"zombie",
|
||||
"skeleton",
|
||||
"creeper",
|
||||
"spider",
|
||||
"witch",
|
||||
"pillager",
|
||||
"vindicator",
|
||||
"husk",
|
||||
"stray",
|
||||
"drowned",
|
||||
"phantom",
|
||||
"enderman",
|
||||
"slime",
|
||||
"magma_cube",
|
||||
"hoglin",
|
||||
"piglin_brute",
|
||||
"ravager",
|
||||
"warden",
|
||||
"breeze",
|
||||
"bogged",
|
||||
]);
|
||||
|
||||
export function snapshot(bot) {
|
||||
if (!bot || !bot.entity) {
|
||||
return { connected: false };
|
||||
}
|
||||
const pos = bot.entity.position;
|
||||
const entities = Object.values(bot.entities || {});
|
||||
const players = entities.filter((e) => e.type === "player" && e.username && e.username !== bot.username);
|
||||
const hostiles = entities.filter((e) => HOSTILE.has((e.name || "").toLowerCase()));
|
||||
const closestHostile = hostiles.reduce((best, e) => {
|
||||
const d = e.position.distanceTo(pos);
|
||||
return !best || d < best.d ? { d, e } : best;
|
||||
}, null);
|
||||
|
||||
const inventory = (bot.inventory?.items?.() ?? []).reduce((acc, item) => {
|
||||
acc[item.name] = (acc[item.name] ?? 0) + item.count;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
username: bot.username,
|
||||
position: vec3ToObj(pos),
|
||||
health: bot.health,
|
||||
food: bot.food,
|
||||
saturation: bot.foodSaturation,
|
||||
experience: bot.experience?.level,
|
||||
time: bot.time?.timeOfDay,
|
||||
isDay: bot.time?.isDay,
|
||||
weather: { rain: bot.isRaining, thunder: bot.thundering },
|
||||
dimension: bot.game?.dimension,
|
||||
inventory,
|
||||
players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })),
|
||||
hostileCount: hostiles.length,
|
||||
closestHostile: closestHostile
|
||||
? { name: closestHostile.e.name, distance: Math.round(closestHostile.d * 10) / 10 }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Spawn `pi -p "<prompt>"` as a one-shot subprocess and stream stdout/stderr
|
||||
// to the caller. Used for headless escalation: the bot's reflex / TUI can ask
|
||||
// Pi a single question without keeping a long-lived TUI session open.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { info, warn } from "./log.js";
|
||||
|
||||
// Resolve `pi` lazily — user has it on PATH via vite-plus shim.
|
||||
const PI_BIN = process.env.PI_BIN || "pi";
|
||||
|
||||
export function askPi({ prompt, onChunk, onDone, cwd, signal }) {
|
||||
const startedAt = Date.now();
|
||||
info("pi-bridge", `spawning pi -p (${prompt.length} chars)`);
|
||||
|
||||
const child = spawn(PI_BIN, ["-p", prompt], {
|
||||
cwd: cwd || process.cwd(),
|
||||
env: { ...process.env, CI: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
signal,
|
||||
});
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
onChunk?.({ stream: "stdout", text: chunk });
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
onChunk?.({ stream: "stderr", text: chunk });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
warn("pi-bridge", `pi subprocess failed to start: ${err?.message ?? err}`);
|
||||
onDone?.({ code: -1, durationMs: Date.now() - startedAt, error: String(err) });
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
info("pi-bridge", `pi exited code=${code} after ${durationMs}ms`);
|
||||
onDone?.({ code: code ?? -1, durationMs });
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Reflex layer: priority-ordered list of pure-script behaviors. Each reflex
|
||||
// inspects the latest snapshot and either returns a no-op or starts an action.
|
||||
// LLM is NOT called here. If every reflex declines, the tick yields and we try
|
||||
// again next interval. Escalation to Pi happens elsewhere, only when the bot
|
||||
// has been idle/stuck for an extended window.
|
||||
|
||||
import { info, warn } from "./log.js";
|
||||
|
||||
const REFLEX_LOG = "reflex";
|
||||
|
||||
// A reflex returns one of:
|
||||
// { action: "noop" } — nothing to do
|
||||
// { action: "starting", kind, detail } — kicked off async work
|
||||
// { action: "completed", kind, detail } — fully sync, already done
|
||||
// Reflexes must NEVER throw — they should log and return noop on failure.
|
||||
|
||||
function defendReflex(ctx) {
|
||||
const s = ctx.snapshot;
|
||||
if (!s.connected) return { action: "noop" };
|
||||
if (!s.closestHostile) return { action: "noop" };
|
||||
if (s.closestHostile.distance > 6) return { action: "noop" };
|
||||
// Stub: just log. Real attack/flee logic comes in a follow-up commit.
|
||||
info(REFLEX_LOG, `defend: hostile ${s.closestHostile.name} at ${s.closestHostile.distance}m (stub, no action yet)`);
|
||||
return { action: "completed", kind: "defend-stub", detail: s.closestHostile };
|
||||
}
|
||||
|
||||
function eatReflex(ctx) {
|
||||
const s = ctx.snapshot;
|
||||
if (!s.connected) return { action: "noop" };
|
||||
if (s.food === undefined || s.food >= 16) return { action: "noop" };
|
||||
// Stub: log only. consume() integration arrives with the actions module.
|
||||
info(REFLEX_LOG, `eat: hunger ${s.food}/20 (stub, no action yet)`);
|
||||
return { action: "completed", kind: "eat-stub", detail: { food: s.food } };
|
||||
}
|
||||
|
||||
function sleepReflex(ctx) {
|
||||
const s = ctx.snapshot;
|
||||
if (!s.connected) return { action: "noop" };
|
||||
if (s.isDay) return { action: "noop" };
|
||||
if (!s.inventory?.["red_bed"] && !s.inventory?.["white_bed"]) return { action: "noop" };
|
||||
info(REFLEX_LOG, `sleep: night detected, bed in inventory (stub)`);
|
||||
return { action: "completed", kind: "sleep-stub" };
|
||||
}
|
||||
|
||||
function idleReflex(ctx) {
|
||||
const s = ctx.snapshot;
|
||||
if (!s.connected) return { action: "noop" };
|
||||
// Once every ~10 ticks, log a heartbeat with HP/food/pos. Tunable later.
|
||||
ctx.idleCounter = (ctx.idleCounter ?? 0) + 1;
|
||||
if (ctx.idleCounter % 10 !== 0) return { action: "noop" };
|
||||
info(REFLEX_LOG, `idle: hp=${s.health} food=${s.food} pos=${s.position?.x},${s.position?.y},${s.position?.z}`);
|
||||
return { action: "completed", kind: "idle-heartbeat" };
|
||||
}
|
||||
|
||||
const REFLEXES = [
|
||||
{ name: "defend", fn: defendReflex },
|
||||
{ name: "eat", fn: eatReflex },
|
||||
{ name: "sleep", fn: sleepReflex },
|
||||
{ name: "idle", fn: idleReflex },
|
||||
];
|
||||
|
||||
export function runTick(ctx) {
|
||||
for (const reflex of REFLEXES) {
|
||||
let outcome;
|
||||
try {
|
||||
outcome = reflex.fn(ctx);
|
||||
} catch (e) {
|
||||
warn(REFLEX_LOG, `reflex ${reflex.name} threw: ${e?.message ?? e}`);
|
||||
continue;
|
||||
}
|
||||
if (!outcome || outcome.action === "noop") continue;
|
||||
// First non-noop wins — stop the chain so we don't double-act per tick.
|
||||
return { reflex: reflex.name, ...outcome };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user