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:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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" } };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user