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:
Yuriy Mayatnikov
2026-05-25 16:04:48 +03:00
committed by GitHub
co-authored by Claude Opus 4.7 mayatnikov
parent 989ddf335e
commit 1e3b36a9a1
16 changed files with 2464 additions and 75 deletions
+10
View File
@@ -1,5 +1,15 @@
# pepa-pi-bot — agent mandate
> **Runtime notice (2026-05-25).** This file is the seed prompt for the
> **Pi-only runtime** (`npm run agent`). The default day-to-day runtime is now
> the hybrid one under [`runtime/`](./runtime/) — a script-driven reflex loop
> with Pi invoked only on demand. See [`docs/runtime.md`](./docs/runtime.md)
> for the new architecture. The principles below still apply to both modes:
> tool catalog, safety rules, operator-trust model, and memory protocol are
> shared. When you (the agent, in Pi-only mode) propose new code, prefer
> writing it as a reflex/action in `runtime/` over an extension in
> `extensions/` — the hybrid runtime is where new work lands going forward.
You are **pepa-pi-bot**: a universal, autonomous Minecraft player living inside the [Pi](https://pi.dev) runtime.
The repo you are running from is **your house**. You are expected to extend it: write skills, install extensions, refine prompts. Treat the repo as your long-term memory.
+80 -55
View File
@@ -1,38 +1,46 @@
# pepa-pi-bot
> A universal, autonomous, self-extending Minecraft player. Powered by [Pi](https://pi.dev) and the [Mineflayer](https://github.com/PrismarineJS/mineflayer) protocol stack. Works against **any** Minecraft Java server — vanilla, Paper, Spigot, Fabric, Forge, online-mode or cracked, modded or vanilla.
> A universal, autonomous, self-extending Minecraft player. Built on [Mineflayer](https://github.com/PrismarineJS/mineflayer) with a hybrid runtime: a fast script-driven reflex loop for the everyday, and headless [Pi](https://pi.dev) escalation for the hard bits. Works against **any** Minecraft Java server — vanilla, Paper, Spigot, Fabric, Forge, online-mode or cracked, modded or vanilla.
The bot is **not a finished application**. It is a seed: a Pi agent with an initial mandate and a hand-off to whatever Minecraft server you point it at. From there, the agent is expected to grow its own toolset — writing new skills, fetching extensions, and adapting its behaviour as it plays.
The bot is **not a finished application**. It is a seed: a Mineflayer body, a tiny reflex brain, and a hand-off to whatever Minecraft server you point it at. The bot is expected to grow its own toolset over time — writing new reflexes, installing skills, adapting its behaviour as it plays.
The name `pepa-pi-bot` is just the project's name (`pepa` from the original test server, `pi` from the runtime). The bot itself is server-agnostic.
The name `pepa-pi-bot` is just the project's name (`pepa` from the original test server, `pi` from the original runtime). The bot itself is server-agnostic.
## Runtime modes
Two ways to run the bot. The hybrid runtime is the default — Pi-only is a fallback for experiments.
| Mode | Entry | When to use |
|---|---|---|
| **Hybrid runtime** (recommended) | `npm run bot` + `npm run tui` | Day-to-day. Script-driven reflex tick + Ink TUI dashboard + Pi/Codex called only on demand. Fast, cheap, observable. |
| **Pi-only** (fallback) | `npm run agent` | When you want every decision to go through an LLM (rare, but useful for experiments and code-writing sessions). |
See [`docs/runtime.md`](./docs/runtime.md) for the full hybrid runtime guide, IPC protocol, TUI hotkeys, and the self-improvement loop.
## Concept
Most Minecraft AI bots ship as monolithic projects: hard-coded actions, fixed prompts, a single LLM provider, sometimes a single target server. This repo flips that around.
Most Minecraft AI bots ship as monolithic projects: hard-coded actions, fixed prompts, a single LLM provider, sometimes a single target server. This repo flips that around with a layered runtime.
```
┌───────────────────────────────────────────────┐
Pi (terminal agent, model-agnostic)
├── AGENTS.md ← generic mandate
│ ├── skills/ ← grown over time │
│ └── extensions/ ← TS plugins, also grown │
└───────────────┬───────────────────────────────┘
│ spawns / controls
┌───────────────────────────────────────────────┐
Mineflayer client
- joins MC server as a real player
- chat, movement, inventory, world events
└──────────────────────────────────────────────┘
│ TCP 25565
┌───────────────────────────────────────────────┐
│ ANY Minecraft Java server │
│ configured via .env (host, port, auth, ...) │
└───────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────
TUI (Ink) — operator dashboard, attaches via Unix sock
status / live log / MC chat / hotkeys / ask-Pi
└──────────────────────┬───────────────────────────────────┘
│ newline-JSON
┌──────────────────────────────────────────────────────────┐
│ runtime/bot.js — long-running Node daemon │
│ ├── Mineflayer client (MC TCP, AuthMe, chat, events) │
├── Reflex loop (defend > eat > sleep > idle)
│ pure script — no LLM in the hot path
└── pi-bridge — spawn `pi -p` only on demand
└──────────────────────┬───────────────────────────────────┘
│ TCP 25565 (any host/port)
ANY Minecraft Java server (configured in .env)
```
The Pi agent is the brain. Mineflayer is the body. The bridge between them — the skills, the prompt templates, the supervision loop — is meant to be written **by the agent itself**, starting from a minimal scaffold in this repo.
The reflex loop is the brain stem. Pi is the cortex — called only when the reflex loop is genuinely stuck, or when the operator asks for help via the TUI. Mineflayer is the body. The skills, reflexes, and supervision loop are meant to grow over time — both by hand and by the bot itself proposing patches.
## Prerequisites
@@ -49,41 +57,46 @@ The Pi agent is the brain. Mineflayer is the body. The bridge between them — t
## Quickstart
```bash
# 1. Clone
# 1. Clone + configure
git clone git@github.com:xmatic-squad/pepa-pi-bot.git
cd pepa-pi-bot
# 2. Configure for your target server
cp .env.example .env
$EDITOR .env # set MC_HOST, MC_USERNAME, auth mode, LLM provider, etc.
$EDITOR .env # set MC_HOST, MC_USERNAME, auth mode, AuthMe password, etc.
# 3. Install Node deps (mineflayer + dotenv to start)
# 2. Install Node deps
npm install
# 4. Authenticate Pi with your LLM provider
pi /login # OAuth flow — works with ChatGPT Pro / Claude Max
# OR
export OPENAI_API_KEY=sk-...
# OR
export ANTHROPIC_API_KEY=sk-ant-...
# 3. (Optional) Authenticate Pi for the escalation hotkey
pi /login # OAuth flow — ChatGPT Pro / Claude Max
# or export OPENAI_API_KEY / ANTHROPIC_API_KEY
# 5. Launch the agent in this directory
pi
# 4. Run the bot — two terminals
# Terminal 1: the daemon (logs in stdout, persists state under state/<host>/)
npm run bot
# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/q.
npm run tui
```
On first launch Pi loads `AGENTS.md` from the project root. That file is the seed prompt — it tells the agent it is a Minecraft player, where to find its configuration, and that it is expected to extend itself.
The TUI auto-reconnects to the bot if you restart it. Press `q` to leave the TUI; the bot keeps running.
### Send the first message
> Want the LLM-driven, single-process flavour? `npm run agent` launches the original Pi runtime instead. See [`docs/runtime.md`](./docs/runtime.md) for the trade-offs.
Pi only acts when you write to it. Paste the [bootstrap prompt](./prompts/bootstrap.md) as the very first message:
### Sending chat or asking Pi from the TUI
```
You're awake. Read AGENTS.md and the repo's current state, then begin executing "First objective — bootstrap your own body" from AGENTS.md. Walk me through each step before you run it the first time — I want to see which Pi tooling (extensions API, skill API, plain bash, etc.) you choose for the mineflayer bridge.
```
- Press **`c`** in the TUI to enter chat mode — type, Enter sends into MC chat (rate-limited per `.env`).
- Press **`a`** to enter ask-Pi mode — type a prompt, Enter spawns `pi -p "<prompt>"`. Output streams into the Pi panel without leaving the TUI.
The agent will then write `extensions/mineflayer-bridge.{ts,js}`, register it with Pi, handle whatever in-game login the server demands, send `hello`, and write its first skill at `skills/server-onboarding.md`.
### TUI hotkeys cheatsheet
Sessions persist by default. Use `pi -c` to resume the last conversation; subsequent sessions don't need the bootstrap prompt — a simple `Resume. Check the server's online, log in if needed, and report status.` is enough.
| Key | Effect |
|---|---|
| `p` | Pause / resume the reflex loop (MC connection stays). |
| `s` | Stop the bot (graceful disconnect + cleanup). |
| `r` | Force a fresh status snapshot. |
| `c` | Send a chat message into MC. |
| `a` | Ask Pi (one-shot subprocess). |
| `q` | Quit the TUI — bot keeps running. |
## Authentication
@@ -128,17 +141,29 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the skill format and how to propo
```
pepa-pi-bot/
├── README.md ← you are here
├── AGENTS.md ← seed prompt, loaded by Pi on launch
├── .env.example ← all required env vars, no secrets
├── .gitignore
├── LICENSEMIT
├── package.json ← node deps (mineflayer + dotenv to start)
├── skills/ ← grown by the agent (markdown skills)
├── extensions/ ← grown by the agent (typescript extensions)
├── prompts/ ← reusable prompt templates
├── README.md ← you are here
├── AGENTS.md ← seed prompt, loaded by the Pi-only runtime
├── .env.example ← all required env vars, no secrets
├── package.json ← node deps + scripts (`bot`, `tui`, `agent`)
├── runtime/ hybrid runtime (script reflex + IPC server)
│ ├── bot.js long-running Mineflayer daemon
│ ├── reflex.js priority-ordered behaviours, no LLM
│ ├── perceive.js snapshot builder
│ ├── ipc-server.js Unix-socket server
│ ├── ipc-protocol.js shared IPC contract
│ └── pi-bridge.js spawn `pi -p` on demand
├── tui/ ← Ink TUI dashboard
│ ├── tui.tsx
│ └── ipc-client.js
├── skills/ ← markdown skills (grown by bot or operator)
├── extensions/ ← Pi extensions (mindcraft-skills, mineflayer-bridge)
├── prompts/ ← reusable prompt templates
└── docs/
── architecture.md ← longer-form design notes
── runtime.md hybrid runtime guide (start here)
├── architecture.md longer-form design notes
├── memory-model.md per-server state layout
├── roadmap.md phased plan
└── …
```
## Safety boundaries
+217
View File
@@ -0,0 +1,217 @@
# Runtime — hybrid script + LLM-on-demand
> Status: **active**. This is the recommended way to run pepa-pi-bot since
> 2026-05-25. The pure Pi runtime (`pi` from repo root) still works and is
> documented as a fallback at the bottom of this file.
## Why a hybrid runtime?
The original design ran every tick inside Pi — the LLM saw the world, picked
one tool, executed it, looped. That gave full self-extension out of the box,
but had three problems in practice:
1. **Slow.** A "look around → defend yourself" round-trip took 2060 seconds
because the LLM was in the hot path.
2. **Expensive.** Hostile mob at 4 m? Cost of evasion = one full reasoning
pass. Hungry? Same. Idle? Same.
3. **Invisible.** With Pi as the only frontend, you had to `tmux capture-pane`
to know what the bot was doing.
The hybrid runtime splits the bot into a script-driven layer that handles
fast, well-understood things on its own, and a Pi (or Codex) headless
escalation that's only invoked when the script gets stuck or needs to write
new code for itself.
## Architecture
```
┌────────────────────────────────────────────────────────────────────┐
│ operator │
│ ├── repo edits (.env, skills/, runtime/) │
│ ├── TUI (Ink) — see status, send chat, press [a] to escalate │
│ └── (future) Telegram bridge │
└─────────────┬────────────────────────────────────────────┬─────────┘
│ Unix socket (newline-JSON) │ git
▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ runtime/bot.js — single long-running Node process │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ ┌────────────────┐│
│ │ Mineflayer │ │ Reflex loop │ │ IPC server ││
│ │ - MC TCP │ │ - tick every N sec │ │ - Unix socket ││
│ │ - AuthMe handler │◀─│ - priority order: │─▶│ - broadcasts ││
│ │ - chat / events │ │ defend > eat │ │ status/log/ ││
│ │ │ │ > sleep > current │ │ chat events ││
│ │ │ │ > idle │ │ - accepts ││
│ │ │ │ - NO LLM in path │ │ commands ││
│ └──────────────────┘ └─────────┬────────────┘ └────────────────┘│
│ │ │
│ ▼ on stuck / new scenario │
│ ┌──────────────────────┐ │
│ │ pi-bridge.js │ │
│ │ spawn `pi -p` │ │
│ │ stream stdout to IPC │ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│ TCP 25565
Minecraft server
```
The bot is **one process**. The TUI is a separate process you can connect and
disconnect at will — the bot keeps running. Multiple TUI clients can attach
to the same bot simultaneously.
## Quickstart
```bash
# Once
cd ~/Projects/pepa-pi-bot
npm install
# Terminal 1 — the bot daemon
npm run bot
# Logs go to stdout AND state/<host>/logs/<YYYY-MM-DD>.log
# Terminal 2 — the dashboard
npm run tui
```
The TUI auto-reconnects to the bot if you restart it. Press `q` to leave the
TUI; the bot is unaffected.
## TUI hotkeys
| Key | Effect |
|-----|--------|
| `p` | Pause / resume the reflex loop (MC connection stays). |
| `s` | Stop the bot process gracefully (disconnect + cleanup + exit). |
| `r` | Force-broadcast a status snapshot now. |
| `c` | Enter **chat mode** — type a message, Enter sends it into MC chat. |
| `a` | Enter **ask-Pi mode** — type a prompt, Enter spawns `pi -p` and streams output into the Pi panel. |
| `q` | Quit TUI only. Bot keeps running. |
`Enter` submits, blank submit cancels.
## What the reflex loop does today
All reflex bodies are currently **stubs** — they log decisions but don't yet
call into Mineflayer actions. The priority chain is wired:
1. `defendReflex` — closest hostile within 6 m → log + decision (next:
actually attack / flee).
2. `eatReflex` — food ≤ 16 → log (next: equip food, eat).
3. `sleepReflex` — night + bed in inventory → log (next: `bot.sleep`).
4. `idleReflex` — every 10th tick, log heartbeat (HP / food / pos).
Adding a new reflex = a function `(ctx) => { action, ... }` in
`runtime/reflex.js`, inserted at the right priority. Pure script, no LLM.
## When the bot calls Pi
Reflexes that don't handle a situation simply return `noop`. After N
consecutive tick cycles with no useful action — or when a reflex explicitly
flags "stuck" — the bot will escalate by calling `pi-bridge.js`:
```js
askPi({
prompt: "I've been at the same position for 5 minutes, last reflex chain
fell through, snapshot attached. What's a reasonable next action?",
onChunk, onDone,
});
```
This is **not wired into the reflex loop yet** — the escalation is currently
operator-driven via TUI hotkey `a`. Wiring it up as an automatic fallback is
the next milestone.
## IPC protocol
Socket: `state/<MC_HOST>_<MC_PORT>/bot.sock` (permissions 0600, removed on
shutdown). Framing: one JSON object per line.
**Server → client events** (see `runtime/ipc-protocol.js`):
| Type | Payload |
|------|---------|
| `hello` | `{ snapshot, recentLogs }` — sent on connect. |
| `status` | full snapshot from `perceive.js`. |
| `log` | `{ ts, level, source, text, details }` — every log line. |
| `chat` | `{ from, text, kind: "player" \| "system" }`. |
| `death` | `{ reason, position }`. |
| `error` | `{ source, text }`. |
| `ask-pi-chunk` | `{ stream: "stdout" \| "stderr", text }`. |
| `ask-pi-done` | `{ code, durationMs }`. |
**Client → server commands:**
| Type | Payload | Effect |
|------|---------|--------|
| `cmd:pause` | `{}` | Reflex loop stops ticking. |
| `cmd:resume` | `{}` | Reflex loop resumes. |
| `cmd:stop` | `{}` | Graceful shutdown of the bot. |
| `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). |
| `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p "<prompt>"`. |
| `cmd:snapshot` | `{}` | Force a `status` event now. |
The protocol is intentionally tiny — anyone can write a second client
(a Telegram bridge, a web UI, a one-shot CLI) by reading
`runtime/ipc-protocol.js`.
## Self-improvement loop (planned)
When a reflex repeatedly fails (e.g. "tried to navigate to base 3 times,
pathfinder returned noPath each time"), the bot will:
1. Write `state/<host>/proposals/YYYY-MM-DD-<slug>.md` describing the gap.
2. Mark a flag in the next `status` event so the TUI surfaces it.
3. Wait for operator approval (TUI key `y` on a proposal — not yet built).
4. On approval: spawn Pi headless with the proposal text + repo context, ask
it to write a new skill / patch, commit on a feature-branch.
5. Hot-reload the affected module (reflex / actions) without dropping the MC
connection.
This is the "bot writes its own code, asks permission, restarts itself"
loop — the whole point of having Pi as an escalation rather than a runtime.
Not wired yet; tracked under tasks #56#58 history.
## File layout
```
runtime/
bot.js entrypoint — owns MC + tick + IPC + reconnect
config.js reads .env, exposes frozen config + redacted view
log.js ring buffer + stdout + daily file + IPC fan-out
perceive.js snapshot(bot) → JSON
reflex.js priority chain (defend / eat / sleep / idle, stubs)
ipc-server.js Unix-socket server
ipc-protocol.js shared contract (event types, command types, framer)
pi-bridge.js spawn `pi -p`, stream stdout
tui/
tui.tsx Ink dashboard (React)
ipc-client.js socket client → EventEmitter
```
Per-server state stays under `state/<MC_HOST>_<MC_PORT>/`, gitignored, same
as before. The `bot.sock` lives there too.
## Pi-only fallback
The original Pi-driven runtime still works if you prefer the single-process
model — `npm run agent` from repo root loads `AGENTS.md` and the existing
extensions in `extensions/`. The two runtimes share the `.env`, the
`mineflayer` deps, and the `state/` directory. They MUST NOT run
simultaneously — both will try to claim the same MC nickname and the
server will kick one of them.
If you switch between them frequently, kill one before starting the other:
```bash
# stop hybrid
# (in TUI press 's', or just kill `npm run bot`)
# start Pi
npm run agent
```
+36 -18
View File
@@ -165,9 +165,19 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
const skills = skillsMod as Record<string, (...args: any[]) => any>;
const world = worldMod as Record<string, (...args: any[]) => any>;
const safeCall = async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {
// safeCall: wraps a skill call with error labeling AND a hard timeout.
// Without a timeout the Mindcraft skills (defendSelf / avoidEnemies / stay
// / craftRecipe / etc.) can hang forever inside pathfinder / pvp loops if
// the goal is unreachable, blocking the entire Pi loop. Observed live:
// mc_avoid_enemies pending >10 min with no progress. Default 30s; callers
// override per-tool (goToPosition gets 120s, etc).
const safeCall = async <T>(
label: string,
fn: () => T | Promise<T>,
timeoutMs: number = 30_000,
): Promise<T> => {
try {
return await fn();
return await withTimeout(Promise.resolve().then(fn), timeoutMs, label);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`${label}: ${msg}`);
@@ -319,7 +329,11 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { blockType: string; x: number; y: number; z: number }) {
const bot = getBot();
const ok = await safeCall("placeBlock", () => skills.placeBlock(bot, params.blockType, params.x, params.y, params.z));
const ok = await safeCall(
"placeBlock",
() => skills.placeBlock(bot, params.blockType, params.x, params.y, params.z),
30_000,
);
return textResult(ok ? `Placed ${params.blockType} at ${params.x},${params.y},${params.z}.` : `placeBlock returned false.`, { ok, ...params });
},
});
@@ -333,8 +347,10 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { x: number; y: number; z: number; minDistance?: number }) {
const bot = getBot();
const ok = await safeCall("goToPosition", () =>
withTimeout(skills.goToPosition(bot, params.x, params.y, params.z, params.minDistance ?? 2), 120_000, `goToPosition(${params.x},${params.y},${params.z})`),
const ok = await safeCall(
`goToPosition(${params.x},${params.y},${params.z})`,
() => skills.goToPosition(bot, params.x, params.y, params.z, params.minDistance ?? 2),
120_000,
);
return textResult(ok ? `Arrived near ${params.x},${params.y},${params.z}.` : `goToPosition returned false.`, { ok, ...params });
},
@@ -349,12 +365,10 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { blockType: string; minDistance?: number; range?: number }) {
const bot = getBot();
const ok = await safeCall("goToNearestBlock", () =>
withTimeout(
skills.goToNearestBlock(bot, params.blockType, params.minDistance ?? 2, params.range ?? 64),
90_000,
`goToNearestBlock(${params.blockType})`,
),
const ok = await safeCall(
`goToNearestBlock(${params.blockType})`,
() => skills.goToNearestBlock(bot, params.blockType, params.minDistance ?? 2, params.range ?? 64),
90_000,
);
return textResult(ok ? `Arrived near nearest ${params.blockType}.` : `goToNearestBlock returned false for ${params.blockType}.`, { ok, ...params });
},
@@ -369,7 +383,11 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { itemName: string; num?: number }) {
const bot = getBot();
const ok = await safeCall("craftRecipe", () => skills.craftRecipe(bot, params.itemName, Math.max(1, Math.floor(params.num ?? 1))));
const ok = await safeCall(
"craftRecipe",
() => skills.craftRecipe(bot, params.itemName, Math.max(1, Math.floor(params.num ?? 1))),
30_000,
);
return textResult(ok ? `Crafted ${params.itemName}.` : `craftRecipe returned false.`, { ok, ...params });
},
});
@@ -383,7 +401,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { itemName: string }) {
const bot = getBot();
const ok = await safeCall("equip", () => skills.equip(bot, params.itemName));
const ok = await safeCall("equip", () => skills.equip(bot, params.itemName), 15_000);
return textResult(ok ? `Equipped ${params.itemName}.` : `equip returned false for ${params.itemName}.`, { ok, ...params });
},
});
@@ -397,7 +415,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { itemName?: string }) {
const bot = getBot();
const ok = await safeCall("consume", () => skills.consume(bot, params.itemName ?? ""));
const ok = await safeCall("consume", () => skills.consume(bot, params.itemName ?? ""), 30_000);
return textResult(ok ? `Ate ${params.itemName ?? "food"}.` : `consume returned false.`, { ok, ...params });
},
});
@@ -411,7 +429,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { range?: number }) {
const bot = getBot();
const ok = await safeCall("defendSelf", () => skills.defendSelf(bot, params.range ?? 9));
const ok = await safeCall("defendSelf", () => skills.defendSelf(bot, params.range ?? 9), 45_000);
return textResult(ok ? `Defended against hostiles.` : `defendSelf returned false.`, { ok, ...params });
},
});
@@ -425,7 +443,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute(_id, params: { distance?: number }) {
const bot = getBot();
const ok = await safeCall("avoidEnemies", () => skills.avoidEnemies(bot, params.distance ?? 16));
const ok = await safeCall("avoidEnemies", () => skills.avoidEnemies(bot, params.distance ?? 16), 45_000);
return textResult(ok ? `Avoided enemies.` : `avoidEnemies returned false.`, { ok, ...params });
},
});
@@ -440,7 +458,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
async execute(_id, params: { seconds?: number }) {
const bot = getBot();
const secs = Math.max(1, Math.min(600, Math.floor(params.seconds ?? 30)));
await safeCall("stay", () => skills.stay(bot, secs));
await safeCall("stay", () => skills.stay(bot, secs), secs * 1000 + 10_000);
return textResult(`Stood still for ${secs}s.`, { seconds: secs });
},
});
@@ -454,7 +472,7 @@ export default async function mindcraftSkills(pi: ExtensionAPI) {
executionMode: "sequential",
async execute() {
const bot = getBot();
const ok = await safeCall("pickupNearbyItems", () => skills.pickupNearbyItems(bot));
const ok = await safeCall("pickupNearbyItems", () => skills.pickupNearbyItems(bot), 30_000);
return textResult(ok ? `Picked up nearby items.` : `pickupNearbyItems returned false.`);
},
});
+1087
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -10,10 +10,14 @@
},
"scripts": {
"agent": "pi",
"agent:resume": "pi -c"
"agent:resume": "pi -c",
"bot": "node runtime/bot.js",
"tui": "tsx tui/tui.tsx"
},
"dependencies": {
"dotenv": "^16.4.5",
"ink": "^7.0.4",
"ink-text-input": "^6.0.0",
"minecraft-data": "^3.110.2",
"mineflayer": "^4.37.1",
"mineflayer-armor-manager": "^2.0.1",
@@ -23,6 +27,7 @@
"mineflayer-pvp": "^1.3.2",
"mineflayer-tool": "^1.2.0",
"prismarine-item": "^1.18.0",
"react": "^19.2.6",
"vec3": "^0.2.0"
},
"repository": {
@@ -32,5 +37,9 @@
"bugs": {
"url": "https://github.com/xmatic-squad/pepa-pi-bot/issues"
},
"homepage": "https://github.com/xmatic-squad/pepa-pi-bot#readme"
"homepage": "https://github.com/xmatic-squad/pepa-pi-bot#readme",
"devDependencies": {
"@types/react": "^19.2.15",
"tsx": "^4.22.3"
}
}
+222
View File
@@ -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();
+48
View File
@@ -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)" };
}
+50
View File
@@ -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) });
}
}
};
}
+88
View File
@@ -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 };
}
+70
View File
@@ -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);
}
+69
View File
@@ -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,
};
}
+44
View File
@@ -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;
}
+76
View File
@@ -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;
}
+66
View File
@@ -0,0 +1,66 @@
// Thin client wrapper around the Unix-socket IPC server. Emits events as a
// regular EventEmitter so the React layer can subscribe.
import net from "node:net";
import { EventEmitter } from "node:events";
import { createLineParser, encodeFrame } from "../runtime/ipc-protocol.js";
import { socketPath } from "../runtime/config.js";
export function createIpcClient() {
const ee = new EventEmitter();
let socket = null;
let reconnectTimer = null;
let shuttingDown = false;
function connect() {
if (socket || shuttingDown) return;
socket = net.createConnection(socketPath);
const parser = createLineParser((obj) => {
if (obj.__parseError) {
ee.emit("error", new Error(`bad frame: ${obj.err}`));
return;
}
ee.emit("frame", obj);
if (obj.type) ee.emit(obj.type, obj.payload, obj.ts);
});
socket.setEncoding("utf8");
socket.on("connect", () => ee.emit("connected"));
socket.on("data", parser);
socket.on("close", () => {
socket = null;
ee.emit("disconnected");
if (!shuttingDown) scheduleReconnect();
});
socket.on("error", (err) => {
ee.emit("ipc-error", err);
});
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
}
function send(type, payload) {
if (!socket || socket.destroyed) return false;
try {
socket.write(encodeFrame({ type, payload }));
return true;
} catch {
return false;
}
}
function close() {
shuttingDown = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.destroy();
}
connect();
return Object.assign(ee, { send, close });
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Ink-based TUI dashboard for the pepa runtime.
*
* Layout:
* ┌─────────── status (HP / food / pos / task / connection / paused) ──────────┐
* │ ┌───────── event log ─────────┐ ┌──────── MC chat ──────────┐ │
* │ │ │ │ │ │
* │ └─────────────────────────────┘ └──────────────────────────┘ │
* └─────────────────── command bar (hotkeys + chat input) ─────────────────────┘
*
* Hotkeys:
* p — pause / resume reflex loop
* s — stop bot (sends cmd:stop)
* r — request fresh snapshot
* c — enter chat mode (type, Enter to send to MC)
* a — enter ask-Pi mode (type, Enter to spawn pi -p)
* q — quit TUI (bot keeps running)
*/
import React, { useEffect, useReducer, useState } from "react";
import { render, Box, Text, useApp, useInput } from "ink";
import TextInput from "ink-text-input";
import { createIpcClient } from "./ipc-client.js";
import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js";
type LogEntry = { ts: string; level: string; source: string; text: string };
type ChatEntry = { ts: string; from: string; text: string; kind: string };
type Snapshot = Record<string, any>;
type State = {
connectedToBot: boolean;
snapshot: Snapshot;
logs: LogEntry[];
chat: ChatEntry[];
paused: boolean;
piStream: string;
piRunning: boolean;
};
type Action =
| { type: "ipc-connected" }
| { type: "ipc-disconnected" }
| { type: "snapshot"; payload: Snapshot }
| { type: "log"; payload: LogEntry }
| { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string }
| { type: "death"; payload: any; ts: string }
| { type: "pi-chunk"; payload: { stream: string; text: string } }
| { type: "pi-done"; payload: { code: number; durationMs: number } }
| { type: "set-paused"; paused: boolean }
| { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } };
const MAX_LOGS = 200;
const MAX_CHAT = 100;
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ipc-connected":
return { ...state, connectedToBot: true };
case "ipc-disconnected":
return { ...state, connectedToBot: false };
case "snapshot":
return { ...state, snapshot: action.payload || {} };
case "log":
return { ...state, logs: [...state.logs, action.payload].slice(-MAX_LOGS) };
case "chat":
return { ...state, chat: [...state.chat, { ts: action.ts, ...action.payload }].slice(-MAX_CHAT) };
case "death":
return {
...state,
logs: [
...state.logs,
{ ts: action.ts, level: "warn", source: "mc", text: `death at ${JSON.stringify(action.payload?.position ?? null)}` },
].slice(-MAX_LOGS),
};
case "pi-chunk":
return { ...state, piRunning: true, piStream: (state.piStream + action.payload.text).slice(-2000) };
case "pi-done":
return {
...state,
piRunning: false,
piStream: state.piStream + `\n[pi done code=${action.payload.code} after ${action.payload.durationMs}ms]\n`,
};
case "set-paused":
return { ...state, paused: action.paused };
case "hello":
return {
...state,
snapshot: action.payload.snapshot || {},
logs: action.payload.recentLogs || [],
};
default:
return state;
}
}
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
const tone = snapshot.connected ? "green" : "red";
return (
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
<Text>
<Text color={tone} bold>
{snapshot.connected ? "● MC online" : "○ MC offline"}
</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>}
</Text>
<Text>
user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "}
pos=
{snapshot.position
? `${snapshot.position.x},${snapshot.position.y},${snapshot.position.z}`
: "?"}{" "}
day={String(snapshot.isDay ?? "?")} hostiles={snapshot.hostileCount ?? 0}
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
</Text>
</Box>
);
}
function EventLog({ logs }: { logs: LogEntry[] }) {
const last = logs.slice(-14);
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} width="60%">
<Text bold underline>
events
</Text>
{last.map((l, i) => (
<Text key={i} color={l.level === "warn" ? "yellow" : l.level === "error" ? "red" : "white"}>
{l.ts.slice(11, 19)} [{l.source}] {l.text}
</Text>
))}
</Box>
);
}
function ChatPanel({ chat }: { chat: ChatEntry[] }) {
const last = chat.slice(-14);
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} width="40%">
<Text bold underline>
MC chat
</Text>
{last.map((c, i) => (
<Text key={i} color={c.kind === "system" ? "gray" : "cyan"}>
{c.ts?.slice(11, 19) ?? ""} {c.from}: {c.text}
</Text>
))}
</Box>
);
}
function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean }) {
if (!piStream && !piRunning) return null;
return (
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor={piRunning ? "magenta" : "gray"}>
<Text bold underline>
pi (escalation) {piRunning ? "● running" : "○ idle"}
</Text>
<Text>{piStream || "(no output yet)"}</Text>
</Box>
);
}
type Mode = "idle" | "chat" | "ask-pi";
function App() {
const { exit } = useApp();
const [state, dispatch] = useReducer(reducer, {
connectedToBot: false,
snapshot: {},
logs: [],
chat: [],
paused: false,
piStream: "",
piRunning: false,
});
const [client] = useState(() => createIpcClient());
const [mode, setMode] = useState<Mode>("idle");
const [inputValue, setInputValue] = useState("");
useEffect(() => {
const onConnected = () => dispatch({ type: "ipc-connected" });
const onDisconnected = () => dispatch({ type: "ipc-disconnected" });
const onFrame = (frame: any) => {
switch (frame.type) {
case EVENT_TYPES.STATUS:
dispatch({ type: "snapshot", payload: frame.payload });
break;
case EVENT_TYPES.LOG:
dispatch({ type: "log", payload: frame.payload });
break;
case EVENT_TYPES.CHAT:
dispatch({ type: "chat", payload: frame.payload, ts: frame.ts });
break;
case EVENT_TYPES.DEATH:
dispatch({ type: "death", payload: frame.payload, ts: frame.ts });
break;
case EVENT_TYPES.HELLO:
dispatch({ type: "hello", payload: frame.payload });
break;
case EVENT_TYPES.ASK_PI_CHUNK:
dispatch({ type: "pi-chunk", payload: frame.payload });
break;
case EVENT_TYPES.ASK_PI_DONE:
dispatch({ type: "pi-done", payload: frame.payload });
break;
}
};
(client as any).on("connected", onConnected);
(client as any).on("disconnected", onDisconnected);
(client as any).on("frame", onFrame);
return () => {
(client as any).off("connected", onConnected);
(client as any).off("disconnected", onDisconnected);
(client as any).off("frame", onFrame);
client.close();
};
}, [client]);
useInput((input, key) => {
if (mode !== "idle") return; // text input has its own handling
if (input === "q") {
client.close();
exit();
return;
}
if (input === "p") {
const next = !state.paused;
client.send(next ? COMMAND_TYPES.PAUSE : COMMAND_TYPES.RESUME, {});
dispatch({ type: "set-paused", paused: next });
}
if (input === "s") {
client.send(COMMAND_TYPES.STOP, {});
}
if (input === "r") {
client.send(COMMAND_TYPES.SNAPSHOT, {});
}
if (input === "c") setMode("chat");
if (input === "a") setMode("ask-pi");
});
function submit(value: string) {
const text = value.trim();
setInputValue("");
const m = mode;
setMode("idle");
if (!text) return;
if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text });
else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text });
}
const hotkeyHint =
mode === "idle"
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [q]uit"
: mode === "chat"
? "chat → MC (Enter to send, Esc to cancel)"
: "ask-pi → spawn pi -p (Enter to send)";
return (
<Box flexDirection="column">
<StatusBar snapshot={state.snapshot} paused={state.paused} connectedToBot={state.connectedToBot} />
<Box flexDirection="row">
<EventLog logs={state.logs} />
<ChatPanel chat={state.chat} />
</Box>
<PiPanel piStream={state.piStream} piRunning={state.piRunning} />
<Box borderStyle="single" paddingX={1}>
{mode === "idle" ? (
<Text dimColor>{hotkeyHint}</Text>
) : (
<>
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
{mode === "chat" ? "chat> " : "pi> "}
</Text>
<TextInput
value={inputValue}
onChange={setInputValue}
onSubmit={submit}
/>
</>
)}
</Box>
</Box>
);
}
render(<App />);