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>
This commit is contained in:
@@ -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