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>/)
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
```
@@ -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. |
| `c` | Send a chat message into MC. |
| `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. |
| `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.
🌿 **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).
+59 -35
View File
@@ -96,6 +96,9 @@ TUI; the bot is unaffected.
| `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. |
| `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. |
| `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
Mineflayer actions:
1. **`defendReflex`** — closest hostile within 4 m → `attackNearest`
(equips best melee). Within 12 m + low HP or ≥3 hostiles → `fleeFrom`
along the away-vector.
2. **`eatReflex`** — food < 16 → `eatBestFood` (picks from
FOOD_PRIORITY list, equip + consume). 5 s cooldown.
3. **`sleepReflex`** — night + no hostile within 8 m → `sleepInBed`
(finds nearest placed bed within 16 blocks, paths there, sleeps).
5 min cooldown on failures.
4. **`techTreeReflex`** — deterministic crafting progression
(planks → sticks → wooden axe → pickaxe → sword) when prerequisites
are in inventory.
5. **`autonomousReflex`** — when nothing reactive fires: chop trees until
~16 logs, then wander to discover new chunks.
1. **Modes**Mindcraft-style interrupts run first:
`self_preservation`, `hunger`, `night_shelter`. They dispatch
registered skills (`survive.flee`, `survive.eat`, `survive.sleep`)
and therefore feed metrics, scenario memory and current-task state.
2. **`defendReflex`** — closest hostile within 4 m → `attackNearest`
(equips best melee). Low HP / close hostile → flee.
3. **`eatReflex`** — food < 16 and edible item is carried →
`eatBestFood`. No-food states fall through to the curriculum instead
of eat-spamming.
4. **`sleepReflex`** — night + no hostile within 8 m + bed available →
`sleepInBed`. Impossible states are skipped before dispatch.
5. **`curriculumReflex`** — dispatches `snapshot.curriculum.plan.skillId`
through `runSkill`. Inventory pressure can insert
`village.deposit-surplus` before continuing.
6. **`idleReflex`** — every 20th tick, log heartbeat (HP / food / pos).
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
return `code: "unsupported_version"` instead of crashing.
Reference skills shipped today: `gather.logs`, `survive.eat`,
`explore.wander`. The reflex loop still calls the older
`runtime/actions.js` primitives directly — porting more behaviours to
skills lands in later phases.
Reference skills shipped today include `gather.logs`, `gather.stone`,
`gather.wool`, `survive.eat`, `survive.flee`, `survive.sleep`,
`survive.acquire-food`, `explore.wander`, `explore.far`,
`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:
@@ -216,7 +224,8 @@ npm test
```
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
@@ -229,14 +238,13 @@ planks).
The current curriculum result is on every snapshot as
`snapshot.curriculum = { milestone, plan, inventoryFull }` so the TUI
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 reflex loop is a Phase 4 task; today the reflex still uses
`actions.js` directly.
The scheduler now calls `runSkill(plan.skillId, …)` directly from
`curriculumReflex`; no LLM is in the hot path.
Inventory pressure: `isInventoryFull(snapshot)` is exposed on every
curriculum result; the TUI surfaces `[inventory full]` next to the
milestone label so the operator can see when a deposit step is needed
before progress continues.
curriculum result; the TUI surfaces `[inventory full]`, and when a
known/nearby chest exists the scheduler tries `village.deposit-surplus`
before the next milestone.
### Optional: prismarine-viewer
@@ -259,10 +267,15 @@ Two persistent stores under `state/<host>/`:
Pruned at 6 h age + 10k line ceiling. `leanestQuadrant({x, z})`
returns the cardinal quadrant the bot has explored LEAST — used by
`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,
code, ok, detail, ts)` tuples. `situationHash` is a coarse fingerprint
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})` —
≥3 failures of the same `(skill, situation)` within 30 min and 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)
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
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
within 32m), the curriculum reflex swaps to `wander` for ~60 s.
- 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.
- Unknown `skillId` (curriculum suggested something that isn't
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
array of repo-relative path prefixes the auto-patcher is allowed to
modify. `state-store.readProposalEditScope(filename)` reads it back;
hooking `scripts/auto-patch.js` to refuse cherry-picks that touch
other areas is the remaining follow-up.
modify. `scripts/auto-patch.js` enforces that scope, runs the cheap
`scripts/lint-patch.js` gate, then runs `npm test` before cherry-pick.
Per-skill metrics live in memory only (best-effort) but are surfaced
on `snapshot.skillMetrics = { [skillId]: { ok, fail, lastTs } }` so
the TUI can show which skills are reliable and which keep failing.
Learning speed is configurable:
- `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)
@@ -442,6 +460,9 @@ shutdown). Framing: one JSON object per line.
| `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. |
| `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
(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)
- creates branch auto/<slug> off main
- 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
6. supervisor's runtime/*.js watcher fires the moment the cherry-pick
lands → child restarts on the new code
@@ -483,8 +505,10 @@ if they break things. The flow:
### Rate limits
- **Proposal cooldown**: 30 min between proposal files of any kind.
- **Auto-improve cooldown**: 15 min between finished `auto-patch.js` runs.
- **Hourly cap**: max 4 auto-patches per hour, even if cooldown allows.
- **Auto-improve cooldown**: default 15 min between finished
`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
supervisor exits and waits for human review.
+5
View File
@@ -67,6 +67,10 @@ function forceStopCollectBlock(bot) {
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
// before calling goto — otherwise it inherits whatever the previous caller
// 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 } };
} catch (e) {
warn("action", `wander pathfinder failed: ${e.message} — continuing blind in ${best.name}`);
clearPathfinderGoal(bot);
const beforeBlind = clonePos(bot.entity.position);
try { await bot.look(best.yaw, 0, true); } catch {}
bot.setControlState("forward", true);
+3 -3
View File
@@ -15,7 +15,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { stateDir } from "./config.js";
import { config, stateDir } from "./config.js";
import { info, warn } from "./log.js";
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 DEBOUNCE_MS = 10_000;
const COOLDOWN_MS = 15 * 60 * 1000;
const MAX_TOTAL_PER_HOUR = 4;
const COOLDOWN_MS = config.autoImproveCooldownMs;
const MAX_TOTAL_PER_HOUR = config.autoImproveMaxPerHour;
let inFlight = false;
let lastFinishedAt = 0;
+14 -3
View File
@@ -56,6 +56,7 @@ import { requestCritique } from "./critic.js";
import { createSkillMetrics } from "./skill-metrics.js";
import { createWorldJournal } from "./world-journal.js";
import { createScenarioMemory, situationHash } from "./scenario-memory.js";
import { createOwnedBlocksLedger } from "./owned-blocks.js";
fs.mkdirSync(stateDir, { recursive: true });
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
// isn't it doing more?" without parsing the log stream.
const noProgress = createNoProgressDetector();
const stuckIncident = createStuckIncidentDetector();
const stuckIncident = createStuckIncidentDetector({
thresholdMs: config.stuckThresholdMs,
cooldownMs: config.stuckCooldownMs,
});
const skillMetrics = createSkillMetrics();
const worldJournal = createWorldJournal();
const scenarioMemory = createScenarioMemory();
const ownedBlocks = createOwnedBlocksLedger();
let lastResult = null; // { label, ok, code, detail, ts }
let lastFailureAt = 0;
let lastPlanReadAt = 0;
@@ -109,6 +114,8 @@ const reflexCtx = {
dispatch: dispatchAction,
journal: worldJournal,
memory: scenarioMemory,
metrics: skillMetrics,
owned: ownedBlocks,
};
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.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt });
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.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt });
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.currentActionLabel = label;
const startedAt = Date.now();
// Capture the situation hash BEFORE the action runs so a failure is
// attributable to the state at dispatch time, not the state after the
// (partial) effect.
@@ -243,7 +254,7 @@ function dispatchAction(fn, label, opts = {}) {
detail: res?.detail,
ts: Date.now(),
};
skillMetrics.record(label, ok);
skillMetrics.record(label, ok, { code: lastResult.code, durationMs: Date.now() - startedAt });
scenarioMemory.record({
skillId: label,
situation: startSituation,
@@ -278,7 +289,7 @@ function dispatchAction(fn, label, opts = {}) {
detail: String(e?.message ?? e),
ts: Date.now(),
};
skillMetrics.record(label, false);
skillMetrics.record(label, false, { code: "threw", durationMs: Date.now() - startedAt });
scenarioMemory.record({
skillId: label,
situation: startSituation,
+12
View File
@@ -18,9 +18,16 @@ function opt(name, 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 port = Number.parseInt(opt("MC_PORT", "25565"), 10);
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
// handshake — the right default per the survival-bot PRD (no hard-coded modern
@@ -44,6 +51,11 @@ export const config = Object.freeze({
.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),
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.
viewerPort: (() => {
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;
},
suggest: () => ({ skillId: "survive.eat" }), // best-effort; richer "find food" skill lands later
suggest: () => ({ skillId: "survive.acquire-food" }),
},
{
id: "storage.chest",
title: "Place a personal chest",
isDone: (inv) => has(inv, "chest"),
suggest: () => ({ skillId: "craft.chest" }),
isDone: (_inv, snap) => !!snap?.locations?.chest,
suggest: (inv) => {
if (!has(inv, "chest")) return { skillId: "craft.chest" };
return { skillId: "village.place-chest" };
},
},
{
id: "shelter.torch",
+17
View File
@@ -154,6 +154,7 @@ test("all done → null", () => {
nextMilestone(snap(inv, {
food: 20,
locations: {
chest: { x: 1, y: 64, z: 0 },
base: { 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");
}
});
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;
if (!snap) return null;
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
if (hp < 6 && food > 0 && snap.hasFood) {
return { action: { skillId: "eat" }, detail: { reason: "hp<6", hp } };
if (hp < 6 && hasFood) {
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
const ch = snap.closestHostile;
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;
},
@@ -102,8 +110,11 @@ registerMode({
update(ctx) {
const snap = ctx?.snapshot;
if (!snap) return null;
if (ctx.modeCooldown?.hunger && Date.now() < ctx.modeCooldown.hunger) return null;
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;
},
@@ -116,11 +127,14 @@ registerMode({
update(ctx) {
const snap = ctx?.snapshot;
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
if (snap.isDay) return null;
const inv = snap.inventory || {};
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
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 out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } });
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 () => {
_resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 2}`);
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 () => {
@@ -91,5 +91,5 @@ test("night_shelter: night + bed in hand → sleep", async () => {
_resetModes();
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 } } });
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);
}
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, {
intervalMs = WATCH_INTERVAL_MS,
windowMs = STUCK_WINDOW_MS,
@@ -71,6 +84,15 @@ export function createPathfinderWatchdog(bot, {
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 here = hpos(bot.entity?.position);
@@ -81,8 +103,11 @@ export function createPathfinderWatchdog(bot, {
}
if (now - lastSeenAt < windowMs) return;
// Stuck. Force a replan — clear the goal, then re-set the same
// goal so pathfinder rebuilds the graph against the current world.
// Stuck. Force a replan by setting the exact same goal object again.
// 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) {
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
@@ -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}`);
try {
const goalCopy = goal;
// setGoal(null) cancels the current pathing without bubbling
// 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);
pf.setGoal(goalCopy);
lastSeenAt = now; // reset window
} catch (e) {
warn("pathfinder", `replan failed: ${e.message}`);
@@ -118,4 +135,4 @@ export function createPathfinderWatchdog(bot, {
}
// 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 });
await new Promise((r) => setTimeout(r, 2200)); // pass min-travel + window
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}`);
// First call is setGoal(null).
assert.equal(bot.setGoalCalls[0], null);
assert.equal(bot.setGoalCalls[0], goal);
});
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 () => {
@@ -68,8 +79,7 @@ test("respects maxReplans cap", async () => {
const wd = createPathfinderWatchdog(bot, { intervalMs: 50, windowMs: 80, delta: 0.5, maxReplans: 2 });
await new Promise((r) => setTimeout(r, 5000));
wd.stop();
// Each replan = setGoal(null) + delayed setGoal(goal). So 2 replans ≤ 4 calls.
assert.ok(bot.setGoalCalls.length <= 4, `expected ≤4 setGoal calls, got ${bot.setGoalCalls.length}`);
assert.ok(bot.setGoalCalls.length <= 2, `expected ≤2 setGoal calls, got ${bot.setGoalCalls.length}`);
});
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
// for reflex decisions and for periodic IPC STATUS events.
import { foods } from "./skills/groups.js";
import { findBlocksByName } from "./perception.js";
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 };
@@ -29,6 +32,95 @@ const HOSTILE = new Set([
"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) {
if (!bot || !bot.entity) {
return { connected: false };
@@ -42,10 +134,29 @@ export function snapshot(bot) {
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;
}, {});
const inventory = inventoryCounts(bot);
const nearbyBlocks = countInterestingBlocks(bot, pos);
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 {
connected: true,
@@ -60,6 +171,15 @@ export function snapshot(bot) {
weather: { rain: bot.isRaining, thunder: bot.thundering },
dimension: bot.game?.dimension,
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)) })),
hostileCount: hostiles.length,
closestHostile: closestHostile
+76 -3
View File
@@ -311,6 +311,45 @@ function sleepReflex(ctx) {
const CURRICULUM_COOLDOWN_MS = 4_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) {
const s = ctx.snapshot;
@@ -323,6 +362,39 @@ function curriculumReflex(ctx) {
const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
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.
// First hint → small wander (might just be 32-block reach issue).
@@ -382,7 +454,7 @@ function curriculumReflex(ctx) {
if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be
// 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)) {
ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS;
}
@@ -432,11 +504,12 @@ export function runTick(ctx) {
if (modeHit?.action?.skillId) {
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
ctx.dispatch(fn, modeHit.action.skillId, {});
return {
reflex: `mode:${modeHit.mode}`,
action: "dispatch",
action: "dispatched",
kind: `mode:${modeHit.mode}`,
label: modeHit.action.skillId,
fn,
detail: modeHit.detail,
};
}
+87
View File
@@ -45,6 +45,7 @@ function makeCtx({
lastEatAt = 0,
lastSleepAttemptAt = 0,
lastCurriculumAt = 0,
metrics,
} = {}) {
const dispatches = [];
const ctx = {
@@ -56,6 +57,7 @@ function makeCtx({
lastSleepAttemptAt,
lastCurriculumAt,
skillBackoff,
metrics,
dispatch(fn, label, opts = {}) {
dispatches.push({ fn, label, opts });
},
@@ -82,6 +84,23 @@ test("disconnected snapshot → no dispatch", () => {
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", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
@@ -279,6 +298,74 @@ test("wander-hint backoff swaps skill for wander on the next tick", () => {
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", () => {
const { ctx, dispatches } = makeCtx({
snapshot: {
+6 -1
View File
@@ -45,13 +45,18 @@ export function situationHash(snapshot) {
const hostile = snapshot.closestHostile && snapshot.closestHostile.distance < 24
? 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
// I have". Limited to first 10 names for hash stability.
const invKeys = Object.keys(snapshot.inventory ?? {})
.sort()
.slice(0, 10)
.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() {
+4 -1
View File
@@ -42,11 +42,14 @@ function extractHeaderComment(src) {
function skillFilePath(id) {
const slug = id.replace(/\./g, "-");
const tail = id.split(".").slice(1).join("-");
const candidates = [
path.join(__dirname, "skills", `${slug}.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;
}
+57 -9
View File
@@ -1,27 +1,75 @@
// Per-skill ok/fail counters. Aggregated for the lifetime of the bot
// process (best-effort persistence is left for a future iteration —
// today's counters reset on restart, which keeps the data store
// simple while still being useful for incident bodies and the TUI).
// Per-skill ok/fail counters. Persisted under state/<host>/ so the bot's
// next run and auto-improvement prompts can learn from prior attempts,
// not only the current process lifetime.
export function createSkillMetrics() {
const counts = new Map(); // id → { ok, fail, lastTs }
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "./config.js";
function record(id, ok) {
const cur = counts.get(id) ?? { ok: 0, fail: 0, lastTs: 0 };
const METRICS_PATH = path.join(stateDir, "skill-metrics.json");
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++;
else cur.fail++;
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);
if (persist) saveMetrics(counts);
}
function snapshot() {
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;
}
function reset() {
counts.clear();
if (persist) {
try { fs.unlinkSync(METRICS_PATH); } catch {}
}
}
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 } = {}) {
const bot = ctx.bot;
const ownedLedger = owned ?? ctx.owned;
const planks = pickBuildPlanks(bot);
const base = getLocation("base") ?? getLocation(SHELTER_NAME);
const center = { x: base.x, y: base.y, z: base.z };
@@ -158,8 +159,8 @@ export const skill = Object.freeze({
}
try {
await withTimeout(bot.placeBlock(place.ref, place.face), 5000, "placeBlock");
if (owned?.markPlaced) {
owned.markPlaced({
if (ownedLedger?.markPlaced) {
ownedLedger.markPlaced({
x: target.x, y: target.y, z: target.z,
blockType: planks.name,
skill: "village.build-shelter",
+1 -1
View File
@@ -23,7 +23,7 @@ export const skill = Object.freeze({
return { ok: true };
},
async execute(ctx) {
const result = scoreCurrentPosition(ctx.bot);
const result = scoreCurrentPosition(ctx.bot, { isOwned: ctx.owned?.isOwned });
if (!result?.position) {
return { ok: false, code: "no_position", detail: "bot has no position", worldDelta: null };
}
+23 -26
View File
@@ -8,7 +8,7 @@
// patch.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
const { pathfinder, Movements } = pathfinderPkg;
import { info, warn } from "../log.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
// gives us a free-direction signal cheaply.
const dist = Math.max(24, args.distance ?? 48);
const beforeProbe = clonePos(bot.entity.position);
const trials = await probeCardinalStep(bot, 800);
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}`);
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) {
// All cardinals blocked. Try the cheap vertical escape first; if it
// 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 tz = Math.round(here.z + Math.cos(-best.yaw) * dist);
const ty = Math.round(here.y);
info("action", `explore.far: walking ${best.name} ${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, {
yaw: best.yaw,
dirName: best.name,
blindMs: args.blindMs ?? 7_000,
tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`,
});
}
info("action", `explore.far: blind-walking ${best.name} toward ${tx},${ty},${tz}`);
return blindWalkOrTunnelOut(bot, {
yaw: best.yaw,
dirName: best.name,
blindMs: args.blindMs ?? 7_000,
tunnelPushMs: args.tunnelPushMs,
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);
try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true);
@@ -150,7 +147,7 @@ async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMov
return {
ok: true,
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) },
};
}
+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 wander } from "./wander.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 diagPhysics } from "./diagnose-physics.js";
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
import { skill as gatherStone } from "./gather-stone.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 buildShelter } from "./build-shelter.js";
import { skill as placeChest } from "./place-chest.js";
import { skill as depositSurplus } from "./deposit-surplus.js";
import { skill as farmWheat } from "./farm-wheat.js";
import {
@@ -65,14 +69,18 @@ register(chopLogs);
register(eat);
register(wander);
register(exploreFar);
register(flee);
register(sleep);
register(tunnelOut);
register(diagPhysics);
register(diagScan);
register(diagMatch);
register(gatherStone);
register(gatherWool);
register(acquireFood);
register(chooseBase);
register(buildShelter);
register(placeChest);
register(depositSurplus);
register(farmWheat);
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) {
if (isPassableBlock(block)) return false;
await equipLikelyTool(bot, block.name);
const tool = await equipLikelyTool(bot, block.name);
const timeoutMs = digTimeoutMs(block.name, tool);
try {
if (typeof bot.lookAt === "function") {
await withTimeout(bot.lookAt(centerOf(block.position), true), 1_500, `lookAt(${block.name})`);
@@ -288,7 +289,7 @@ async function digOne(bot, block) {
} catch {
// 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);
if (after && !isPassableBlock(after) && after.name === block.name) {
throw new Error(`block still present after dig: ${block.name}`);
@@ -296,6 +297,16 @@ async function digOne(bot, block) {
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) {
try { await bot.look(yaw, 0, true); } catch {}
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);
info("action", `tunnel-out: ${reason}${dir.name} (${dir.digTargets.length} blocks to clear)`);
try {
for (const target of dir.digTargets) {
await digOne(bot, target.block);
let dug = 0;
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);
const moved = horizontalDistance(before, bot.entity.position);
@@ -340,7 +359,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
return {
ok: true,
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 },
};
}
@@ -363,7 +382,7 @@ export async function digEscapeTunnel(bot, { maxSteps = 3, minMove = 0.75, pushM
export const skill = Object.freeze({
id: "recovery.tunnel-out",
title: "Tunnel out of a wedged 1x1 hole",
timeoutMs: 45_000,
timeoutMs: 120_000,
preconditions(ctx) {
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
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/);
});
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 () => {
const 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", () => {
const m = createSkillMetrics();
const m = createSkillMetrics({ persist: false });
m.record("gather.logs", true);
m.record("gather.logs", true);
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() {
const { exit } = useApp();
@@ -366,6 +366,9 @@ function App() {
}
if (input === "c") setMode("chat");
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, {});
});
@@ -377,14 +380,33 @@ function App() {
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 });
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 =
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"
? "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 (
<Box flexDirection="column">
@@ -412,7 +434,7 @@ function App() {
) : (
<>
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
{mode === "chat" ? "chat> " : "pi> "}
{mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}
</Text>
<TextInput
value={inputValue}