chore: snapshot pre-v0.2.0 WIP (pathfinder/reflex/metrics/skills improvements)

Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this
state. Subsequent commits in this branch layer the knowledge base,
post-mortem coach, and persona narration on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:05:12 +03:00
co-authored by Claude Opus 4.7
parent 3ee2ab886e
commit 86e5294bb8
30 changed files with 1023 additions and 130 deletions
+5 -2
View File
@@ -83,7 +83,7 @@ pi /login # OAuth flow — ChatGPT Pro / Claude Max
# Terminal 1: the daemon (logs in stdout, persists state under state/<host>/) # Terminal 1: the daemon (logs in stdout, persists state under state/<host>/)
npm run bot npm run bot
# Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/q. # Terminal 2: the dashboard (Ink TUI). Hotkeys: p/s/r/c/a/k/v/!/y/q.
npm run tui npm run tui
``` ```
@@ -105,6 +105,9 @@ The TUI auto-reconnects to the bot if you restart it. Press `q` to leave the TUI
| `r` | Force a fresh status snapshot. | | `r` | Force a fresh status snapshot. |
| `c` | Send a chat message into MC. | | `c` | Send a chat message into MC. |
| `a` | Ask Pi (one-shot subprocess). | | `a` | Ask Pi (one-shot subprocess). |
| `k` | Run one registered skill with optional JSON args. |
| `v` | Capture a viewer screenshot for debugging. |
| `!` | Force a critic-backed incident/proposal. |
| `y` | Open the latest pending proposal (badge appears in status bar). In the panel: `y` approve, `n`/Esc close. | | `y` | Open the latest pending proposal (badge appears in status bar). In the panel: `y` approve, `n`/Esc close. |
| `q` | Quit the TUI — bot keeps running. | | `q` | Quit the TUI — bot keeps running. |
@@ -213,7 +216,7 @@ These are mirrored in `AGENTS.md` and re-stated at the top of any system prompt
🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour. 🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour.
🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored). Phase 0 (chat-control cleanup, version auto-detect) is done; Phase 1+ (observability, skill substrate, survival curriculum, base/village loop) is the next focus. 🌿 **Survival-bot pivot (2026-05-25)** — the bot is becoming a self-sufficient survival resident of the configured server. **MC chat is dialog-only**; operator/player chat commands are recorded but not dispatched (TUI is the only local control plane). The hybrid runtime now has enriched perception, priority modes, a skill-driven curriculum, food acquisition, bed/sleep, base/chest/shelter/farm skills, persistent skill metrics, scenario memory, and a scoped auto-patch loop with `npm test` smoke gating. Full plan: `plans/autonomous-survival-bot-prd.md` (local-only, gitignored).
Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Memory layout: [`docs/memory-model.md`](./docs/memory-model.md). Day-to-day judgement: "Operating principles" in [`AGENTS.md`](./AGENTS.md). Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Memory layout: [`docs/memory-model.md`](./docs/memory-model.md). Day-to-day judgement: "Operating principles" in [`AGENTS.md`](./AGENTS.md).
+59 -35
View File
@@ -96,6 +96,9 @@ TUI; the bot is unaffected.
| `r` | Force-broadcast a status snapshot now. | | `r` | Force-broadcast a status snapshot now. |
| `c` | Enter **chat mode** — type a message, Enter sends it into MC chat. | | `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. | | `a` | Enter **ask-Pi mode** — type a prompt, Enter spawns `pi -p` and streams output into the Pi panel. |
| `k` | Enter **run-skill mode** — type `skill.id` plus optional JSON args; dispatches once through `cmd:run-skill`. |
| `v` | Capture a headless viewer screenshot into `state/<host>/screenshots/` when viewer support is available. |
| `!` | Force a stuck incident proposal through the critic/auto-improve path for diagnostics. |
| `y` | Open the latest pending proposal. In the proposal panel: `y` approves, `n`/Esc closes. | | `y` | Open the latest pending proposal. In the proposal panel: `y` approves, `n`/Esc closes. |
| `q` | Quit TUI only. Bot keeps running. | | `q` | Quit TUI only. Bot keeps running. |
@@ -106,19 +109,20 @@ TUI; the bot is unaffected.
The chain (highest priority first), wired and dispatching real The chain (highest priority first), wired and dispatching real
Mineflayer actions: Mineflayer actions:
1. **`defendReflex`** — closest hostile within 4 m → `attackNearest` 1. **Modes**Mindcraft-style interrupts run first:
(equips best melee). Within 12 m + low HP or ≥3 hostiles → `fleeFrom` `self_preservation`, `hunger`, `night_shelter`. They dispatch
along the away-vector. registered skills (`survive.flee`, `survive.eat`, `survive.sleep`)
2. **`eatReflex`** — food < 16 → `eatBestFood` (picks from and therefore feed metrics, scenario memory and current-task state.
FOOD_PRIORITY list, equip + consume). 5 s cooldown. 2. **`defendReflex`** — closest hostile within 4 m → `attackNearest`
3. **`sleepReflex`** — night + no hostile within 8 m → `sleepInBed` (equips best melee). Low HP / close hostile → flee.
(finds nearest placed bed within 16 blocks, paths there, sleeps). 3. **`eatReflex`** — food < 16 and edible item is carried →
5 min cooldown on failures. `eatBestFood`. No-food states fall through to the curriculum instead
4. **`techTreeReflex`** — deterministic crafting progression of eat-spamming.
(planks → sticks → wooden axe → pickaxe → sword) when prerequisites 4. **`sleepReflex`** — night + no hostile within 8 m + bed available →
are in inventory. `sleepInBed`. Impossible states are skipped before dispatch.
5. **`autonomousReflex`** — when nothing reactive fires: chop trees until 5. **`curriculumReflex`** — dispatches `snapshot.curriculum.plan.skillId`
~16 logs, then wander to discover new chunks. through `runSkill`. Inventory pressure can insert
`village.deposit-surplus` before continuing.
6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos). 6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos).
There is **no operator-goal reflex anymore.** MC chat does not create There is **no operator-goal reflex anymore.** MC chat does not create
@@ -199,10 +203,14 @@ from `bot.registry`, so a version-sensitive item that doesn't exist on
the connected server simply doesn't appear in the set and skills the connected server simply doesn't appear in the set and skills
return `code: "unsupported_version"` instead of crashing. return `code: "unsupported_version"` instead of crashing.
Reference skills shipped today: `gather.logs`, `survive.eat`, Reference skills shipped today include `gather.logs`, `gather.stone`,
`explore.wander`. The reflex loop still calls the older `gather.wool`, `survive.eat`, `survive.flee`, `survive.sleep`,
`runtime/actions.js` primitives directly — porting more behaviours to `survive.acquire-food`, `explore.wander`, `explore.far`,
skills lands in later phases. `village.choose-base`, `village.place-chest`,
`village.deposit-surplus`, `village.build-shelter`, `farm.wheat`, and
the `craft.*` progression. Some low-level action primitives still live
in `runtime/actions.js`, but they are increasingly called behind skill
contracts so metrics and self-improvement see them.
Run the contract + groups + curriculum tests: Run the contract + groups + curriculum tests:
@@ -216,7 +224,8 @@ npm test
``` ```
wood.16 → wood.planks-and-sticks → wood.tools → wood.16 → wood.planks-and-sticks → wood.tools →
stone.32 → stone.tools → food.basic → storage.chest → shelter.torch survive.bed → stone.32 → stone.tools → food.basic →
storage.chest → shelter.torch → village.base-site → village.shelter
``` ```
Each milestone has an `isDone(inventory, snapshot)` predicate and a Each milestone has an `isDone(inventory, snapshot)` predicate and a
@@ -229,14 +238,13 @@ planks).
The current curriculum result is on every snapshot as The current curriculum result is on every snapshot as
`snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI `snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI
can show what the bot is working on and which skill should drive it. can show what the bot is working on and which skill should drive it.
Wiring the scheduler to actually call `runSkill(plan.skillId, …)` in The scheduler now calls `runSkill(plan.skillId, …)` directly from
the reflex loop is a Phase 4 task; today the reflex still uses `curriculumReflex`; no LLM is in the hot path.
`actions.js` directly.
Inventory pressure: `isInventoryFull(snapshot)` is exposed on every Inventory pressure: `isInventoryFull(snapshot)` is exposed on every
curriculum result; the TUI surfaces `[inventory full]` next to the curriculum result; the TUI surfaces `[inventory full]`, and when a
milestone label so the operator can see when a deposit step is needed known/nearby chest exists the scheduler tries `village.deposit-surplus`
before progress continues. before the next milestone.
### Optional: prismarine-viewer ### Optional: prismarine-viewer
@@ -259,10 +267,15 @@ Two persistent stores under `state/<host>/`:
Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})` Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})`
returns the cardinal quadrant the bot has explored LEAST — used by returns the cardinal quadrant the bot has explored LEAST — used by
`explore.far` to circle rather than retread the same patch. `explore.far` to circle rather than retread the same patch.
- **`skill-metrics.json`** — persistent per-skill ok/fail counters,
last code and duration. `snapshot.skillMetrics` exposes the loaded
totals so proposals learn from previous runs, not only the current
process lifetime.
- **`scenarios.jsonl`** — sliding window of `(skillId, situationHash, - **`scenarios.jsonl`** — sliding window of `(skillId, situationHash,
code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint
of where + how the bot was (16-cell + 8y bucket, day/night, food of where + how the bot was (16-cell + 8y bucket, day/night, food
bucket, hp bucket, inventory key set, closest hostile name). The bucket, hp bucket, biome, nearby block groups, inventory key set,
closest hostile name). The
curriculum reflex calls `memory.shouldSkip({skillId, situation})` — curriculum reflex calls `memory.shouldSkip({skillId, situation})` —
≥3 failures of the same `(skill, situation)` within 30 min and the ≥3 failures of the same `(skill, situation)` within 30 min and the
reflex auto-converts into a wander hint instead of re-dispatching the reflex auto-converts into a wander hint instead of re-dispatching the
@@ -276,6 +289,7 @@ based on what's actually been tried, not just one snapshot.
### Scheduler driven by the curriculum (2026-05-26) ### Scheduler driven by the curriculum (2026-05-26)
The reflex chain is now: `defend → eat → sleep → curriculum → idle`. The reflex chain is now: `defend → eat → sleep → curriculum → idle`.
Modes run before that chain and dispatch their own skills immediately.
`curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated `curriculumReflex` reads `snapshot.curriculum.plan.skillId` (populated
by `runtime/curriculum.js` each tick) and dispatches it via by `runtime/curriculum.js` each tick) and dispatches it via
@@ -286,7 +300,8 @@ by `runtime/curriculum.js` each tick) and dispatches it via
`gather.logs` returns `code: "no_target"` because there's no tree `gather.logs` returns `code: "no_target"` because there's no tree
within 32m), the curriculum reflex swaps to `wander` for ~60 s. within 32m), the curriculum reflex swaps to `wander` for ~60 s.
- If the result code is `missing_tool` / `missing_material` / - If the result code is `missing_tool` / `missing_material` /
`no_target` / `no_food_source` / `unsupported_version`, that `no_target` / `no_food_source` / `unsupported_version` / storage
blockers, that
specific skill backs off for 60 s instead of retrying every tick. specific skill backs off for 60 s instead of retrying every tick.
- Unknown `skillId` (curriculum suggested something that isn't - Unknown `skillId` (curriculum suggested something that isn't
registered yet) falls through to `wander` — useful while we wire registered yet) falls through to `wander` — useful while we wire
@@ -360,13 +375,16 @@ Two classes of proposals now land in `state/<host>/proposals/`:
Both kinds now persist an **`editScope`** in their frontmatter — an Both kinds now persist an **`editScope`** in their frontmatter — an
array of repo-relative path prefixes the auto-patcher is allowed to array of repo-relative path prefixes the auto-patcher is allowed to
modify. `state-store.readProposalEditScope(filename)` reads it back; modify. `scripts/auto-patch.js` enforces that scope, runs the cheap
hooking `scripts/auto-patch.js` to refuse cherry-picks that touch `scripts/lint-patch.js` gate, then runs `npm test` before cherry-pick.
other areas is the remaining follow-up.
Per-skill metrics live in memory only (best-effort) but are surfaced Learning speed is configurable:
on `snapshot.skillMetrics = { [skillId]: { ok, fail, lastTs } }` so
the TUI can show which skills are reliable and which keep failing. - `PEPA_LEARNING_MODE=fast` or `dev` lowers stuck detection and
auto-improve cooldowns for active training sessions.
- `PEPA_STUCK_THRESHOLD_SECONDS`, `PEPA_STUCK_COOLDOWN_SECONDS`,
`PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS`,
`PEPA_AUTO_IMPROVE_MAX_PER_HOUR` override those defaults directly.
### Social layer (Phase 5) ### Social layer (Phase 5)
@@ -442,6 +460,9 @@ shutdown). Framing: one JSON object per line.
| `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). | | `cmd:chat` | `{ text }` | Sends text into MC chat (rate-limited). |
| `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p "<prompt>"`. | | `cmd:ask-pi` | `{ prompt }` | Spawns `pi -p "<prompt>"`. |
| `cmd:snapshot` | `{}` | Force a `status` event now. | | `cmd:snapshot` | `{}` | Force a `status` event now. |
| `cmd:run-skill` | `{ skillId, args? }` | Pauses the reflex loop long enough to dispatch one registered skill. |
| `cmd:screenshot` | `{ reason?, frames? }` | Captures a headless viewer screenshot for visual debugging. |
| `cmd:force-incident` | `{ kind?, reason? }` | Forces a critic-backed proposal, useful for testing self-improvement. |
The protocol is intentionally tiny — anyone can write a second client The protocol is intentionally tiny — anyone can write a second client
(a Telegram bridge, a web UI, a one-shot CLI) by reading (a Telegram bridge, a web UI, a one-shot CLI) by reading
@@ -470,7 +491,8 @@ if they break things. The flow:
- moves proposal pending → approved/ (audit trail) - moves proposal pending → approved/ (audit trail)
- creates branch auto/<slug> off main - creates branch auto/<slug> off main
- runs `pi -p "<patch prompt>"` with 10-min timeout - runs `pi -p "<patch prompt>"` with 10-min timeout
- if Pi committed AND only touched runtime/ → cherry-pick onto main - if Pi committed AND changed only the proposal editScope
(+ runtime/**/*.test.js) AND lint/npm-test pass → cherry-pick onto main
- else → discard branch, exit non-zero - else → discard branch, exit non-zero
6. supervisor's runtime/*.js watcher fires the moment the cherry-pick 6. supervisor's runtime/*.js watcher fires the moment the cherry-pick
lands → child restarts on the new code lands → child restarts on the new code
@@ -483,8 +505,10 @@ if they break things. The flow:
### Rate limits ### Rate limits
- **Proposal cooldown**: 30 min between proposal files of any kind. - **Proposal cooldown**: 30 min between proposal files of any kind.
- **Auto-improve cooldown**: 15 min between finished `auto-patch.js` runs. - **Auto-improve cooldown**: default 15 min between finished
- **Hourly cap**: max 4 auto-patches per hour, even if cooldown allows. `auto-patch.js` runs; `PEPA_LEARNING_MODE=fast|dev` lowers this to
5 min unless overridden.
- **Hourly cap**: default max 4 auto-patches per hour; fast/dev default is 8.
- **Rollback cap**: 3 rollbacks per supervisor lifetime; after that the - **Rollback cap**: 3 rollbacks per supervisor lifetime; after that the
supervisor exits and waits for human review. supervisor exits and waits for human review.
+5
View File
@@ -67,6 +67,10 @@ function forceStopCollectBlock(bot) {
try { bot.pathfinder?.stop?.(); } catch {} try { bot.pathfinder?.stop?.(); } catch {}
} }
function clearPathfinderGoal(bot) {
try { bot.pathfinder?.setGoal?.(null); } catch {}
}
// Each action that uses pathfinder should set its own Movements profile // Each action that uses pathfinder should set its own Movements profile
// before calling goto — otherwise it inherits whatever the previous caller // before calling goto — otherwise it inherits whatever the previous caller
// left set, which has caused live regressions (e.g. flee setting canDig=false, // left set, which has caused live regressions (e.g. flee setting canDig=false,
@@ -476,6 +480,7 @@ export async function wander(bot, radius = 12) {
return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } }; return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, via: best.name } };
} catch (e) { } catch (e) {
warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`); warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`);
clearPathfinderGoal(bot);
const beforeBlind = clonePos(bot.entity.position); const beforeBlind = clonePos(bot.entity.position);
try { await bot.look(best.yaw, 0, true); } catch {} try { await bot.look(best.yaw, 0, true); } catch {}
bot.setControlState("forward", true); bot.setControlState("forward", true);
+3 -3
View File
@@ -15,7 +15,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { stateDir } from "./config.js"; import { config, stateDir } from "./config.js";
import { info, warn } from "./log.js"; import { info, warn } from "./log.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -25,8 +25,8 @@ const PATCH_SCRIPT = path.join(REPO_ROOT, "scripts", "auto-patch.js");
const PROPOSALS_DIR = path.join(stateDir, "proposals"); const PROPOSALS_DIR = path.join(stateDir, "proposals");
const DEBOUNCE_MS = 10_000; const DEBOUNCE_MS = 10_000;
const COOLDOWN_MS = 15 * 60 * 1000; const COOLDOWN_MS = config.autoImproveCooldownMs;
const MAX_TOTAL_PER_HOUR = 4; const MAX_TOTAL_PER_HOUR = config.autoImproveMaxPerHour;
let inFlight = false; let inFlight = false;
let lastFinishedAt = 0; let lastFinishedAt = 0;
+14 -3
View File
@@ -56,6 +56,7 @@ import { requestCritique } from "./critic.js";
import { createSkillMetrics } from "./skill-metrics.js"; import { createSkillMetrics } from "./skill-metrics.js";
import { createWorldJournal } from "./world-journal.js"; import { createWorldJournal } from "./world-journal.js";
import { createScenarioMemory, situationHash } from "./scenario-memory.js"; import { createScenarioMemory, situationHash } from "./scenario-memory.js";
import { createOwnedBlocksLedger } from "./owned-blocks.js";
fs.mkdirSync(stateDir, { recursive: true }); fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -81,10 +82,14 @@ let lastEscalationAt = 0;
// future Telegram/diary surfaces) can answer "what is the bot doing and why // future Telegram/diary surfaces) can answer "what is the bot doing and why
// isn't it doing more?" without parsing the log stream. // isn't it doing more?" without parsing the log stream.
const noProgress = createNoProgressDetector(); const noProgress = createNoProgressDetector();
const stuckIncident = createStuckIncidentDetector(); const stuckIncident = createStuckIncidentDetector({
thresholdMs: config.stuckThresholdMs,
cooldownMs: config.stuckCooldownMs,
});
const skillMetrics = createSkillMetrics(); const skillMetrics = createSkillMetrics();
const worldJournal = createWorldJournal(); const worldJournal = createWorldJournal();
const scenarioMemory = createScenarioMemory(); const scenarioMemory = createScenarioMemory();
const ownedBlocks = createOwnedBlocksLedger();
let lastResult = null; // { label, ok, code, detail, ts } let lastResult = null; // { label, ok, code, detail, ts }
let lastFailureAt = 0; let lastFailureAt = 0;
let lastPlanReadAt = 0; let lastPlanReadAt = 0;
@@ -109,6 +114,8 @@ const reflexCtx = {
dispatch: dispatchAction, dispatch: dispatchAction,
journal: worldJournal, journal: worldJournal,
memory: scenarioMemory, memory: scenarioMemory,
metrics: skillMetrics,
owned: ownedBlocks,
}; };
let chatTimestamps = []; let chatTimestamps = [];
@@ -186,6 +193,9 @@ function recordWorldDeltaToJournal(label, res, snapshot) {
if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt }); if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt });
if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt }); if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt });
if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt }); if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt });
if (wd.chestAt) worldJournal.append({ kind: "chest", name: "storage", at: wd.chestAt });
if (wd.fledTo) worldJournal.append({ kind: "retreat", name: label, at: wd.fledTo });
if (wd.acquiredFood && snapshot?.position) worldJournal.append({ kind: "food", name: wd.source ?? "food", at: snapshot.position });
if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt }); if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt });
if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt }); if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt });
if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt }); if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt });
@@ -218,6 +228,7 @@ function dispatchAction(fn, label, opts = {}) {
} }
reflexCtx.busy = true; reflexCtx.busy = true;
reflexCtx.currentActionLabel = label; reflexCtx.currentActionLabel = label;
const startedAt = Date.now();
// Capture the situation hash BEFORE the action runs so a failure is // Capture the situation hash BEFORE the action runs so a failure is
// attributable to the state at dispatch time, not the state after the // attributable to the state at dispatch time, not the state after the
// (partial) effect. // (partial) effect.
@@ -243,7 +254,7 @@ function dispatchAction(fn, label, opts = {}) {
detail: res?.detail, detail: res?.detail,
ts: Date.now(), ts: Date.now(),
}; };
skillMetrics.record(label, ok); skillMetrics.record(label, ok, { code: lastResult.code, durationMs: Date.now() - startedAt });
scenarioMemory.record({ scenarioMemory.record({
skillId: label, skillId: label,
situation: startSituation, situation: startSituation,
@@ -278,7 +289,7 @@ function dispatchAction(fn, label, opts = {}) {
detail: String(e?.message ?? e), detail: String(e?.message ?? e),
ts: Date.now(), ts: Date.now(),
}; };
skillMetrics.record(label, false); skillMetrics.record(label, false, { code: "threw", durationMs: Date.now() - startedAt });
scenarioMemory.record({ scenarioMemory.record({
skillId: label, skillId: label,
situation: startSituation, situation: startSituation,
+12
View File
@@ -18,9 +18,16 @@ function opt(name, fallback = "") {
return process.env[name]?.trim() || fallback; return process.env[name]?.trim() || fallback;
} }
function optInt(name, fallback) {
const raw = Number.parseInt(opt(name, String(fallback)), 10);
return Number.isFinite(raw) ? raw : fallback;
}
const host = req("MC_HOST"); const host = req("MC_HOST");
const port = Number.parseInt(opt("MC_PORT", "25565"), 10); const port = Number.parseInt(opt("MC_PORT", "25565"), 10);
const username = req("MC_USERNAME"); const username = req("MC_USERNAME");
const learningMode = opt("PEPA_LEARNING_MODE", "normal").toLowerCase();
const fastLearning = learningMode === "fast" || learningMode === "dev";
// MC_VERSION: "auto" (or empty) lets mineflayer auto-detect from the server // MC_VERSION: "auto" (or empty) lets mineflayer auto-detect from the server
// handshake — the right default per the survival-bot PRD (no hard-coded modern // handshake — the right default per the survival-bot PRD (no hard-coded modern
@@ -44,6 +51,11 @@ export const config = Object.freeze({
.filter(Boolean), .filter(Boolean),
tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000, tickIntervalMs: Math.max(1, Number.parseInt(opt("TICK_INTERVAL_SECONDS", "3"), 10)) * 1000,
chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10), chatRateLimitPerMin: Number.parseInt(opt("CHAT_RATE_LIMIT_PER_MIN", "15"), 10),
learningMode,
stuckThresholdMs: Math.max(15, optInt("PEPA_STUCK_THRESHOLD_SECONDS", fastLearning ? 60 : 300)) * 1000,
stuckCooldownMs: Math.max(60, optInt("PEPA_STUCK_COOLDOWN_SECONDS", fastLearning ? 600 : 1800)) * 1000,
autoImproveCooldownMs: Math.max(60, optInt("PEPA_AUTO_IMPROVE_COOLDOWN_SECONDS", fastLearning ? 300 : 900)) * 1000,
autoImproveMaxPerHour: Math.max(1, optInt("PEPA_AUTO_IMPROVE_MAX_PER_HOUR", fastLearning ? 8 : 4)),
// Optional prismarine-viewer port for local visual debugging. 0/empty = off. // Optional prismarine-viewer port for local visual debugging. 0/empty = off.
viewerPort: (() => { viewerPort: (() => {
const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10); const v = Number.parseInt(opt("VIEWER_PORT", "0"), 10);
+6 -3
View File
@@ -157,13 +157,16 @@ const MILESTONES = [
); );
return carrying || (snap?.food ?? 20) >= 18; return carrying || (snap?.food ?? 20) >= 18;
}, },
suggest: () => ({ skillId: "survive.eat" }), // best-effort; richer "find food" skill lands later suggest: () => ({ skillId: "survive.acquire-food" }),
}, },
{ {
id: "storage.chest", id: "storage.chest",
title: "Place a personal chest", title: "Place a personal chest",
isDone: (inv) => has(inv, "chest"), isDone: (_inv, snap) => !!snap?.locations?.chest,
suggest: () => ({ skillId: "craft.chest" }), suggest: (inv) => {
if (!has(inv, "chest")) return { skillId: "craft.chest" };
return { skillId: "village.place-chest" };
},
}, },
{ {
id: "shelter.torch", id: "shelter.torch",
+17
View File
@@ -154,6 +154,7 @@ test("all done → null", () => {
nextMilestone(snap(inv, { nextMilestone(snap(inv, {
food: 20, food: 20,
locations: { locations: {
chest: { x: 1, y: 64, z: 0 },
base: { x: 0, y: 64, z: 0 }, base: { x: 0, y: 64, z: 0 },
shelter: { x: 0, y: 64, z: 0 }, shelter: { x: 0, y: 64, z: 0 },
}, },
@@ -189,3 +190,19 @@ test("listMilestones exposes ordered ids for diary/TUI", () => {
assert.equal(typeof m.title, "string"); assert.equal(typeof m.title, "string");
} }
}); });
test("food.basic with no carried food suggests acquire-food", () => {
const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 }));
assert.equal(got.milestone.id, "food.basic");
assert.equal(got.plan.skillId, "survive.acquire-food");
});
test("storage.chest crafts first, then places carried chest", () => {
const needCraft = nextMilestone(snapAfter("food.basic", { chest: 0 }));
assert.equal(needCraft.milestone.id, "storage.chest");
assert.equal(needCraft.plan.skillId, "craft.chest");
const needPlace = nextMilestone(snapAfter("food.basic", { chest: 1 }));
assert.equal(needPlace.milestone.id, "storage.chest");
assert.equal(needPlace.plan.skillId, "village.place-chest");
});
+20 -6
View File
@@ -81,15 +81,23 @@ registerMode({
const snap = ctx?.snapshot; const snap = ctx?.snapshot;
if (!snap) return null; if (!snap) return null;
const hp = snap.health ?? 20; const hp = snap.health ?? 20;
const food = snap.food ?? 20; const hasFood = !!snap.hasFood;
if (ctx.modeCooldown?.self_preservation && Date.now() < ctx.modeCooldown.self_preservation) return null;
// HP critically low + we have food → eat NOW // HP critically low + we have food → eat NOW
if (hp < 6 && food > 0 && snap.hasFood) { if (hp < 6 && hasFood) {
return { action: { skillId: "eat" }, detail: { reason: "hp<6", hp } }; ctx.modeCooldown = ctx.modeCooldown ?? {};
ctx.modeCooldown.self_preservation = Date.now() + 5_000;
return { action: { skillId: "survive.eat" }, detail: { reason: "hp<6", hp } };
} }
// Hostile within reach and HP low → flee // Hostile within reach and HP low → flee
const ch = snap.closestHostile; const ch = snap.closestHostile;
if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) { if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) {
return { action: { skillId: "explore.far" }, detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance } }; ctx.modeCooldown = ctx.modeCooldown ?? {};
ctx.modeCooldown.self_preservation = Date.now() + 8_000;
return {
action: { skillId: "survive.flee", args: { hostileName: ch.name } },
detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance },
};
} }
return null; return null;
}, },
@@ -102,8 +110,11 @@ registerMode({
update(ctx) { update(ctx) {
const snap = ctx?.snapshot; const snap = ctx?.snapshot;
if (!snap) return null; if (!snap) return null;
if (ctx.modeCooldown?.hunger && Date.now() < ctx.modeCooldown.hunger) return null;
if ((snap.food ?? 20) < 14 && snap.hasFood) { if ((snap.food ?? 20) < 14 && snap.hasFood) {
return { action: { skillId: "eat" }, detail: { reason: "food<14", food: snap.food } }; ctx.modeCooldown = ctx.modeCooldown ?? {};
ctx.modeCooldown.hunger = Date.now() + 5_000;
return { action: { skillId: "survive.eat" }, detail: { reason: "food<14", food: snap.food } };
} }
return null; return null;
}, },
@@ -116,11 +127,14 @@ registerMode({
update(ctx) { update(ctx) {
const snap = ctx?.snapshot; const snap = ctx?.snapshot;
if (!snap) return null; if (!snap) return null;
if (ctx.modeCooldown?.night_shelter && Date.now() < ctx.modeCooldown.night_shelter) return null;
// Only at night and only if we actually carry / can place a bed // Only at night and only if we actually carry / can place a bed
if (snap.isDay) return null; if (snap.isDay) return null;
const inv = snap.inventory || {}; const inv = snap.inventory || {};
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n)); const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
if (!hasBed) return null; if (!hasBed) return null;
return { action: { skillId: "sleep" }, detail: { reason: "night with bed in hand" } }; ctx.modeCooldown = ctx.modeCooldown ?? {};
ctx.modeCooldown.night_shelter = Date.now() + 30_000;
return { action: { skillId: "survive.sleep" }, detail: { reason: "night with bed in hand" } };
}, },
}); });
+3 -3
View File
@@ -70,14 +70,14 @@ test("self_preservation: low-HP + food + hasFood → eat", async () => {
const mod = await import(`./modes.js?cb=${Date.now() + 1}`); const mod = await import(`./modes.js?cb=${Date.now() + 1}`);
const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } }); const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } });
assert.equal(out.mode, "self_preservation"); assert.equal(out.mode, "self_preservation");
assert.equal(out.action.skillId, "eat"); assert.equal(out.action.skillId, "survive.eat");
}); });
test("hunger: food below 14 with food → eat", async () => { test("hunger: food below 14 with food → eat", async () => {
_resetModes(); _resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 2}`); const mod = await import(`./modes.js?cb=${Date.now() + 2}`);
const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } }); const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } });
assert.equal(out.action.skillId, "eat"); assert.equal(out.action.skillId, "survive.eat");
}); });
test("night_shelter: day → null (skip)", async () => { test("night_shelter: day → null (skip)", async () => {
@@ -91,5 +91,5 @@ test("night_shelter: night + bed in hand → sleep", async () => {
_resetModes(); _resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 4}`); const mod = await import(`./modes.js?cb=${Date.now() + 4}`);
const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } }); const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } });
assert.equal(out.action.skillId, "sleep"); assert.equal(out.action.skillId, "survive.sleep");
}); });
+29 -12
View File
@@ -35,6 +35,19 @@ function hdist(a, b) {
return Math.hypot(a.x - b.x, a.z - b.z); return Math.hypot(a.x - b.x, a.z - b.z);
} }
function collectBlockOwnsPathfinder(bot) {
try {
const targets = bot.collectBlock?.targets;
if (!targets) return false;
if (typeof targets.empty === "boolean") return targets.empty === false;
if (Array.isArray(targets.targets)) return targets.targets.length > 0;
if (typeof targets.size === "number") return targets.size > 0;
return false;
} catch {
return false;
}
}
export function createPathfinderWatchdog(bot, { export function createPathfinderWatchdog(bot, {
intervalMs = WATCH_INTERVAL_MS, intervalMs = WATCH_INTERVAL_MS,
windowMs = STUCK_WINDOW_MS, windowMs = STUCK_WINDOW_MS,
@@ -71,6 +84,15 @@ export function createPathfinderWatchdog(bot, {
return; return;
} }
if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return; if (Date.now() - goalStartedAt < MIN_TRAVEL_TIME_MS) return;
if (collectBlockOwnsPathfinder(bot)) {
// mineflayer-collectblock treats pathfinder goal changes as a
// hard cancellation ("The goal was changed before it could be
// completed"). Let the collect skill's own timeout/blacklist
// handle these paths instead of invalidating the current dig.
lastSeenPos = hpos(bot.entity?.position);
lastSeenAt = Date.now();
return;
}
const now = Date.now(); const now = Date.now();
const here = hpos(bot.entity?.position); const here = hpos(bot.entity?.position);
@@ -81,8 +103,11 @@ export function createPathfinderWatchdog(bot, {
} }
if (now - lastSeenAt < windowMs) return; if (now - lastSeenAt < windowMs) return;
// Stuck. Force a replan — clear the goal, then re-set the same // Stuck. Force a replan by setting the exact same goal object again.
// goal so pathfinder rebuilds the graph against the current world. // mineflayer-pathfinder emits goal_updated on every setGoal() call
// and rebuilds the graph, but goto() only rejects as GoalChanged when
// the new goal object is different. Clearing to null first breaks the
// caller, as observed live with gather.logs/explore.far.
if (replansThisGoal >= maxReplans) { if (replansThisGoal >= maxReplans) {
warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`); warn("pathfinder", `stuck > ${windowMs / 1000}s and hit ${maxReplans} replans; giving up — caller's timeout will fire`);
lastSeenAt = now; // throttle further warnings within this window lastSeenAt = now; // throttle further warnings within this window
@@ -92,15 +117,7 @@ export function createPathfinderWatchdog(bot, {
info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`); info("pathfinder", `stuck for ${Math.round((now - lastSeenAt) / 1000)}s at (${Math.round(here?.x ?? 0)},${Math.round(here?.z ?? 0)}) — forcing replan #${replansThisGoal}`);
try { try {
const goalCopy = goal; const goalCopy = goal;
// setGoal(null) cancels the current pathing without bubbling pf.setGoal(goalCopy);
// an error to the awaiting goto() promise.
pf.setGoal(null);
// Re-set immediately. mineflayer-pathfinder will compute a
// fresh path off the latest world snapshot.
setTimeout(() => {
if (stopped) return;
try { pf.setGoal(goalCopy); } catch (e) { warn("pathfinder", `replan setGoal failed: ${e.message}`); }
}, 250);
lastSeenAt = now; // reset window lastSeenAt = now; // reset window
} catch (e) { } catch (e) {
warn("pathfinder", `replan failed: ${e.message}`); warn("pathfinder", `replan failed: ${e.message}`);
@@ -118,4 +135,4 @@ export function createPathfinderWatchdog(bot, {
} }
// Pure helpers for tests. // Pure helpers for tests.
export const _internal = { hpos, hdist }; export const _internal = { hpos, hdist, collectBlockOwnsPathfinder };
+15 -5
View File
@@ -55,10 +55,21 @@ test("replan fires after stuck window elapses", async () => {
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 }); const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 });
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
wd.stop(); wd.stop();
// At least one setGoal(null) call. // At least one same-object setGoal(goal) call.
assert.ok(bot.setGoalCalls.length >= 1, `expected ≥1 setGoal call, got ${bot.setGoalCalls.length}`); assert.ok(bot.setGoalCalls.length >= 1, `expected ≥1 setGoal call, got ${bot.setGoalCalls.length}`);
// First call is setGoal(null). assert.equal(bot.setGoalCalls[0], goal);
assert.equal(bot.setGoalCalls[0], null); });
test("does not replan while collectBlock owns pathfinder", async () => {
const goal = { id: "g1" };
const bot = makeBot({ goalRef: goal, pos: { x: 0, y: 64, z: 0 } });
bot.pathfinder._owner = bot;
bot.collectBlock = { targets: { empty: false, targets: [{}] } };
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 100, delta: 0.5, maxReplans: 5 });
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
wd.stop();
assert.equal(_internal.collectBlockOwnsPathfinder(bot), true);
assert.equal(bot.setGoalCalls.length, 0);
}); });
test("respects maxReplans cap", async () => { test("respects maxReplans cap", async () => {
@@ -68,8 +79,7 @@ test("respects maxReplans cap", async () => {
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 }); const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 });
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
wd.stop(); wd.stop();
// Each replan = setGoal(null) + delayed setGoal(goal). So 2 replans ≤ 4 calls. assert.ok(bot.setGoalCalls.length <= 2, `expected ≤2 setGoal calls, got ${bot.setGoalCalls.length}`);
assert.ok(bot.setGoalCalls.length <= 4, `expected ≤4 setGoal calls, got ${bot.setGoalCalls.length}`);
}); });
test("resets counters when goal changes", async () => { test("resets counters when goal changes", async () => {
+124 -4
View File
@@ -1,6 +1,9 @@
// Build a compact, JSON-safe snapshot of the world around the bot. Used both // Build a compact, JSON-safe snapshot of the world around the bot. Used both
// for reflex decisions and for periodic IPC STATUS events. // for reflex decisions and for periodic IPC STATUS events.
import { foods } from "./skills/groups.js";
import { findBlocksByName } from "./perception.js";
function vec3ToObj(v) { function vec3ToObj(v) {
if (!v) return null; 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 }; return { x: Math.round(v.x * 100) / 100, y: Math.round(v.y * 100) / 100, z: Math.round(v.z * 100) / 100 };
@@ -29,6 +32,95 @@ const HOSTILE = new Set([
"bogged", "bogged",
]); ]);
const PASSIVE = new Set([
"cow",
"pig",
"chicken",
"sheep",
"rabbit",
"mooshroom",
"cod",
"salmon",
]);
const INTERESTING_BLOCK_GROUPS = Object.freeze({
logs: ["oak_log", "dark_oak_log", "spruce_log", "birch_log", "jungle_log", "acacia_log", "mangrove_log", "cherry_log", "pale_oak_log"],
stone: ["stone", "cobblestone", "deepslate", "cobbled_deepslate", "andesite", "diorite", "granite"],
water: ["water"],
lava: ["lava", "fire", "soul_fire"],
beds: ["white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed"],
storage: ["chest", "trapped_chest", "barrel"],
wool: ["white_wool", "orange_wool", "magenta_wool", "light_blue_wool", "yellow_wool", "lime_wool", "pink_wool", "gray_wool", "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", "brown_wool", "green_wool", "red_wool", "black_wool"],
crops: ["wheat", "carrots", "potatoes", "beetroots", "sweet_berry_bush"],
coal: ["coal_ore", "deepslate_coal_ore"],
});
function inventoryCounts(bot) {
return (bot.inventory?.items?.() ?? []).reduce((acc, item) => {
acc[item.name] = (acc[item.name] ?? 0) + item.count;
return acc;
}, {});
}
function countInterestingBlocks(bot, pos, radius = 16) {
const out = {};
for (const [kind, names] of Object.entries(INTERESTING_BLOCK_GROUPS)) {
let positions = [];
try {
positions = findBlocksByName(bot, names, { maxDistance: radius, count: 16 });
} catch {
positions = [];
}
if (positions.length === 0) continue;
const nearest = positions
.map((p) => ({ position: vec3ToObj(p), distance: Math.round(Math.hypot(p.x - pos.x, p.z - pos.z) * 10) / 10 }))
.sort((a, b) => a.distance - b.distance)[0];
out[kind] = { count: positions.length, nearest };
}
return out;
}
function carriedEquipment(bot) {
const slots = bot.inventory?.slots ?? [];
return {
hand: bot.heldItem?.name ?? null,
head: slots[5]?.name ?? null,
torso: slots[6]?.name ?? null,
legs: slots[7]?.name ?? null,
feet: slots[8]?.name ?? null,
};
}
function hasEdibleFood(bot, inventory) {
const allowed = foods(bot);
return Object.keys(inventory ?? {}).some((name) => allowed.has(name));
}
function entityDistance(e, pos) {
try {
return Math.round(e.position.distanceTo(pos) * 10) / 10;
} catch {
return null;
}
}
function entitySnapshot(e, pos) {
return {
name: e.username ?? e.name ?? e.displayName ?? "?",
type: e.type ?? null,
distance: entityDistance(e, pos),
position: vec3ToObj(e.position),
};
}
function blockNameAt(bot, pos) {
try {
return bot.blockAt(pos)?.name ?? null;
} catch {
return null;
}
}
export function snapshot(bot) { export function snapshot(bot) {
if (!bot || !bot.entity) { if (!bot || !bot.entity) {
return { connected: false }; return { connected: false };
@@ -42,10 +134,29 @@ export function snapshot(bot) {
return !best || d < best.d ? { d, e } : best; return !best || d < best.d ? { d, e } : best;
}, null); }, null);
const inventory = (bot.inventory?.items?.() ?? []).reduce((acc, item) => { const inventory = inventoryCounts(bot);
acc[item.name] = (acc[item.name] ?? 0) + item.count; const nearbyBlocks = countInterestingBlocks(bot, pos);
return acc; const droppedItems = entities
}, {}); .filter((e) => e.type === "object" || e.name === "item")
.filter((e) => e.position && e.position.distanceTo(pos) <= 24)
.map((e) => entitySnapshot(e, pos))
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity))
.slice(0, 12);
const passives = entities
.filter((e) => PASSIVE.has((e.name || "").toLowerCase()) && e.position)
.map((e) => entitySnapshot(e, pos))
.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity))
.slice(0, 12);
const footBlock = blockNameAt(bot, pos);
const belowBlock = blockNameAt(bot, pos.offset(0, -1, 0));
const headBlock = blockNameAt(bot, pos.offset(0, 1, 0));
const hazards = {
lavaNearby: !!nearbyBlocks.lava,
inFluid: footBlock === "water" || footBlock === "lava",
footBlock,
belowBlock,
headBlock,
};
return { return {
connected: true, connected: true,
@@ -60,6 +171,15 @@ export function snapshot(bot) {
weather: { rain: bot.isRaining, thunder: bot.thundering }, weather: { rain: bot.isRaining, thunder: bot.thundering },
dimension: bot.game?.dimension, dimension: bot.game?.dimension,
inventory, inventory,
hasFood: hasEdibleFood(bot, inventory),
equipment: carriedEquipment(bot),
nearbyBlocks,
nearbyEntities: {
passives,
droppedItems,
},
hazards,
biome: bot.blockAt?.(pos)?.biome?.name ?? bot.blockAt?.(pos)?.biome ?? null,
players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })), players: players.map((p) => ({ name: p.username, distance: Math.round(p.position.distanceTo(pos)) })),
hostileCount: hostiles.length, hostileCount: hostiles.length,
closestHostile: closestHostile closestHostile: closestHostile
+76 -3
View File
@@ -311,6 +311,45 @@ function sleepReflex(ctx) {
const CURRICULUM_COOLDOWN_MS = 4_000; const CURRICULUM_COOLDOWN_MS = 4_000;
const SKILL_BACKOFF_MS = 60_000; const SKILL_BACKOFF_MS = 60_000;
const METRIC_BACKOFF_MS = 10 * 60_000;
const METRIC_BAD_CODES = new Set(["timeout", "failed", "wedged", "silent_dig_failure", "validation_failed"]);
function isRecentBadMetric(metric, { minFails = 2, maxAgeMs = METRIC_BACKOFF_MS } = {}) {
if (!metric) return false;
if ((metric.fail ?? 0) < minFails) return false;
if (!METRIC_BAD_CODES.has(metric.lastCode)) return false;
if ((metric.ok ?? 0) > 0 && metric.lastCode !== "timeout") return false;
return Date.now() - (metric.lastTs ?? 0) < maxAgeMs;
}
function recentSuccessAfter(metric, ts) {
if (!metric || !ts) return false;
if ((metric.ok ?? 0) <= 0) return false;
return (metric.lastTs ?? 0) > ts && metric.lastCode === "done";
}
function metricRecoverySkill(ctx, plannedSkillId) {
let metrics = null;
try {
metrics = ctx.metrics?.snapshot?.() ?? null;
} catch {
return null;
}
if (!metrics) return null;
const badExplore = isRecentBadMetric(metrics["explore.far"], { minFails: 1 });
const badWander = isRecentBadMetric(metrics.wander, { minFails: 2 });
const lastMovementBadTs = Math.max(
badExplore ? (metrics["explore.far"]?.lastTs ?? 0) : 0,
badWander ? (metrics.wander?.lastTs ?? 0) : 0,
);
if ((badExplore || badWander) && !recentSuccessAfter(metrics["recovery.tunnel-out"], lastMovementBadTs)) {
return { skillId: "recovery.tunnel-out", reason: "recent movement recovery failures" };
}
if (plannedSkillId && isRecentBadMetric(metrics[plannedSkillId], { minFails: 2 })) {
return { skillId: "explore.far", reason: `${plannedSkillId} recently failed repeatedly` };
}
return null;
}
function curriculumReflex(ctx) { function curriculumReflex(ctx) {
const s = ctx.snapshot; const s = ctx.snapshot;
@@ -323,6 +362,39 @@ function curriculumReflex(ctx) {
const plan = s.curriculum?.plan; const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0; const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil; const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
if (metricRecovery) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
if (plan?.skillId) ctx.skillBackoff[plan.skillId] = Date.now() + SKILL_BACKOFF_MS;
const args = metricRecovery.skillId === "recovery.tunnel-out"
? { reason: metricRecovery.reason, maxSteps: 3 }
: {};
ctx.dispatch(() => runSkill(metricRecovery.skillId, ctx, args), metricRecovery.skillId, {});
return {
action: "dispatched",
kind: "curriculum-metric-recovery",
label: metricRecovery.skillId,
};
}
if (s.curriculum?.inventoryFull) {
const depositId = s.locations?.chest || s.nearbyBlocks?.storage ? "village.deposit-surplus" : null;
if (depositId) {
const depositBackoff = ctx.skillBackoff?.[depositId] ?? 0;
if (Date.now() >= depositBackoff) {
ctx.lastCurriculumAt = Date.now();
ctx.dispatch(() => runSkill(depositId, ctx), depositId, {
onComplete: (res) => {
if (!res?.ok) {
ctx.skillBackoff = ctx.skillBackoff ?? {};
ctx.skillBackoff[depositId] = Date.now() + SKILL_BACKOFF_MS;
}
},
});
return { action: "dispatched", kind: "curriculum-deposit", label: depositId };
}
}
}
// No skill plan from curriculum OR a recent skill asked us to wander. // No skill plan from curriculum OR a recent skill asked us to wander.
// First hint → small wander (might just be 32-block reach issue). // First hint → small wander (might just be 32-block reach issue).
@@ -382,7 +454,7 @@ function curriculumReflex(ctx) {
if (!res?.ok) { if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be // missing_tool / missing_material / no_target shouldn't be
// retried on the very next tick. Hold for SKILL_BACKOFF_MS. // retried on the very next tick. Hold for SKILL_BACKOFF_MS.
const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version"]); const cooldownCodes = new Set(["missing_tool", "missing_material", "no_target", "no_food_source", "unsupported_version", "no_chest", "no_space", "nothing_to_deposit"]);
if (cooldownCodes.has(res?.code)) { if (cooldownCodes.has(res?.code)) {
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS; ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
} }
@@ -432,11 +504,12 @@ export function runTick(ctx) {
if (modeHit?.action?.skillId) { if (modeHit?.action?.skillId) {
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {}); const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() }; ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
ctx.dispatch(fn, modeHit.action.skillId, {});
return { return {
reflex: `mode:${modeHit.mode}`, reflex: `mode:${modeHit.mode}`,
action: "dispatch", action: "dispatched",
kind: `mode:${modeHit.mode}`,
label: modeHit.action.skillId, label: modeHit.action.skillId,
fn,
detail: modeHit.detail, detail: modeHit.detail,
}; };
} }
+87
View File
@@ -45,6 +45,7 @@ function makeCtx({
lastEatAt = 0, lastEatAt = 0,
lastSleepAttemptAt = 0, lastSleepAttemptAt = 0,
lastCurriculumAt = 0, lastCurriculumAt = 0,
metrics,
} = {}) { } = {}) {
const dispatches = []; const dispatches = [];
const ctx = { const ctx = {
@@ -56,6 +57,7 @@ function makeCtx({
lastSleepAttemptAt, lastSleepAttemptAt,
lastCurriculumAt, lastCurriculumAt,
skillBackoff, skillBackoff,
metrics,
dispatch(fn, label, opts = {}) { dispatch(fn, label, opts = {}) {
dispatches.push({ fn, label, opts }); dispatches.push({ fn, label, opts });
}, },
@@ -82,6 +84,23 @@ test("disconnected snapshot → no dispatch", () => {
assert.equal(dispatches.length, 0); assert.equal(dispatches.length, 0);
}); });
test("mode hit dispatches the returned skill immediately", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 4,
food: 10,
hasFood: true,
inventory: { bread: 1 },
curriculum: { plan: { skillId: "gather.logs" } },
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "mode:self_preservation");
assert.equal(out.action, "dispatched");
assert.equal(dispatches[0].label, "survive.eat");
});
test("defend wins over curriculum when hostile in melee", () => { test("defend wins over curriculum when hostile in melee", () => {
const { ctx, dispatches } = makeCtx({ const { ctx, dispatches } = makeCtx({
snapshot: { snapshot: {
@@ -279,6 +298,74 @@ test("wander-hint backoff swaps skill for wander on the next tick", () => {
assert.equal(dispatches[0].label, "wander"); assert.equal(dispatches[0].label, "wander");
}); });
test("recent repeated skill timeouts trigger metric recovery", () => {
const now = Date.now();
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
curriculum: { plan: { skillId: "gather.logs" } },
},
metrics: {
snapshot: () => ({
"gather.logs": { ok: 0, fail: 3, lastTs: now, lastCode: "timeout" },
}),
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(out.kind, "curriculum-metric-recovery");
assert.equal(dispatches[0].label, "explore.far");
assert.ok((ctx.skillBackoff?.["gather.logs"] ?? 0) > Date.now());
});
test("recent movement timeouts trigger tunnel recovery", () => {
const now = Date.now();
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
curriculum: { plan: { skillId: "gather.logs" } },
},
metrics: {
snapshot: () => ({
"explore.far": { ok: 0, fail: 1, lastTs: now, lastCode: "timeout" },
}),
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(out.kind, "curriculum-metric-recovery");
assert.equal(dispatches[0].label, "recovery.tunnel-out");
});
test("successful tunnel recovery clears movement-timeout trigger", () => {
const now = Date.now();
const { ctx, dispatches } = makeCtx({
snapshot: {
connected: true,
health: 20,
food: 20,
isDay: true,
curriculum: { plan: { skillId: "gather.logs" } },
},
metrics: {
snapshot: () => ({
"explore.far": { ok: 0, fail: 1, lastTs: now - 5_000, lastCode: "timeout" },
"recovery.tunnel-out": { ok: 1, fail: 0, lastTs: now, lastCode: "done" },
}),
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(out.kind, "curriculum-skill");
assert.equal(dispatches[0].label, "gather.logs");
});
test("onComplete sets wander hint when skill recovery says so", () => { test("onComplete sets wander hint when skill recovery says so", () => {
const { ctx, dispatches } = makeCtx({ const { ctx, dispatches } = makeCtx({
snapshot: { snapshot: {
+6 -1
View File
@@ -45,13 +45,18 @@ export function situationHash(snapshot) {
const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24 const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24
? snapshot.closestHostile.name ? snapshot.closestHostile.name
: "-"; : "-";
const biome = snapshot.biome ?? "-";
const blocks = Object.keys(snapshot.nearbyBlocks ?? {})
.sort()
.slice(0, 8)
.join(",") || "-";
// Inventory keys, sorted — we lose counts but keep "what kind of stuff do // Inventory keys, sorted — we lose counts but keep "what kind of stuff do
// I have". Limited to first 10 names for hash stability. // I have". Limited to first 10 names for hash stability.
const invKeys = Object.keys(snapshot.inventory ?? {}) const invKeys = Object.keys(snapshot.inventory ?? {})
.sort() .sort()
.slice(0, 10) .slice(0, 10)
.join(",") || "-"; .join(",") || "-";
return `${cx},${cy},${cz}|${day}|${food}|${hp}|host:${hostile}|inv:${invKeys}`; return `${cx},${cy},${cz}|${day}|${food}|${hp}|bio:${biome}|blocks:${blocks}|host:${hostile}|inv:${invKeys}`;
} }
function loadScenarios() { function loadScenarios() {
+4 -1
View File
@@ -42,11 +42,14 @@ function extractHeaderComment(src) {
function skillFilePath(id) { function skillFilePath(id) {
const slug = id.replace(/\./g, "-"); const slug = id.replace(/\./g, "-");
const tail = id.split(".").slice(1).join("-");
const candidates = [ const candidates = [
path.join(__dirname, "skills", `${slug}.js`), path.join(__dirname, "skills", `${slug}.js`),
path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`), path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`),
tail ? path.join(__dirname, "skills", `${tail}.js`) : null,
tail ? path.join(__dirname, "skills", `${tail.replace(/-/g, "_")}.js`) : null,
]; ];
for (const p of candidates) if (fs.existsSync(p)) return p; for (const p of candidates) if (p && fs.existsSync(p)) return p;
return null; return null;
} }
+57 -9
View File
@@ -1,27 +1,75 @@
// Per-skill ok/fail counters. Aggregated for the lifetime of the bot // Per-skill ok/fail counters. Persisted under state/<host>/ so the bot's
// process (best-effort persistence is left for a future iteration — // next run and auto-improvement prompts can learn from prior attempts,
// today's counters reset on restart, which keeps the data store // not only the current process lifetime.
// simple while still being useful for incident bodies and the TUI).
export function createSkillMetrics() { import fs from "node:fs";
const counts = new Map(); // id → { ok, fail, lastTs } import path from "node:path";
import { stateDir } from "./config.js";
function record(id, ok) { const METRICS_PATH = path.join(stateDir, "skill-metrics.json");
const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0 };
function loadMetrics() {
const counts = new Map();
try {
const raw = fs.readFileSync(METRICS_PATH, "utf8");
const parsed = JSON.parse(raw);
for (const [id, m] of Object.entries(parsed ?? {})) {
counts.set(id, {
ok: Number(m.ok ?? 0),
fail: Number(m.fail ?? 0),
lastTs: Number(m.lastTs ?? 0),
lastCode: m.lastCode ?? null,
lastDurationMs: Number(m.lastDurationMs ?? 0),
totalDurationMs: Number(m.totalDurationMs ?? 0),
});
}
} catch {}
return counts;
}
function saveMetrics(counts) {
try {
fs.mkdirSync(stateDir, { recursive: true });
const out = {};
for (const [id, m] of counts) out[id] = { ...m };
const tmp = `${METRICS_PATH}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(out, null, 2));
fs.renameSync(tmp, METRICS_PATH);
} catch {}
}
export function createSkillMetrics({ persist = true } = {}) {
const counts = persist ? loadMetrics() : new Map(); // id → { ok, fail, lastTs }
function record(id, ok, { code = null, durationMs = 0 } = {}) {
const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0, lastCode: null, lastDurationMs: 0, totalDurationMs: 0 };
if (ok) cur.ok++; if (ok) cur.ok++;
else cur.fail++; else cur.fail++;
cur.lastTs = Date.now(); cur.lastTs = Date.now();
cur.lastCode = code ?? cur.lastCode ?? null;
cur.lastDurationMs = Math.max(0, Math.round(durationMs || 0));
cur.totalDurationMs += cur.lastDurationMs;
counts.set(id, cur); counts.set(id, cur);
if (persist) saveMetrics(counts);
} }
function snapshot() { function snapshot() {
const out = {}; const out = {};
for (const [id, m] of counts) out[id] = { ...m }; for (const [id, m] of counts) {
const total = (m.ok ?? 0) + (m.fail ?? 0);
out[id] = {
...m,
avgDurationMs: total > 0 ? Math.round((m.totalDurationMs ?? 0) / total) : 0,
};
}
return out; return out;
} }
function reset() { function reset() {
counts.clear(); counts.clear();
if (persist) {
try { fs.unlinkSync(METRICS_PATH); } catch {}
}
} }
return { record, snapshot, reset }; return { record, snapshot, reset };
+163
View File
@@ -0,0 +1,163 @@
// survive.acquire-food — turn "hungry and no edible item" into a concrete
// world action. The first implementation is intentionally conservative:
// pick up nearby drops if they are already visible, otherwise hunt a nearby
// passive animal. It does not harvest player-looking crops.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
import { foods } from "./groups.js";
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
let pluginLoaded = new WeakSet();
function ensurePathfinder(bot) {
if (pluginLoaded.has(bot)) return;
bot.loadPlugin(pathfinder);
pluginLoaded.add(bot);
}
function setMovementsForTravel(bot) {
const m = new Movements(bot);
m.canDig = true;
m.allow1by1towers = false;
bot.pathfinder.setMovements(m);
}
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function foodCount(bot) {
const allowed = foods(bot);
return bot.inventory.items().reduce((sum, item) => allowed.has(item.name) ? sum + item.count : sum, 0);
}
function nearestPassiveFoodMob(bot, maxDistance = 32) {
const here = bot?.entity?.position;
if (!here) return null;
let best = null;
for (const e of Object.values(bot.entities ?? {})) {
if (!e?.position || !PASSIVE_FOOD_MOBS.has(e.name)) continue;
const d = e.position.distanceTo(here);
if (d > maxDistance) continue;
if (!best || d < best.distance) best = { entity: e, distance: d };
}
return best;
}
function nearbyDroppedItems(bot, maxDistance = 8) {
const here = bot?.entity?.position;
if (!here) return [];
return Object.values(bot.entities ?? {})
.filter((e) => e?.position && (e.type === "object" || e.name === "item"))
.map((e) => ({ entity: e, distance: e.position.distanceTo(here) }))
.filter((e) => e.distance <= maxDistance)
.sort((a, b) => a.distance - b.distance);
}
async function pickupNearbyDrops(bot) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
let picked = 0;
for (const { entity } of nearbyDroppedItems(bot, 8).slice(0, 6)) {
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(entity.position.x, entity.position.y, entity.position.z, 1)),
8_000,
"gotoDrop",
);
picked++;
} catch {}
}
if (picked > 0) await new Promise((r) => setTimeout(r, 600));
return picked;
}
export const skill = Object.freeze({
id: "survive.acquire-food",
title: "Acquire a basic food item",
timeoutMs: 75_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (foodCount(ctx.bot) > 0) return { ok: false, code: "already_have", detail: "already carrying edible food" };
if (nearestPassiveFoodMob(ctx.bot) || nearbyDroppedItems(ctx.bot, 8).length > 0) return { ok: true };
return { ok: false, code: "no_target", detail: "no nearby food drops or passive food mobs" };
},
async execute(ctx) {
const bot = ctx.bot;
const before = foodCount(bot);
const picked = await pickupNearbyDrops(bot);
if (foodCount(bot) > before) {
return {
ok: true,
code: "done",
detail: { source: "drop", picked },
worldDelta: { acquiredFood: foodCount(bot) - before, source: "drop" },
};
}
const target = nearestPassiveFoodMob(bot);
if (!target) return { ok: false, code: "no_target", detail: "no passive food mob visible", worldDelta: null };
ensurePathfinder(bot);
setMovementsForTravel(bot);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)),
30_000,
"pathToFoodMob",
);
} catch (e) {
return { ok: false, code: "no_path", detail: e.message, worldDelta: null };
}
info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`);
try {
for (let i = 0; i < 8; i++) {
const current = Object.values(bot.entities ?? {}).find((e) => e.id === target.entity.id);
if (!current) break;
if (current.position.distanceTo(bot.entity.position) > 4) {
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalFollow(current, 2)),
8_000,
"repathFoodMob",
);
} catch {}
}
bot.attack(current);
await new Promise((r) => setTimeout(r, 700));
}
await new Promise((r) => setTimeout(r, 1_000));
await pickupNearbyDrops(bot);
const after = foodCount(bot);
if (after <= before) {
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but found no edible drop`, worldDelta: null };
}
return {
ok: true,
code: "done",
detail: { source: "hunt", mob: target.entity.name, gained: after - before },
worldDelta: { acquiredFood: after - before, source: "hunt", mob: target.entity.name },
};
} catch (e) {
warn("action", `survive.acquire-food failed: ${e.message}`);
return { ok: false, code: "failed", detail: e.message, worldDelta: null };
}
},
recover(ctx, result) {
if (result.code === "no_target" || result.code === "no_path") {
return { hint: "wander", reason: "need to search for passive food mobs" };
}
return null;
},
});
export const _internal = { foodCount, nearestPassiveFoodMob };
+3 -2
View File
@@ -121,6 +121,7 @@ export const skill = Object.freeze({
}, },
async execute(ctx, { owned } = {}) { async execute(ctx, { owned } = {}) {
const bot = ctx.bot; const bot = ctx.bot;
const ownedLedger = owned ?? ctx.owned;
const planks = pickBuildPlanks(bot); const planks = pickBuildPlanks(bot);
const base = getLocation("base") ?? getLocation(SHELTER_NAME); const base = getLocation("base") ?? getLocation(SHELTER_NAME);
const center = { x: base.x, y: base.y, z: base.z }; const center = { x: base.x, y: base.y, z: base.z };
@@ -158,8 +159,8 @@ export const skill = Object.freeze({
} }
try { try {
await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock"); await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock");
if (owned?.markPlaced) { if (ownedLedger?.markPlaced) {
owned.markPlaced({ ownedLedger.markPlaced({
x: target.x, y: target.y, z: target.z, x: target.x, y: target.y, z: target.z,
blockType: planks.name, blockType: planks.name,
skill: "village.build-shelter", skill: "village.build-shelter",
+1 -1
View File
@@ -23,7 +23,7 @@ export const skill = Object.freeze({
return { ok: true }; return { ok: true };
}, },
async execute(ctx) { async execute(ctx) {
const result = scoreCurrentPosition(ctx.bot); const result = scoreCurrentPosition(ctx.bot, { isOwned: ctx.owned?.isOwned });
if (!result?.position) { if (!result?.position) {
return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null }; return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null };
} }
+16 -19
View File
@@ -8,7 +8,7 @@
// patch. // patch.
import pathfinderPkg from "mineflayer-pathfinder"; import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg; const { pathfinder, Movements } = pathfinderPkg;
import { info, warn } from "../log.js"; import { info, warn } from "../log.js";
import { digEscapeTunnel } from "./recovery-tunnel-out.js"; import { digEscapeTunnel } from "./recovery-tunnel-out.js";
@@ -69,6 +69,7 @@ export const skill = Object.freeze({
// this server mean we can't trust GoalNear; cardinal probing // this server mean we can't trust GoalNear; cardinal probing
// gives us a free-direction signal cheaply. // gives us a free-direction signal cheaply.
const dist = Math.max(24, args.distance ?? 48); const dist = Math.max(24, args.distance ?? 48);
const beforeProbe = clonePos(bot.entity.position);
const trials = await probeCardinalStep(bot, 800); const trials = await probeCardinalStep(bot, 800);
const movable = trials.filter((t) => t.dist > 0.5); const movable = trials.filter((t) => t.dist > 0.5);
@@ -89,6 +90,16 @@ export const skill = Object.freeze({
} }
info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`); info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`);
const probeMoved = horizontalDistance(beforeProbe, bot.entity.position);
if (probeMoved >= 2) {
return {
ok: true,
code: "done",
detail: { mode: "probe-moved", dir: best.name, moved: probeMoved },
worldDelta: { movedTo: clonePos(bot.entity.position) },
};
}
if (best.dist < 0.5) { if (best.dist < 0.5) {
// All cardinals blocked. Try the cheap vertical escape first; if it // All cardinals blocked. Try the cheap vertical escape first; if it
// does not actually move us, carve a short horizontal tunnel. The // does not actually move us, carve a short horizontal tunnel. The
@@ -107,33 +118,19 @@ export const skill = Object.freeze({
const tx = Math.round(here.x + Math.sin(-best.yaw) * dist); const tx = Math.round(here.x + Math.sin(-best.yaw) * dist);
const tz = Math.round(here.z + Math.cos(-best.yaw) * dist); const tz = Math.round(here.z + Math.cos(-best.yaw) * dist);
const ty = Math.round(here.y); const ty = Math.round(here.y);
info("action", `explore.far: walking ${best.name} ${tx},${ty},${tz}`); info("action", `explore.far: blind-walking ${best.name} toward ${tx},${ty},${tz}`);
try {
await withTimeout(
bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 4)),
45_000,
`explore.far(${tx},${tz})`,
);
return {
ok: true, code: "done",
detail: { to: { x: tx, y: ty, z: tz }, dir: best.name },
worldDelta: { movedTo: { x: tx, y: ty, z: tz } },
};
} catch (e) {
warn("action", `explore.far pathfinder failed: ${e.message} — continuing blind`);
return blindWalkOrTunnelOut(bot, { return blindWalkOrTunnelOut(bot, {
yaw: best.yaw, yaw: best.yaw,
dirName: best.name, dirName: best.name,
blindMs: args.blindMs ?? 7_000, blindMs: args.blindMs ?? 7_000,
tunnelPushMs: args.tunnelPushMs, tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`, reason: `explore.far blind ${best.name}`,
intended: { x: tx, y: ty, z: tz },
}); });
}
}, },
}); });
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback" } = {}) { async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) {
const before = clonePos(bot.entity.position); const before = clonePos(bot.entity.position);
try { await bot.look(yaw, 0, true); } catch {} try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true); bot.setControlState("forward", true);
@@ -150,7 +147,7 @@ async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMov
return { return {
ok: true, ok: true,
code: "done", code: "done",
detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved }, detail: { mode: "blind-moved", previousMode: "blind", dir: dirName, moved, intended },
worldDelta: { movedTo: clonePos(bot.entity.position) }, worldDelta: { movedTo: clonePos(bot.entity.position) },
}; };
} }
+61
View File
@@ -0,0 +1,61 @@
// survive.flee — emergency retreat from the nearest hostile. Unlike
// explore.far, this skill explicitly moves away from the hostile entity
// that triggered the mode.
import { fleeFrom } from "../actions.js";
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",
]);
function nearestHostile(bot, { hostileName } = {}) {
const here = bot?.entity?.position;
if (!here) return null;
let best = null;
for (const e of Object.values(bot.entities ?? {})) {
if (!e?.position) continue;
const name = (e.name || "").toLowerCase();
if (hostileName && name !== String(hostileName).toLowerCase()) continue;
if (!hostileName && !HOSTILE.has(name)) continue;
const d = e.position.distanceTo(here);
if (!best || d < best.distance) best = { entity: e, distance: d };
}
return best;
}
export const skill = Object.freeze({
id: "survive.flee",
title: "Retreat from the nearest hostile",
timeoutMs: 40_000,
preconditions(ctx, args = {}) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
const hit = nearestHostile(ctx.bot, args);
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile entity" };
return { ok: true };
},
async execute(ctx, args = {}) {
const hit = nearestHostile(ctx.bot, args);
if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null };
const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16);
if (res.ok) {
return {
ok: true,
code: "done",
detail: { ...res.detail, from: hit.entity.name, distance: Math.round(hit.distance * 10) / 10 },
worldDelta: { fledTo: res.detail?.to ?? null },
};
}
const msg = String(res.detail ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: res.detail, worldDelta: null };
},
recover(ctx, result) {
if (result.code === "timeout") return { hint: "tunnel-out", reason: "flee path timed out" };
return null;
},
});
export const _internal = { nearestHostile };
+8
View File
@@ -26,13 +26,17 @@ import { skill as chopLogs } from "./chop-logs.js";
import { skill as eat } from "./eat.js"; import { skill as eat } from "./eat.js";
import { skill as wander } from "./wander.js"; import { skill as wander } from "./wander.js";
import { skill as exploreFar } from "./explore-far.js"; import { skill as exploreFar } from "./explore-far.js";
import { skill as flee } from "./flee.js";
import { skill as sleep } from "./sleep.js";
import { skill as tunnelOut } from "./recovery-tunnel-out.js"; import { skill as tunnelOut } from "./recovery-tunnel-out.js";
import { skill as diagPhysics } from "./diagnose-physics.js"; import { skill as diagPhysics } from "./diagnose-physics.js";
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js"; import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
import { skill as gatherStone } from "./gather-stone.js"; import { skill as gatherStone } from "./gather-stone.js";
import { skill as gatherWool } from "./gather-wool.js"; import { skill as gatherWool } from "./gather-wool.js";
import { skill as acquireFood } from "./acquire-food.js";
import { skill as chooseBase } from "./choose-base.js"; import { skill as chooseBase } from "./choose-base.js";
import { skill as buildShelter } from "./build-shelter.js"; import { skill as buildShelter } from "./build-shelter.js";
import { skill as placeChest } from "./place-chest.js";
import { skill as depositSurplus } from "./deposit-surplus.js"; import { skill as depositSurplus } from "./deposit-surplus.js";
import { skill as farmWheat } from "./farm-wheat.js"; import { skill as farmWheat } from "./farm-wheat.js";
import { import {
@@ -65,14 +69,18 @@ register(chopLogs);
register(eat); register(eat);
register(wander); register(wander);
register(exploreFar); register(exploreFar);
register(flee);
register(sleep);
register(tunnelOut); register(tunnelOut);
register(diagPhysics); register(diagPhysics);
register(diagScan); register(diagScan);
register(diagMatch); register(diagMatch);
register(gatherStone); register(gatherStone);
register(gatherWool); register(gatherWool);
register(acquireFood);
register(chooseBase); register(chooseBase);
register(buildShelter); register(buildShelter);
register(placeChest);
register(depositSurplus); register(depositSurplus);
register(farmWheat); register(farmWheat);
register(craftPlanksSkill); register(craftPlanksSkill);
+94
View File
@@ -0,0 +1,94 @@
// village.place-chest — place the carried chest near the base/current
// footing and register it as "chest" in locations.json. This turns the
// storage milestone from "I crafted a chest item" into "I have a usable
// storage location".
import { setLocation, getLocation } from "../locations.js";
function withTimeout(promise, ms, label) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function carriedChest(bot) {
return bot.inventory.items().find((i) => i.name === "chest" || i.name === "trapped_chest");
}
function isEmpty(block) {
return !block || block.boundingBox === "empty" || block.name === "air" || block.name === "cave_air" || block.name === "void_air";
}
function placementCandidate(bot) {
const here = bot.entity.position.floored ? bot.entity.position.floored() : bot.entity.position;
const offsets = [
{ x: 1, z: 0 },
{ x: -1, z: 0 },
{ x: 0, z: 1 },
{ x: 0, z: -1 },
{ x: 2, z: 0 },
{ x: 0, z: 2 },
];
for (const off of offsets) {
const ref = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y - 1), z: Math.round(here.z + off.z) });
const target = bot.blockAt({ x: Math.round(here.x + off.x), y: Math.round(here.y), z: Math.round(here.z + off.z) });
if (ref?.boundingBox === "block" && isEmpty(target)) {
return { ref, face: { x: 0, y: 1, z: 0 }, at: { x: ref.position.x, y: ref.position.y + 1, z: ref.position.z } };
}
}
return null;
}
export const skill = Object.freeze({
id: "village.place-chest",
title: "Place a personal chest",
timeoutMs: 30_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (getLocation("chest")) return { ok: false, code: "already_have", detail: "chest location already exists" };
if (!carriedChest(ctx.bot)) return { ok: false, code: "missing_material", detail: "no chest item in inventory" };
if (!placementCandidate(ctx.bot)) return { ok: false, code: "no_space", detail: "no adjacent placeable slot" };
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
const item = carriedChest(bot);
if (!item) return { ok: false, code: "missing_material", detail: "no chest item after precondition", worldDelta: null };
const place = placementCandidate(bot);
if (!place) return { ok: false, code: "no_space", detail: "no adjacent placeable slot", worldDelta: null };
try {
await withTimeout(bot.equip(item, "hand"), 3_000, "equip chest");
await withTimeout(bot.placeBlock(place.ref, place.face), 5_000, "place chest");
const loc = setLocation("chest", {
x: place.at.x,
y: place.at.y,
z: place.at.z,
dimension: ctx.snapshot?.dimension ?? "overworld",
radius: 2,
note: "auto-placed storage chest",
});
ctx.owned?.markPlaced?.({
x: loc.x,
y: loc.y,
z: loc.z,
dimension: loc.dimension,
blockType: item.name,
skill: "village.place-chest",
});
return {
ok: true,
code: "done",
detail: { location: loc, item: item.name },
worldDelta: { chestAt: { x: loc.x, y: loc.y, z: loc.z }, placedType: item.name },
};
} catch (e) {
const msg = String(e?.message ?? "");
const code = msg.includes("timed out") ? "timeout" : "failed";
return { ok: false, code, detail: e.message, worldDelta: null };
}
},
});
export const _internal = { placementCandidate };
+25 -6
View File
@@ -280,7 +280,8 @@ export function rankTunnelDirections(bot, maxSteps = 3) {
async function digOne(bot, block) { async function digOne(bot, block) {
if (isPassableBlock(block)) return false; if (isPassableBlock(block)) return false;
await equipLikelyTool(bot, block.name); const tool = await equipLikelyTool(bot, block.name);
const timeoutMs = digTimeoutMs(block.name, tool);
try { try {
if (typeof bot.lookAt === "function") { if (typeof bot.lookAt === "function") {
await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`); await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`);
@@ -288,7 +289,7 @@ async function digOne(bot, block) {
} catch { } catch {
// Dig may still work; do not abort on look jitter. // Dig may still work; do not abort on look jitter.
} }
await withTimeout(bot.dig(block), 10_000, `dig(${block.name})`); await withTimeout(bot.dig(block), timeoutMs, `dig(${block.name})`);
const after = bot.blockAt(block.position); const after = bot.blockAt(block.position);
if (after && !isPassableBlock(after) && after.name === block.name) { if (after && !isPassableBlock(after) && after.name === block.name) {
throw new Error(`block still present after dig: ${block.name}`); throw new Error(`block still present after dig: ${block.name}`);
@@ -296,6 +297,16 @@ async function digOne(bot, block) {
return true; return true;
} }
function digTimeoutMs(blockName, equippedTool) {
const kind = toolKindFor(blockName);
if (!kind) return 12_000;
if (equippedTool?.includes(kind)) return 12_000;
if (kind === "pickaxe") return 25_000;
if (kind === "axe") return 18_000;
if (kind === "shovel") return 15_000;
return 12_000;
}
async function pushForward(bot, yaw, ms) { async function pushForward(bot, yaw, ms) {
try { await bot.look(yaw, 0, true); } catch {} try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true); bot.setControlState("forward", true);
@@ -329,8 +340,16 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
const before = posClone(bot.entity.position); const before = posClone(bot.entity.position);
info("action", `tunnel-out: ${reason}${dir.name} (${dir.digTargets.length} blocks to clear)`); info("action", `tunnel-out: ${reason}${dir.name} (${dir.digTargets.length} blocks to clear)`);
try { try {
for (const target of dir.digTargets) { let dug = 0;
await digOne(bot, target.block); let lastStep = 0;
const byStep = [...dir.digTargets]
.sort((a, b) => (a.step - b.step) || (a.kind === "feet" ? -1 : 1));
for (const target of byStep) {
if (target.step !== lastStep && lastStep > 0) {
await pushForward(bot, dir.yaw, Math.min(pushMs, 900));
}
lastStep = target.step;
if (await digOne(bot, target.block)) dug++;
} }
await pushForward(bot, dir.yaw, pushMs); await pushForward(bot, dir.yaw, pushMs);
const moved = horizontalDistance(before, bot.entity.position); const moved = horizontalDistance(before, bot.entity.position);
@@ -340,7 +359,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
return { return {
ok: true, ok: true,
code: "done", code: "done",
detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug: dir.digTargets.length }, detail: { mode: "tunnel-out", dir: dir.name, moved, movedY, dug },
worldDelta: { mode: "tunnel-out", movedTo }, worldDelta: { mode: "tunnel-out", movedTo },
}; };
} }
@@ -363,7 +382,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
export const skill = Object.freeze({ export const skill = Object.freeze({
id: "recovery.tunnel-out", id: "recovery.tunnel-out",
title: "Tunnel out of a wedged 1x1 hole", title: "Tunnel out of a wedged 1x1 hole",
timeoutMs: 45_000, timeoutMs: 120_000,
preconditions(ctx) { preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
return { ok: true }; return { ok: true };
@@ -100,6 +100,38 @@ test("tunnel-out does not count jumping in place as escape", async () => {
assert.match(res.detail.error, /moved only 0\.00 horizontally/); assert.match(res.detail.error, /moved only 0\.00 horizontally/);
}); });
test("tunnel-out digs one reachable layer at a time", async () => {
const blocks = {};
for (let step = 1; step <= 3; step++) {
blocks[`${step},64,0`] = "stone";
blocks[`${step},65,0`] = "stone";
blocks[`${step},63,0`] = "stone";
}
blocks["0,64,-1"] = "oak_planks";
blocks["0,64,1"] = "oak_planks";
blocks["-1,64,0"] = "oak_planks";
const bot = makeBot(blocks);
bot.look = async () => {};
bot.lookAt = async () => {};
bot.dig = async (block) => {
const dist = Math.hypot(block.position.x - bot.entity.position.x, block.position.z - bot.entity.position.z);
if (dist > 1.5) throw new Error(`too far: ${dist.toFixed(1)}`);
blocks[`${block.position.x},${block.position.y},${block.position.z}`] = "air";
};
bot.setControlState = (control, on) => {
if (control === "forward" && !on) {
bot.entity.position = makePos(bot.entity.position.x + 1, bot.entity.position.y, bot.entity.position.z);
}
};
const res = await digEscapeTunnel(bot, { maxSteps: 3, minMove: 0.75, pushMs: 0 });
assert.equal(res.ok, true);
assert.equal(res.detail.dir, "E");
assert.equal(res.detail.dug, 6);
assert.equal(Math.round(bot.entity.position.x), 3);
});
test("explore.far blind fallback does not report done when position is unchanged", async () => { test("explore.far blind fallback does not report done when position is unchanged", async () => {
const blocks = {}; const blocks = {};
const bot = makeBot(blocks); const bot = makeBot(blocks);
+44
View File
@@ -0,0 +1,44 @@
// survive.sleep — use the action-layer bed primitive through the skill
// contract so priority modes can sleep without bypassing metrics,
// scenario-memory, current-task, and self-improvement evidence.
import { sleepInBed } from "../actions.js";
export const skill = Object.freeze({
id: "survive.sleep",
title: "Sleep in or place a carried bed",
timeoutMs: 45_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
if (ctx.snapshot?.isDay) return { ok: false, code: "daytime", detail: "not night" };
const inv = ctx.snapshot?.inventory ?? {};
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
const knownBed = ctx.snapshot?.locations?.shelter || ctx.snapshot?.locations?.base;
if (!hasBed && !knownBed) {
return { ok: false, code: "missing_bed", detail: "no bed in inventory or known shelter" };
}
return { ok: true };
},
async execute(ctx) {
const res = await sleepInBed(ctx.bot);
if (res.ok) {
return {
ok: true,
code: "done",
detail: res.detail,
worldDelta: { sleptAt: res.detail?.bedAt ?? ctx.snapshot?.position ?? null },
};
}
const msg = String(res.detail ?? "");
const code = msg.includes("no bed")
? "missing_bed"
: msg.includes("timed out")
? "timeout"
: "failed";
return { ok: false, code, detail: res.detail, worldDelta: null };
},
recover(ctx, result) {
if (result.code === "missing_bed") return { hint: "curriculum", reason: "need bed milestone" };
return null;
},
});
+1 -1
View File
@@ -74,7 +74,7 @@ test("cooldown prevents back-to-back firings", () => {
}); });
test("skill metrics record ok/fail and expose snapshot", () => { test("skill metrics record ok/fail and expose snapshot", () => {
const m = createSkillMetrics(); const m = createSkillMetrics({ persist: false });
m.record("gather.logs", true); m.record("gather.logs", true);
m.record("gather.logs", true); m.record("gather.logs", true);
m.record("gather.logs", false); m.record("gather.logs", false);
+26 -4
View File
@@ -275,7 +275,7 @@ function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean
); );
} }
type Mode = "idle" | "chat" | "ask-pi"; type Mode = "idle" | "chat" | "ask-pi" | "run-skill" | "incident";
function App() { function App() {
const { exit } = useApp(); const { exit } = useApp();
@@ -366,6 +366,9 @@ function App() {
} }
if (input === "c") setMode("chat"); if (input === "c") setMode("chat");
if (input === "a") setMode("ask-pi"); if (input === "a") setMode("ask-pi");
if (input === "k") setMode("run-skill");
if (input === "v") client.send(COMMAND_TYPES.SCREENSHOT, { reason: "tui", frames: 1 });
if (input === "!") setMode("incident");
if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {}); if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {});
}); });
@@ -377,14 +380,33 @@ function App() {
if (!text) return; if (!text) return;
if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text }); if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text });
else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text }); else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text });
else if (m === "run-skill") {
const [skillId, ...rest] = text.split(/\s+/);
let args = {};
const json = rest.join(" ").trim();
if (json) {
try { args = JSON.parse(json); }
catch {
client.send(COMMAND_TYPES.ASK_PI, { prompt: `Parse this run-skill argument JSON for ${skillId}: ${json}` });
return;
}
}
client.send(COMMAND_TYPES.RUN_SKILL, { skillId, args });
} else if (m === "incident") {
client.send(COMMAND_TYPES.FORCE_INCIDENT, { reason: text, kind: "operator-forced" });
}
} }
const hotkeyHint = const hotkeyHint =
mode === "idle" mode === "idle"
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [y] proposals [q]uit" ? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [k] skill [v] screenshot [!] incident [y] proposals [q]uit"
: mode === "chat" : mode === "chat"
? "chat → MC (Enter to send, Esc to cancel)" ? "chat → MC (Enter to send, Esc to cancel)"
: "ask-pi → spawn pi -p (Enter to send)"; : mode === "ask-pi"
? "ask-pi → spawn pi -p (Enter to send)"
: mode === "run-skill"
? 'run-skill → skill.id {"arg":true}'
: "incident → reason for critic/auto-improve proposal";
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
@@ -412,7 +434,7 @@ function App() {
) : ( ) : (
<> <>
<Text bold color={mode === "chat" ? "cyan" : "magenta"}> <Text bold color={mode === "chat" ? "cyan" : "magenta"}>
{mode === "chat" ? "chat> " : "pi> "} {mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}
</Text> </Text>
<TextInput <TextInput
value={inputValue} value={inputValue}