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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
├── LICENSE ← MIT
|
||||
├── 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
@@ -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 20–60 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
|
||||
```
|
||||
@@ -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.`);
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+1087
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 />);
|
||||
Reference in New Issue
Block a user