diff --git a/README.md b/README.md index ca898c2..ae34cdf 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ pi /login # OAuth flow — ChatGPT Pro / Claude Max # Terminal 1: the daemon (logs in stdout, persists state under state//) 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). diff --git a/docs/runtime.md b/docs/runtime.md index 4159be9..2c2b73d 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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//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//`: 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//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 ""`. | | `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/ off main - runs `pi -p ""` 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. diff --git a/docs/v0.2.0-self-learning.md b/docs/v0.2.0-self-learning.md new file mode 100644 index 0000000..ac7601e --- /dev/null +++ b/docs/v0.2.0-self-learning.md @@ -0,0 +1,229 @@ +# pepa v0.2.0 — self-learning agent + +> Status: **active** (started 2026-05-27). Carries forward the WIP that landed +> in the baseline commit of this branch (pathfinder watchdog, reflex +> metric-recovery, persistent skill metrics, new skills like `flee`, +> `acquire-food`, `place-chest`, `sleep`). +> Local-only repo — this file ships in the working tree but is not pushed. + +## Why v0.2.0 + +The v0.1.x bot survives in the abstract — reflex chain, curriculum, modes, +auto-improve — but on the live server it spent ~15 hours overnight in a tight +death spiral (≈300 deaths, never moved off spawn, no tools, no progress). +Three failure modes dominate: + +1. **No persistent knowledge.** Each tick reasons from scratch. The bot has + no structured memory of "what I tried", "what worked", "what the recipe + for X is", "what kills this mob". `scenarios.jsonl` is a forgetful + sliding window keyed by a coarse situation hash, not a queryable store. +2. **No coaching from failure.** A death is just a respawn event. Pi is + never asked "why did I die, what should I do differently next time". + Lessons aren't extracted and aren't recalled before the next attempt. +3. **No outside knowledge.** The bot doesn't read the wiki, doesn't know + that creepers need 4-block kite distance, that fists vs hostile at + night is suicide, that bread is the cheapest renewable food. + +v0.2.0 fixes the substrate so future iterations get steeper progress per +hour-of-runtime. The bot becomes Voyager-shaped: structured skill library + +persistent learned-skill memory + post-mortem-driven self-improvement + +retrieval-augmented decision making + free chat persona. + +## Pillars + +### 1. Structured knowledge base (SQLite) + +A single per-server `state//knowledge.db` (gitignored) with these +tables: + +| Table | Purpose | +|---|---| +| `lessons` | Generalised lessons learned (one row = "don't attack hostiles at night without armor"). Pi extracts these from post-mortems and explicit incidents. Indexed by tag + situation key. Each lesson has `confidence` and `applied_count`. | +| `recipes` | Crafting recipes (seeded from `docs/minecraft-recipes.json`, augmented by wiki). | +| `mob_intel` | Per-mob behaviour: aggro range, drops, weakness, light requirement, "should we fight or flee" verdict. | +| `block_intel` | Per-block: required tool tier, drops, light emission, walkable. | +| `deaths` | One row per death event: ts, position, last skill, last hostile, hp/food at death, inventory loss summary, raw context blob. | +| `postmortems` | One row per Pi-analysed death event: links to `deaths`, contains structured Pi output (`cause`, `lesson`, `next_action_suggestion`). | +| `poi` | Points of interest: discovered ore vein, water, foreign build, danger zone. Spatial-grid indexed. Supersedes scatter in `world-journal.jsonl` for queryable retrieval; the journal stays as append-only event log. | +| `wiki_pages` | Cached `minecraft.wiki` pages with TTL. `etag`, `body`, `fetched_at`. | +| `chat_log` | Full inbound + outbound chat, structured (speaker, text, intent, replied_with). Supersedes the per-speaker LRU for long-tail player memory while keeping it as the fast in-process buffer. | +| `code_changes` | Audit of every auto-patch the bot applied to its own code: file, diff hash, proposal slug, success/rollback. | + +SQLite chosen because: zero-config, perfect for single-process workloads, +embeddable, queryable by humans (`sqlite3 state/.../knowledge.db ".tables"`). +Migration: bundled `schema.sql` is applied idempotently on every boot; +schema version row gates additive changes. + +**Graceful degradation:** if `better-sqlite3` isn't installed yet (fresh +`git pull` without `npm install`), `runtime/knowledge/store.js` logs one +warning and the knowledge subsystem becomes a no-op. The rest of the bot +keeps working. + +### 2. Death post-mortem coach + +`runtime/coach/postmortem.js`: + +1. Hooks `bot.on('death')` (event already exists; the runtime currently + only logs it). +2. Captures the death-context bundle: last 30s of log/scenarios, last + dispatched skill, last hostile name + position, inventory delta (lost), + plan.md current line, recent world-journal events nearby. +3. Inserts a row into `deaths`. +4. Enqueues a Pi job (rate-limited, max N per hour) that asks: + > Look at this death event. In ≤120 words: what was the root cause, + > what generalised lesson should I remember next time I see this + > situation, and what skill should I dispatch differently? +5. Parses Pi's JSON reply into `postmortems` and `lessons`. + +Lessons are tagged by situation: hostile name, biome, day/night, HP bucket, +inventory presence (weapon? armor?). At dispatch time, the curriculum +reflex calls `lessons.recall({skill, situationHash, hostile})` and adjusts +its plan if a high-confidence lesson is on point. + +### 3. Wiki crawler (Phase 2 of v0.2.0) + +`runtime/knowledge/wiki.js` — on-demand fetch of `minecraft.wiki/w/` +pages with the global fetch API. Parses recipe + mob + block info heuristically +(table-row scrape + targeted regex for hardness/light/drops). Respects +robots.txt and adds `User-Agent: pepa-pi-bot (https://github.com/...)`. +Pages cached in `wiki_pages` with 7-day TTL. + +Used by `recipes`, `mob_intel`, `block_intel` when the local DB misses. +Triggered also by an explicit "go-learn" Pi suggestion ("look up phantom +behaviour before you sleep tonight"). + +### 4. Coach loop + +`runtime/coach/index.js` — a periodic out-of-band coach pass that runs +every 30 minutes (configurable) and: + +- Reads the last N deaths, last N skill failures, last N hours of the + world-journal, the active plan.md. +- Asks Pi (one job, batched) to produce: lessons + plan adjustments + + proposal hints. +- Writes lessons to the DB. +- If Pi recommends a plan change → patches plan.md (with a backup) and + writes a diary entry explaining the change. +- If Pi recommends code surgery → writes a `state//proposals/.md` + file in the existing self-improvement format, so the auto-patch pipeline + picks it up. + +This is the *proactive* counterpart to the reactive stuck-incident +proposal. The bot is no longer waiting to be 5×-broken; it's reflecting +every 30 minutes regardless. + +### 5. Persona — narration & lifelike chat + +`runtime/persona/chatter.js` — when the bot: + +- starts a major skill (gather, build, travel ≥ 50 blocks) +- has a milestone success +- spots a notable threat ("4 креперов рядом") +- gets killed and respawns +- spots a player nearby + +...it occasionally drops a single short Russian line in MC chat ("пошёл за +деревом", "ох, креперы, прячусь", "доброе утро"). Heavily rate-limited +(min 60s between lines, max 8/hour). Driven by simple templates + Pi +fallback for novelty. + +`runtime/persona/look.js` (Phase 2 of v0.2.0) — `bot.look(yaw, pitch)` +during idle to simulate a living player: glance at sky, look at speakers, +look at target block before mining. Implemented as a low-priority +"animation" reflex that fires only when no real action is dispatched. + +### 6. Retrieval-augmented dispatch + +Before dispatching a skill, the curriculum reflex now queries: + +```js +const advice = knowledge.recall({ + skillId, + situation: snapshot.situationHash, + hostile: snapshot.threats[0]?.name, +}); +``` + +If advice contains a lesson with `confidence ≥ 0.6` and an explicit +`avoid: skillId` directive, the skill is backed off (and the lesson's +`applied_count` is incremented). If the lesson contains a `prefer: +alternative_skill_id`, that alternative is dispatched instead. + +This is the loop that closes "I died yesterday in this exact spot +attacking a creeper with fists" → "tonight I avoid attack and dig +shelter instead". + +## Phases — what ships in which rc + +### v0.2.0-rc.1 (this branch) + +- [x] WIP baseline commit (pathfinder/reflex/metrics fixes). +- [ ] This design doc. +- [ ] `better-sqlite3` dep + `runtime/knowledge/{store,schema,seed,index,lessons}.js`. +- [ ] Seed knowledge DB from `docs/minecraft-recipes.json` + the + mob/biome/food/recipe tables in `docs/minecraft-knowledge.md`. +- [ ] `runtime/coach/postmortem.js` — death event capture, deaths table, + Pi-extracted lessons (initial version uses async one-shot, no batch). +- [ ] `runtime/persona/chatter.js` — narration on death/respawn, + gather-start, threat-spotted (templates only, no Pi yet). +- [ ] Wire-in: `bot.js` calls `coach.attach(bot)` and + `persona.attach(bot)` once on connect. +- [ ] Tests for store, lessons, postmortem context capture, persona + cooldowns. + +### v0.2.0-rc.2 + +- [ ] Wiki crawler with cache + parser. +- [ ] Retrieval-augmented dispatch (curriculum + dispatch hooks). +- [ ] `runtime/persona/look.js` — head movement. +- [ ] `runtime/coach/index.js` — periodic batched Pi pass. + +### v0.2.0 (final) + +- [ ] Tune rate limits + thresholds with real data from the prior weeks. +- [ ] Cross-server lesson abstraction (raw → committed `skills/.md` + when applied repeatedly across situations). +- [ ] Roadmap update. + +## Out of scope for v0.2.0 + +- Cross-server identity (still per-host). +- Telegram bridge (Phase 4 in roadmap). +- Voice/avatar mimicry beyond chat narration. +- PvP/grief tooling. + +## File layout (new) + +``` +plans/ + v0.2.0-self-learning.md (this file) + +runtime/knowledge/ + store.js better-sqlite3 gateway, schema bootstrap + schema.sql DDL (versioned) + seed.js load docs/minecraft-recipes.json + mob_intel rows + index.js public API: recall(), record(), lookupRecipe(), … + lessons.js lesson tag-matching, scoring, retrieval + wiki.js (rc.2) on-demand wiki fetcher + +runtime/coach/ + postmortem.js on death → capture → DB → enqueue Pi + index.js (rc.2) periodic batched coach pass + +runtime/persona/ + chatter.js proactive narration + look.js (rc.2) head movement +``` + +## Compatibility / safety + +- All new modules: side-effect-free import (no top-level network, no DB + open). Attach via explicit `.attach(bot)` calls in `bot.js`. +- DB writes are best-effort (try/catch). A broken DB never crashes the + reflex loop. +- Pi calls from the coach respect the existing 6-per-hour banter limit + via a shared rate-limiter (`runtime/pi-budget.js` — new tiny module). +- Chat narration respects existing `cmd:chat` rate limits + dialog-only + policy: it's `bot.chat()` calls, no command verbs. +- All new tables in `state//knowledge.db` are gitignored via the + existing `state/` rule. diff --git a/package-lock.json b/package-lock.json index 21b8448..8e38e3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "pepa-pi-bot", - "version": "0.1.0", + "version": "0.2.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pepa-pi-bot", - "version": "0.1.0", + "version": "0.2.0-rc.1", "license": "MIT", "dependencies": { + "better-sqlite3": "^11.10.0", "canvas": "^3.2.3", "dotenv": "^16.4.5", "ink": "^7.0.4", @@ -742,6 +743,26 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1560,6 +1581,12 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", diff --git a/package.json b/package.json index e45d995..b37ced7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pepa-pi-bot", - "version": "0.1.0", + "version": "0.2.0-rc.1", "private": true, "description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.", "license": "MIT", @@ -16,9 +16,10 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { + "better-sqlite3": "^11.10.0", "canvas": "^3.2.3", "dotenv": "^16.4.5", "ink": "^7.0.4", diff --git a/runtime/actions.js b/runtime/actions.js index 2503b43..5da9715 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -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); diff --git a/runtime/auto-improve.js b/runtime/auto-improve.js index 7796f7f..82e7380 100644 --- a/runtime/auto-improve.js +++ b/runtime/auto-improve.js @@ -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; diff --git a/runtime/bot.js b/runtime/bot.js index f4912cc..78a0ea2 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -56,10 +56,19 @@ 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"; +import { initKnowledge } from "./knowledge/index.js"; +import { attach as attachCoach } from "./coach/postmortem.js"; +import { attach as attachChatter } from "./persona/chatter.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); +// Knowledge subsystem boots in the background. If better-sqlite3 isn't +// installed the call returns false and every knowledge API becomes a +// safe no-op. See docs/v0.2.0-self-learning.md. +initKnowledge({ stateDir }).catch((e) => warn("knowledge", `init failed: ${e?.message ?? e}`)); + // Auto-escalation tunables. With tick=3s, 20 noops ≈ 1 minute idle before we // even consider asking Pi. Cooldown prevents spamming the LLM when the bot // is permanently stuck on the same situation. @@ -81,10 +90,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 +122,8 @@ const reflexCtx = { dispatch: dispatchAction, journal: worldJournal, memory: scenarioMemory, + metrics: skillMetrics, + owned: ownedBlocks, }; let chatTimestamps = []; @@ -186,6 +201,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 +236,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 +262,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 +297,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, @@ -647,6 +666,11 @@ function connect() { pathWatchdog = createPathfinderWatchdog(bot); info("pathfinder", "stuck-replan watchdog armed"); } catch (e) { warn("pathfinder", `watchdog start failed: ${e?.message ?? e}`); } + // v0.2.0 — self-learning coach + persona narration. Both are + // import-safe; they just attach listeners and (for coach) a periodic + // Pi-drain timer. See docs/v0.2.0-self-learning.md. + try { attachCoach(bot, { stateDir, askPi }); } catch (e) { warn("coach", `attach: ${e?.message ?? e}`); } + try { attachChatter(bot, { getSnapshot: () => lastSnapshot }); } catch (e) { warn("persona", `attach: ${e?.message ?? e}`); } }); bot.on("messagestr", (text) => { diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js new file mode 100644 index 0000000..0b63bbc --- /dev/null +++ b/runtime/coach/advice.js @@ -0,0 +1,79 @@ +// coach/advice.js — turn knowledge.lessons into actionable dispatch overrides. +// +// The reflex chain calls consult() right before it would dispatch a +// planned skill. If a high-confidence lesson in the knowledge DB says +// "avoid that skill in this situation", we either swap in the lesson's +// preferred alternative or back off (which the curriculum reflex +// translates into wander / cooldown). +// +// This is the closing of the learning loop: post-mortem → lesson → +// recall → behavioural change. Without this, the DB is just a log. + +import { isAvailable as knowledgeAvailable, topAdvice, markApplied } from "../knowledge/index.js"; +import { info } from "../log.js"; + +// Skills we will not blindly swap into — they require their own +// preconditions (e.g. survive.flee needs a known threat direction). +// The dispatcher will still run runSkill on them, which performs the +// real precondition check. +const SAFE_OVERRIDES = new Set([ + "survive.flee", + "survive.sleep", + "survive.eat", + "recovery.tunnel-out", + "explore.far", + "explore.wander", + "village.build-shelter", +]); + +/** + * consult({ plannedSkillId, snapshot }) + * → { action: 'override'|'avoid'|'proceed', overrideSkillId?, lessonId?, lesson? } + * + * 'override' — dispatch overrideSkillId instead of plannedSkillId + * 'avoid' — don't dispatch plannedSkillId; caller falls back to wander/idle + * 'proceed' — no high-confidence lesson applies; dispatch as planned + */ +export function consult({ plannedSkillId, snapshot } = {}) { + if (!knowledgeAvailable()) return PROCEED; + if (!plannedSkillId) return PROCEED; + const hostile = snapshot?.closestHostile?.name ?? snapshot?.threats?.[0]?.name ?? null; + const situation = snapshot?.situationHash ?? null; + const advice = topAdvice({ + skill: plannedSkillId, + hostile, + situation, + }); + if (!advice.lessonId) return PROCEED; + + // avoid_skill matches? + if (advice.avoid && advice.avoid === plannedSkillId) { + if (advice.prefer && SAFE_OVERRIDES.has(advice.prefer)) { + info("coach", `advice: override ${plannedSkillId} → ${advice.prefer} (lesson #${advice.lessonId})`); + return { + action: "override", + overrideSkillId: advice.prefer, + lessonId: advice.lessonId, + lesson: advice.lesson, + }; + } + info("coach", `advice: avoid ${plannedSkillId} (lesson #${advice.lessonId})`); + return { action: "avoid", lessonId: advice.lessonId, lesson: advice.lesson }; + } + return PROCEED; +} + +/** + * After the dispatcher runs the (possibly overridden) skill, call this + * with the lesson id and whether the outcome was good. Increments the + * lesson's applied/succeeded counters and nudges its confidence. + */ +export function reportOutcome({ lessonId, succeeded }) { + if (!lessonId) return; + markApplied(lessonId, { succeeded: !!succeeded }); +} + +const PROCEED = Object.freeze({ action: "proceed", lessonId: null, lesson: null }); + +// Test exports +export const __testing = { SAFE_OVERRIDES }; diff --git a/runtime/coach/advice.test.js b/runtime/coach/advice.test.js new file mode 100644 index 0000000..8b0d47d --- /dev/null +++ b/runtime/coach/advice.test.js @@ -0,0 +1,97 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { initKnowledge, record } from "../knowledge/index.js"; +import { __resetForTests, isAvailable, closeStore } from "../knowledge/store.js"; +import { consult, reportOutcome, __testing } from "./advice.js"; + +const { SAFE_OVERRIDES } = __testing; + +async function bootstrap() { + __resetForTests(); + const tmp = mkdtempSync(join(tmpdir(), "pepa-advice-test-")); + await initKnowledge({ stateDir: tmp }); + return tmp; +} + +function cleanup(tmp) { + closeStore(); + try { rmSync(tmp, { recursive: true, force: true }); } catch {} +} + +test("consult: returns proceed when knowledge disabled", () => { + __resetForTests(); + const res = consult({ plannedSkillId: "gather.logs", snapshot: {} }); + assert.equal(res.action, "proceed"); +}); + +test("consult: returns proceed when no relevant lesson", async () => { + const tmp = await bootstrap(); + if (!isAvailable()) { cleanup(tmp); return; } + const res = consult({ plannedSkillId: "gather.unknown-skill", snapshot: {} }); + assert.equal(res.action, "proceed"); + cleanup(tmp); +}); + +test("consult: starter creeper rule routes attack → survive.flee", async () => { + const tmp = await bootstrap(); + if (!isAvailable()) { cleanup(tmp); return; } + const res = consult({ + plannedSkillId: "attack creeper", + snapshot: { closestHostile: { name: "creeper", distance: 4 } }, + }); + assert.equal(res.action, "override"); + assert.equal(res.overrideSkillId, "survive.flee"); + assert.ok(res.lessonId); + assert.ok(res.lesson); + cleanup(tmp); +}); + +test("consult: avoid lesson without prefer → 'avoid' action", async () => { + const tmp = await bootstrap(); + if (!isAvailable()) { cleanup(tmp); return; } + record({ + text: "Don't gather.stone — confirmed flaky.", + category: "pathing", + triggerSkill: "gather.stone", + avoidSkill: "gather.stone", + preferSkill: null, + confidence: 0.9, + source: "test", + }); + const res = consult({ plannedSkillId: "gather.stone", snapshot: {} }); + assert.equal(res.action, "avoid"); + assert.ok(res.lessonId); + cleanup(tmp); +}); + +test("consult: prefer outside SAFE_OVERRIDES set → falls to avoid", async () => { + const tmp = await bootstrap(); + if (!isAvailable()) { cleanup(tmp); return; } + record({ + text: "test fallback", + category: "combat", + triggerSkill: "gather.logs", + avoidSkill: "gather.logs", + preferSkill: "non.standard.skill", + confidence: 0.9, + source: "test", + }); + const res = consult({ plannedSkillId: "gather.logs", snapshot: {} }); + assert.equal(res.action, "avoid", "unsafe prefer falls back to avoid, not override"); + cleanup(tmp); +}); + +test("reportOutcome: no-op without lessonId", () => { + reportOutcome({ lessonId: null }); + assert.ok(true); +}); + +test("SAFE_OVERRIDES: only contains known reflex skills", () => { + for (const id of SAFE_OVERRIDES) { + assert.ok(typeof id === "string" && id.includes("."), `${id} looks like a real skill id`); + } +}); diff --git a/runtime/coach/postmortem.js b/runtime/coach/postmortem.js new file mode 100644 index 0000000..d7142b1 --- /dev/null +++ b/runtime/coach/postmortem.js @@ -0,0 +1,380 @@ +// Death post-mortem coach. +// +// On every `bot.death` event we capture the surrounding context (last +// skill, last hostile, recent log/scenario tail, inventory before/after) +// and write a row into the `deaths` table of the knowledge DB. +// +// A separate slow loop drains `unanalysedDeaths()` and asks Pi to extract +// generalised lessons. Pi calls are rate-limited and deduped — many +// near-identical deaths produce ONE lesson, not 50. +// +// Lessons land in the `lessons` table and feed runtime/knowledge/recall() +// for future skill dispatch decisions. +// +// This file is import-safe: side-effect-free, attaches only when +// `attach(bot, ctx)` is called explicitly from bot.js. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + isAvailable as knowledgeAvailable, + insertDeath, + insertPostmortem, + markDeathAnalysed, + unanalysedDeaths, + record as recordLesson, + poiNearby, +} from "../knowledge/index.js"; +import { info, warn } from "../log.js"; + +const COACH_INTERVAL_MS = 5 * 60 * 1000; // 5 min between coach passes +const COACH_BATCH_MAX = 8; // up to 8 deaths per Pi call +const COACH_PI_BUDGET_PER_HOUR = 3; // ≤ 3 Pi calls/hour +const COACH_COOLDOWN_MS = 12 * 60 * 1000; // 12 min between calls +const RECENT_CHAT_TAIL = 6; +const SCENARIO_TAIL = 12; + +let _attached = null; +let _piCallTimes = []; +let _coachTimer = null; +let _lastInventory = null; + +export function attach(bot, ctx = {}) { + if (_attached) { + warn("coach", "attach() called twice; ignoring second attach"); + return; + } + if (!bot) return; + _attached = { bot, ctx }; + + // Snapshot inventory each tick (cheap) so death captures what was lost. + bot.on?.("playerCollect", () => { _lastInventory = snapshotInv(bot); }); + bot.on?.("spawn", () => { _lastInventory = snapshotInv(bot); }); + + bot.on?.("death", () => { + try { + const death = captureDeath(bot, ctx); + if (!death) return; + const deathId = insertDeath(death); + info("coach", `death recorded id=${deathId ?? "-"} cause=${death.cause} hostile=${death.hostile ?? "?"} skill=${death.lastSkill ?? "?"}`); + } catch (e) { + warn("coach", `captureDeath failed: ${e?.message ?? e}`); + } + }); + + // Start the periodic Pi-coach drain loop. + if (ctx.askPi && !_coachTimer) { + _coachTimer = setInterval(() => { + drainOnce({ askPi: ctx.askPi, stateDir: ctx.stateDir }).catch((e) => + warn("coach", `drain error: ${e?.message ?? e}`), + ); + }, COACH_INTERVAL_MS); + _coachTimer.unref?.(); + info("coach", `attached; drain every ${COACH_INTERVAL_MS / 1000}s`); + } else { + info("coach", "attached; Pi not provided, deaths captured without postmortem analysis"); + } +} + +export function detach() { + if (_coachTimer) { + clearInterval(_coachTimer); + _coachTimer = null; + } + _attached = null; +} + +function snapshotInv(bot) { + try { + const items = bot.inventory?.items?.() ?? []; + const dict = {}; + for (const i of items) dict[i.name] = (dict[i.name] || 0) + i.count; + return dict; + } catch { + return null; + } +} + +function diffInv(before, after) { + if (!before) return null; + const lost = []; + for (const [name, count] of Object.entries(before)) { + const now = after?.[name] ?? 0; + if (now < count) lost.push({ name, count: count - now }); + } + return lost.length ? lost : null; +} + +function captureDeath(bot, ctx) { + const pos = bot.entity?.position; + const lastInv = _lastInventory; + const nowInv = snapshotInv(bot); + const inventoryLost = diffInv(lastInv, nowInv); + + const currentTask = readCurrentTask(ctx.stateDir); + const lastSkill = currentTask?.label ?? null; + const lastSkillCode = currentTask?.lastCode ?? null; + + const hostile = closestHostileName(bot); + const cause = inferCause({ bot, hostile, lastSkill, lastSkillCode }); + + const recent = readRecentScenarios(ctx.stateDir, SCENARIO_TAIL); + const journalNearby = readJournalNearby(ctx.stateDir, pos, 32); + const chatTail = ctx.chatHistory?.recent?.(RECENT_CHAT_TAIL) ?? null; + + const contextBlob = { + recentScenarios: recent, + journalNearby, + chatTail, + snapshot: { + pos, + hp: bot.health, + food: bot.food, + time: bot.time?.timeOfDay ?? null, + isRaining: !!bot.isRaining, + }, + }; + + return { + ts: Date.now(), + x: pos?.x ?? null, + y: pos?.y ?? null, + z: pos?.z ?? null, + cause, + hostile, + lastSkill, + lastSkillCode, + hp: 0, + food: bot.food ?? null, + inventoryLost, + contextBlob, + }; +} + +function closestHostileName(bot) { + try { + const me = bot.entity?.position; + if (!me) return null; + let best = null; + let bestDist = Infinity; + for (const e of Object.values(bot.entities ?? {})) { + if (!e || e === bot.entity) continue; + if (e.type !== "hostile" && e.kind !== "Hostile mobs") continue; + const d = e.position?.distanceTo?.(me) ?? Infinity; + if (d < bestDist) { + best = e.name ?? e.mobType ?? null; + bestDist = d; + } + } + return best; + } catch { + return null; + } +} + +function inferCause({ bot, hostile, lastSkill, lastSkillCode }) { + const y = bot.entity?.position?.y; + if (hostile) return "hostile"; + if (typeof bot.food === "number" && bot.food <= 0) return "starvation"; + if (typeof y === "number" && y < 30) return "fall"; + if (lastSkillCode === "drowning") return "drowning"; + if (lastSkillCode === "lava") return "lava"; + return "unknown"; +} + +function readCurrentTask(stateDir) { + if (!stateDir) return null; + const f = resolve(stateDir, "current-task.json"); + if (!existsSync(f)) return null; + try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } +} + +function readRecentScenarios(stateDir, n) { + if (!stateDir) return []; + const f = resolve(stateDir, "scenarios.jsonl"); + if (!existsSync(f)) return []; + try { + const raw = readFileSync(f, "utf8"); + const lines = raw.split("\n").filter(Boolean); + const tail = lines.slice(-n); + return tail.map((l) => { + try { return JSON.parse(l); } catch { return null; } + }).filter(Boolean); + } catch { + return []; + } +} + +function readJournalNearby(stateDir, pos, radius) { + if (!stateDir || !pos) return []; + const f = resolve(stateDir, "world-journal.jsonl"); + if (!existsSync(f)) return []; + try { + const raw = readFileSync(f, "utf8"); + const lines = raw.split("\n").filter(Boolean).slice(-200); + const out = []; + for (const l of lines) { + let row; + try { row = JSON.parse(l); } catch { continue; } + const a = row.at; + if (!a) continue; + const dx = a.x - pos.x; + const dz = a.z - pos.z; + if (dx * dx + dz * dz <= radius * radius) out.push(row); + } + return out.slice(-20); + } catch { + return []; + } +} + +/** + * One pass: take up to COACH_BATCH_MAX unanalysed deaths, summarise them + * for Pi, parse the JSON reply, write lessons + postmortems. + * + * Rate-limited: at most COACH_PI_BUDGET_PER_HOUR calls/hour, with + * COACH_COOLDOWN_MS gap between calls. + */ +export async function drainOnce({ askPi, stateDir, force = false } = {}) { + if (!knowledgeAvailable()) return { ok: false, reason: "knowledge unavailable" }; + if (!askPi) return { ok: false, reason: "no askPi" }; + + const now = Date.now(); + const hourAgo = now - 60 * 60 * 1000; + _piCallTimes = _piCallTimes.filter((t) => t > hourAgo); + if (!force && _piCallTimes.length >= COACH_PI_BUDGET_PER_HOUR) { + return { ok: false, reason: "hourly budget exhausted", calls: _piCallTimes.length }; + } + if (!force && _piCallTimes.length > 0 && now - _piCallTimes[_piCallTimes.length - 1] < COACH_COOLDOWN_MS) { + return { ok: false, reason: "cooldown" }; + } + + const pending = unanalysedDeaths({ limit: COACH_BATCH_MAX }); + if (pending.length === 0) return { ok: true, analysed: 0 }; + + const prompt = buildPrompt(pending); + _piCallTimes.push(now); + + const reply = await askPiOnce({ askPi, prompt }); + if (!reply) return { ok: false, reason: "no reply" }; + + const parsed = extractJson(reply); + if (!parsed) { + warn("coach", "Pi reply was not parseable JSON"); + return { ok: false, reason: "bad reply" }; + } + + let lessonsCount = 0; + for (const item of asArray(parsed.lessons ?? parsed)) { + if (!item || !item.lesson) continue; + recordLesson({ + text: item.lesson, + category: item.category ?? "survival", + triggerSkill: item.trigger_skill ?? null, + triggerHostile: item.trigger_hostile ?? null, + triggerSituation: item.trigger_situation ?? null, + avoidSkill: item.avoid_skill ?? null, + preferSkill: item.prefer_skill ?? null, + confidence: clamp(Number(item.confidence) || 0.6, 0.1, 0.95), + source: "pi-coach", + sourceRef: item.source_ref ?? null, + }); + lessonsCount += 1; + } + + // Write one postmortem per death; if Pi grouped them, share the same lesson. + const groupLesson = parsed.lessons?.[0]?.lesson ?? parsed.lesson ?? null; + for (const d of pending) { + insertPostmortem({ + deathId: d.id, + cause: parsed.cause ?? d.cause, + lesson: groupLesson, + nextAction: parsed.next_action ?? null, + rawResponse: reply.slice(0, 4000), + source: "pi", + }); + markDeathAnalysed(d.id); + } + + info("coach", `drain: analysed ${pending.length} deaths → ${lessonsCount} lessons`); + return { ok: true, analysed: pending.length, lessons: lessonsCount }; +} + +function buildPrompt(deaths) { + const summary = deaths.map((d) => { + const ctx = safeParse(d.context_blob); + const tail = ctx?.recentScenarios ?? []; + const tailFmt = tail.slice(-6).map((s) => ` - ${s.skillId} ${s.code}`).join("\n"); + return [ + `death id=${d.id} ts=${new Date(d.ts).toISOString()}`, + ` position: (${Math.round(d.x ?? 0)}, ${Math.round(d.y ?? 0)}, ${Math.round(d.z ?? 0)})`, + ` cause: ${d.cause}`, + ` hostile: ${d.hostile ?? "(none)"}`, + ` last skill: ${d.last_skill ?? "(none)"} (code: ${d.last_skill_code ?? "?"})`, + ` hp at death: 0 food: ${d.food_at_death ?? "?"}`, + tailFmt ? ` recent dispatches:\n${tailFmt}` : null, + ].filter(Boolean).join("\n"); + }).join("\n\n"); + + return [ + "You are reviewing recent deaths of an autonomous Minecraft survival bot (pepa).", + "The bot is trying to gather wood, craft tools, build a small village, and survive nights.", + "It's currently dying repeatedly. Your job: extract 1-3 short, generalised lessons it can apply on respawn.", + "", + "DEATHS:", + summary, + "", + "Reply with ONE JSON object (no prose, no markdown fences):", + '{ "cause": "", "next_action": "",', + ' "lessons": [', + ' { "lesson": "...", "category": "combat|pathing|crafting|survival|social",', + ' "trigger_skill": "",', + ' "trigger_hostile": "",', + ' "avoid_skill": "",', + ' "prefer_skill": "",', + ' "confidence": 0.7 }', + ' ] }', + "", + "Keep each lesson under 30 words. Be specific (e.g., \"attack creeper with fists\" rather than \"don't fight\").", + ].join("\n"); +} + +function askPiOnce({ askPi, prompt }) { + return new Promise((resolve) => { + let buf = ""; + try { + askPi({ + prompt, + onChunk: ({ stream, text }) => { + if (stream === "stdout") buf += text; + }, + onDone: () => resolve(buf), + }); + } catch (e) { + warn("coach", `askPi failed: ${e?.message ?? e}`); + resolve(null); + } + }); +} + +function extractJson(text) { + if (!text) return null; + // Try to find a JSON object somewhere in the reply. + const cleaned = text.trim().replace(/^```(?:json)?/, "").replace(/```$/, "").trim(); + try { return JSON.parse(cleaned); } catch {} + const m = cleaned.match(/\{[\s\S]*\}/); + if (!m) return null; + try { return JSON.parse(m[0]); } catch { return null; } +} + +function asArray(v) { + if (Array.isArray(v)) return v; + if (v && typeof v === "object") return [v]; + return []; +} + +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } +function safeParse(s) { try { return JSON.parse(s); } catch { return null; } } + +// Test-only exports +export const __testing = { captureDeath, buildPrompt, extractJson, inferCause }; diff --git a/runtime/coach/postmortem.test.js b/runtime/coach/postmortem.test.js new file mode 100644 index 0000000..627ca22 --- /dev/null +++ b/runtime/coach/postmortem.test.js @@ -0,0 +1,154 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { initKnowledge, unanalysedDeaths, recall } from "../knowledge/index.js"; +import { closeStore, __resetForTests, isAvailable } from "../knowledge/store.js"; +import { attach, detach, drainOnce, __testing } from "./postmortem.js"; + +const { captureDeath, extractJson, inferCause, buildPrompt } = __testing; + +function mockBot({ pos = { x: 100, y: 64, z: 200 }, hp = 0, food = 14, entities = {} } = {}) { + const handlers = {}; + return { + entity: { position: { ...pos, distanceTo(o) { return Math.hypot(pos.x - o.x, pos.z - o.z); } } }, + health: hp, + food, + time: { timeOfDay: 18000 }, + entities, + isRaining: false, + inventory: { items: () => [] }, + on(ev, fn) { handlers[ev] = fn; }, + emit(ev, payload) { handlers[ev]?.(payload); }, + }; +} + +test("inferCause: hostile present → 'hostile'", () => { + assert.equal(inferCause({ bot: { food: 20, entity: { position: { y: 64 } } }, hostile: "creeper" }), "hostile"); + assert.equal(inferCause({ bot: { food: 0, entity: { position: { y: 64 } } }, hostile: null }), "starvation"); + assert.equal(inferCause({ bot: { food: 20, entity: { position: { y: 20 } } }, hostile: null }), "fall"); + assert.equal(inferCause({ bot: { food: 20, entity: { position: { y: 64 } } }, hostile: null }), "unknown"); +}); + +test("extractJson: tolerates fences and surrounding text", () => { + assert.deepEqual(extractJson('```json\n{"a": 1}\n```'), { a: 1 }); + assert.deepEqual(extractJson('Reply: {"a": 2} done'), { a: 2 }); + assert.equal(extractJson("not json"), null); + assert.equal(extractJson(""), null); +}); + +test("buildPrompt: includes all death rows and JSON schema hint", () => { + const rows = [ + { id: 1, ts: Date.now(), x: 100, y: 64, z: 200, cause: "hostile", hostile: "creeper", last_skill: "gather.logs", last_skill_code: "timeout", food_at_death: 14, context_blob: JSON.stringify({ recentScenarios: [{ skillId: "gather.logs", code: "timeout" }] }) }, + { id: 2, ts: Date.now(), x: 102, y: 64, z: 201, cause: "hostile", hostile: "creeper", last_skill: "explore.far", last_skill_code: "done", food_at_death: 12, context_blob: null }, + ]; + const prompt = buildPrompt(rows); + assert.match(prompt, /death id=1/); + assert.match(prompt, /death id=2/); + assert.match(prompt, /creeper/); + assert.match(prompt, /Reply with ONE JSON object/); +}); + +test("captureDeath: builds a row with context blob and inferred cause", () => { + const bot = mockBot({ entities: { + 1: { type: "hostile", name: "zombie", position: { x: 101, y: 64, z: 200, distanceTo: (p) => Math.hypot(101 - p.x, 200 - p.z) } }, + }}); + const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-")); + writeFileSync(join(stateDir, "current-task.json"), JSON.stringify({ label: "gather.logs", lastCode: "timeout" })); + const death = captureDeath(bot, { stateDir }); + assert.equal(death.cause, "hostile"); + assert.equal(death.hostile, "zombie"); + assert.equal(death.lastSkill, "gather.logs"); + assert.equal(death.lastSkillCode, "timeout"); + assert.equal(death.x, 100); + assert.ok(death.contextBlob.snapshot); + rmSync(stateDir, { recursive: true, force: true }); +}); + +test("attach + emit('death'): inserts row in knowledge DB", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-")); + __resetForTests(); + await initKnowledge({ stateDir }); + if (!isAvailable()) { + // Without sqlite the no-op contract is enough. + assert.ok(true); + rmSync(stateDir, { recursive: true, force: true }); + return; + } + const bot = mockBot(); + attach(bot, { stateDir }); + bot.emit("death"); + // Insert is sync. + const pending = unanalysedDeaths({ limit: 10 }); + assert.ok(pending.length >= 1, "death row inserted"); + detach(); + closeStore(); + rmSync(stateDir, { recursive: true, force: true }); +}); + +test("drainOnce: respects budget and parses Pi reply", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-")); + __resetForTests(); + await initKnowledge({ stateDir }); + if (!isAvailable()) { + assert.ok(true); + rmSync(stateDir, { recursive: true, force: true }); + return; + } + // Seed one unanalysed death. + const bot = mockBot(); + attach(bot, { stateDir }); + bot.emit("death"); + detach(); + + const lessonsBefore = recall({ category: "combat" }).length; + + const fakeReply = JSON.stringify({ + cause: "creeper_explosion_unarmed", + next_action: "shelter at dusk", + lessons: [{ + lesson: "Stop attacking creepers without armour; dig down 2 instead.", + category: "combat", + trigger_hostile: "creeper", + avoid_skill: "attack creeper", + prefer_skill: "survive.flee", + confidence: 0.85, + }], + }); + const askPi = ({ onChunk, onDone }) => { + onChunk({ stream: "stdout", text: fakeReply }); + onDone({ code: 0 }); + }; + + const result = await drainOnce({ askPi, stateDir, force: true }); + assert.equal(result.ok, true); + assert.equal(result.analysed, 1); + assert.equal(result.lessons, 1); + + const after = recall({ hostile: "creeper", category: "combat" }); + assert.ok(after.length > lessonsBefore, "new lesson recorded"); + + const stillPending = unanalysedDeaths({ limit: 10 }); + assert.equal(stillPending.length, 0, "death marked analysed"); + + closeStore(); + rmSync(stateDir, { recursive: true, force: true }); +}); + +test("drainOnce: empty queue → ok with 0 analysed", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-")); + __resetForTests(); + await initKnowledge({ stateDir }); + if (!isAvailable()) { + assert.ok(true); + rmSync(stateDir, { recursive: true, force: true }); + return; + } + const result = await drainOnce({ askPi: () => {}, stateDir, force: true }); + assert.equal(result.ok, true); + assert.equal(result.analysed, 0); + closeStore(); + rmSync(stateDir, { recursive: true, force: true }); +}); diff --git a/runtime/config.js b/runtime/config.js index e362f4d..2afe768 100644 --- a/runtime/config.js +++ b/runtime/config.js @@ -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); diff --git a/runtime/curriculum.js b/runtime/curriculum.js index b967a2b..df09b55 100644 --- a/runtime/curriculum.js +++ b/runtime/curriculum.js @@ -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", diff --git a/runtime/curriculum.test.js b/runtime/curriculum.test.js index 80d8ecf..3a8ac9f 100644 --- a/runtime/curriculum.test.js +++ b/runtime/curriculum.test.js @@ -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"); +}); diff --git a/runtime/knowledge/index.js b/runtime/knowledge/index.js new file mode 100644 index 0000000..94518b2 --- /dev/null +++ b/runtime/knowledge/index.js @@ -0,0 +1,233 @@ +// Public surface of the knowledge subsystem. Other runtime modules should +// import from here, not from store/seed/lessons directly. +// +// Wire-up: +// await initKnowledge({ stateDir }) +// - opens the SQLite DB at state//knowledge.db +// - applies schema +// - seeds starter recipes/mobs/blocks/lessons (idempotent) +// isAvailable() — true once init succeeded +// +// All other helpers degrade gracefully when the store is unavailable +// (e.g. fresh checkout without `npm install`). + +export { isAvailable, disabledReason, getStore, closeStore, runMaintenance } from "./store.js"; +export { recall, record, markApplied, topAdvice } from "./lessons.js"; + +import { ensureStore, isAvailable as _isAvailable } from "./store.js"; +import { seed } from "./seed.js"; +import { warn, info } from "../log.js"; + +let _initialised = false; + +export async function initKnowledge({ stateDir } = {}) { + if (_initialised) return _isAvailable(); + _initialised = true; + const db = await ensureStore({ stateDir }); + if (!db) { + warn("knowledge", "init: store not available; knowledge layer will be a no-op"); + return false; + } + const seedResult = seed(); + if (!seedResult.ok) { + warn("knowledge", `init: seed step failed (${seedResult.reason})`); + } + return true; +} + +// Death/postmortem helpers — separate file would be overkill; they share +// the store and are only called from coach/postmortem.js. +import { getStore as _getStore } from "./store.js"; + +export function insertDeath({ ts, x, y, z, cause, hostile, lastSkill, lastSkillCode, + hp, food, inventoryLost, contextBlob } = {}) { + if (!_isAvailable()) return null; + try { + const stmt = _getStore().prepare(` + INSERT INTO deaths (ts, x, y, z, cause, hostile, last_skill, last_skill_code, + hp_at_death, food_at_death, inventory_lost, context_blob, analysed) + VALUES (@ts, @x, @y, @z, @cause, @hostile, @lastSkill, @lastSkillCode, + @hp, @food, @inventoryLost, @contextBlob, 0) + `); + const res = stmt.run({ + ts: ts ?? Date.now(), + x: x ?? null, y: y ?? null, z: z ?? null, + cause: cause ?? "unknown", + hostile: hostile ?? null, + lastSkill: lastSkill ?? null, + lastSkillCode: lastSkillCode ?? null, + hp: hp ?? null, + food: food ?? null, + inventoryLost: inventoryLost ? JSON.stringify(inventoryLost) : null, + contextBlob: contextBlob ? JSON.stringify(contextBlob) : null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `insertDeath failed: ${e?.message ?? e}`); + return null; + } +} + +export function unanalysedDeaths({ limit = 5 } = {}) { + if (!_isAvailable()) return []; + try { + return _getStore().prepare(` + SELECT * FROM deaths WHERE analysed = 0 ORDER BY ts ASC LIMIT @limit + `).all({ limit }); + } catch (e) { + warn("knowledge", `unanalysedDeaths failed: ${e?.message ?? e}`); + return []; + } +} + +export function markDeathAnalysed(deathId) { + if (!_isAvailable()) return; + try { + _getStore().prepare("UPDATE deaths SET analysed = 1 WHERE id = ?").run(deathId); + } catch (e) { + warn("knowledge", `markDeathAnalysed failed: ${e?.message ?? e}`); + } +} + +export function insertPostmortem({ deathId, cause, lesson, nextAction, rawResponse, source = "pi" } = {}) { + if (!_isAvailable() || !deathId) return null; + try { + const res = _getStore().prepare(` + INSERT INTO postmortems (death_id, ts, cause, lesson, next_action, raw_response, source) + VALUES (@deathId, @ts, @cause, @lesson, @nextAction, @rawResponse, @source) + `).run({ + deathId, + ts: Date.now(), + cause: cause ?? null, + lesson: lesson ?? null, + nextAction: nextAction ?? null, + rawResponse: rawResponse ?? null, + source, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `insertPostmortem failed: ${e?.message ?? e}`); + return null; + } +} + +// Recipe / mob / block lookups +export function lookupRecipe(name) { + if (!_isAvailable()) return null; + try { + const row = _getStore().prepare(`SELECT * FROM recipes WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, shape: safeParse(row.shape) }; + } catch (e) { + warn("knowledge", `lookupRecipe failed: ${e?.message ?? e}`); + return null; + } +} + +export function lookupMob(name) { + if (!_isAvailable() || !name) return null; + try { + const row = _getStore().prepare(`SELECT * FROM mob_intel WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, drops: safeParse(row.drops) }; + } catch (e) { + warn("knowledge", `lookupMob failed: ${e?.message ?? e}`); + return null; + } +} + +export function lookupBlock(name) { + if (!_isAvailable() || !name) return null; + try { + const row = _getStore().prepare(`SELECT * FROM block_intel WHERE name = ?`).get(name); + if (!row) return null; + return { ...row, drops: safeParse(row.drops) }; + } catch (e) { + warn("knowledge", `lookupBlock failed: ${e?.message ?? e}`); + return null; + } +} + +// POI helpers — spatially-keyed long-term memory. +const CELL = 16; + +export function recordPOI({ kind, name, x, y, z, expiresAt, notes } = {}) { + if (!_isAvailable() || typeof x !== "number" || typeof z !== "number") return null; + try { + const stmt = _getStore().prepare(` + INSERT INTO poi (kind, name, x, y, z, cell_x, cell_z, ts, expires_at, notes) + VALUES (@kind, @name, @x, @y, @z, @cellX, @cellZ, @ts, @expiresAt, @notes) + `); + const cellX = Math.floor(x / CELL); + const cellZ = Math.floor(z / CELL); + const res = stmt.run({ + kind, name: name ?? null, + x, y: y ?? 0, z, + cellX, cellZ, + ts: Date.now(), + expiresAt: expiresAt ?? null, + notes: notes ?? null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `recordPOI failed: ${e?.message ?? e}`); + return null; + } +} + +export function poiNearby({ x, z, kind, radius = 64, limit = 8 } = {}) { + if (!_isAvailable() || typeof x !== "number" || typeof z !== "number") return []; + try { + const cellX = Math.floor(x / CELL); + const cellZ = Math.floor(z / CELL); + const cellRadius = Math.ceil(radius / CELL); + const sql = ` + SELECT *, ((x - @x) * (x - @x) + (z - @z) * (z - @z)) AS dist2 + FROM poi + WHERE cell_x BETWEEN @cxLo AND @cxHi + AND cell_z BETWEEN @czLo AND @czHi + ${kind ? "AND kind = @kind" : ""} + AND (expires_at IS NULL OR expires_at > @now) + ORDER BY dist2 ASC + LIMIT @limit + `; + return _getStore().prepare(sql).all({ + x, z, + cxLo: cellX - cellRadius, cxHi: cellX + cellRadius, + czLo: cellZ - cellRadius, czHi: cellZ + cellRadius, + kind: kind ?? null, + now: Date.now(), + limit, + }).filter((r) => r.dist2 <= radius * radius); + } catch (e) { + warn("knowledge", `poiNearby failed: ${e?.message ?? e}`); + return []; + } +} + +// Chat log +export function logChat({ direction, speaker, text, intent, repliedWith } = {}) { + if (!_isAvailable() || !text) return null; + try { + const res = _getStore().prepare(` + INSERT INTO chat_log (ts, direction, speaker, text, intent, replied_with) + VALUES (@ts, @direction, @speaker, @text, @intent, @repliedWith) + `).run({ + ts: Date.now(), + direction: direction ?? "in", + speaker: speaker ?? null, + text, + intent: intent ?? null, + repliedWith: repliedWith ?? null, + }); + return res.lastInsertRowid; + } catch (e) { + warn("knowledge", `logChat failed: ${e?.message ?? e}`); + return null; + } +} + +function safeParse(s) { + if (!s) return null; + try { return JSON.parse(s); } catch { return null; } +} diff --git a/runtime/knowledge/knowledge.test.js b/runtime/knowledge/knowledge.test.js new file mode 100644 index 0000000..43aa731 --- /dev/null +++ b/runtime/knowledge/knowledge.test.js @@ -0,0 +1,224 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + initKnowledge, + isAvailable, + disabledReason, + lookupRecipe, + lookupMob, + lookupBlock, + recall, + record, + markApplied, + topAdvice, + insertDeath, + unanalysedDeaths, + markDeathAnalysed, + insertPostmortem, + recordPOI, + poiNearby, + logChat, +} from "./index.js"; +import { __resetForTests, closeStore } from "./store.js"; + +// All tests share one DB in a tmp dir per run. The first test bootstraps, +// later tests assume init has happened. When `better-sqlite3` is not +// installed, `isAvailable()` stays false and every test asserts the +// graceful no-op contract instead. + +const tmp = mkdtempSync(join(tmpdir(), "pepa-knowledge-test-")); +let bootstrapped = false; + +async function bootstrap() { + if (bootstrapped) return; + __resetForTests(); + await initKnowledge({ stateDir: tmp }); + bootstrapped = true; +} + +test("init: opens store or stays disabled gracefully", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.match(disabledReason() ?? "", /better-sqlite3|store/i, + "when unavailable, disabledReason should explain why"); + return; // rest of suite covered by no-op assertions below + } + assert.equal(typeof isAvailable(), "boolean"); +}); + +test("seed: recipes, mobs, blocks, lessons present", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(lookupRecipe("planks"), null); + assert.equal(lookupMob("creeper"), null); + assert.equal(lookupBlock("oak_log"), null); + assert.deepEqual(recall(), []); + return; + } + const planks = lookupRecipe("planks"); + assert.ok(planks, "planks recipe seeded"); + assert.equal(planks.yields, 4); + + const creeper = lookupMob("creeper"); + assert.ok(creeper, "creeper intel seeded"); + assert.equal(creeper.threat_level, 5); + assert.equal(creeper.verdict_no_weapon, "flee"); + + const oak = lookupBlock("oak_log"); + assert.ok(oak, "oak_log intel seeded"); + assert.equal(oak.required_tool, "axe"); + + const lessons = recall(); + assert.ok(lessons.length >= 5, `expected ≥5 starter lessons, got ${lessons.length}`); +}); + +test("recall: filter by hostile narrows results", async () => { + await bootstrap(); + if (!isAvailable()) return; + const all = recall(); + const creeperLessons = recall({ hostile: "creeper" }); + assert.ok(creeperLessons.length > 0, "creeper-specific lessons exist"); + assert.ok(creeperLessons.every( + (l) => l.trigger_hostile === null || l.trigger_hostile === "creeper", + ), "filter excludes other hostiles"); + assert.ok(creeperLessons.length <= all.length); +}); + +test("record: insert custom lesson, retrievable by category", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(record({ text: "noop", category: "combat" }).ok, false); + return; + } + const { ok, id } = record({ + text: "Stop attacking creepers with fists — confirmed 30 deaths in spawn area.", + category: "combat", + triggerHostile: "creeper", + avoidSkill: "attack creeper", + preferSkill: "survive.flee", + confidence: 0.8, + source: "test", + }); + assert.equal(ok, true); + assert.ok(typeof id === "number" || typeof id === "bigint"); + + const lessons = recall({ hostile: "creeper", category: "combat" }); + assert.ok(lessons.some((l) => l.id === Number(id))); +}); + +test("markApplied: increments counters, adjusts confidence", async () => { + await bootstrap(); + if (!isAvailable()) return; + const { id } = record({ + text: "test-applied-lesson", category: "pathing", confidence: 0.5, source: "test", + }); + markApplied(id, { succeeded: true }); + markApplied(id, { succeeded: true }); + markApplied(id, { succeeded: false }); + const found = recall({ category: "pathing" }).find((l) => l.id === Number(id)); + assert.ok(found, "lesson retrievable after marks"); + assert.equal(found.applied_count, 3); + assert.equal(found.succeeded_count, 2); + assert.ok(found.confidence > 0.5, "two successes outweighed one failure"); +}); + +test("topAdvice: returns null when no high-confidence lesson matches", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.deepEqual(topAdvice({ hostile: "creeper" }), { avoid: null, prefer: null, lessonId: null, lesson: null }); + return; + } + // Starter rule for creeper is confidence 0.95, with avoid + prefer set. + const advice = topAdvice({ hostile: "creeper" }); + assert.equal(advice.avoid, "attack creeper"); + assert.equal(advice.prefer, "survive.flee"); + assert.ok(advice.lesson); + + // Unrelated mob → no specific advice usually. + const noneAdvice = topAdvice({ hostile: "rabbit" }); + // Either no advice OR a generic lesson without avoid/prefer set. Both fine. + if (noneAdvice.avoid || noneAdvice.prefer) { + assert.ok(typeof noneAdvice.lesson === "string"); + } +}); + +test("death + postmortem round-trip", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(insertDeath({ ts: 1, x: 0, y: 0, z: 0 }), null); + return; + } + const deathId = insertDeath({ + ts: Date.now(), + x: 100, y: 64, z: 200, + cause: "hostile", + hostile: "creeper", + lastSkill: "gather.logs", + lastSkillCode: "timeout", + hp: 0, + food: 14, + inventoryLost: [{ name: "oak_log", count: 4 }], + contextBlob: { lastTicks: ["wandered E", "noticed creeper at 6m", "boom"] }, + }); + assert.ok(deathId); + + const pending = unanalysedDeaths({ limit: 10 }); + assert.ok(pending.some((d) => d.id === Number(deathId))); + + const pmId = insertPostmortem({ + deathId, + cause: "creeper_explosion_in_open", + lesson: "Don't gather logs at night without armor.", + nextAction: "shelter, then gather at dawn", + rawResponse: '{"cause":"creeper"}', + }); + assert.ok(pmId); + + markDeathAnalysed(deathId); + const stillPending = unanalysedDeaths({ limit: 10 }); + assert.ok(!stillPending.some((d) => d.id === Number(deathId))); +}); + +test("poi: insert + nearby query", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(recordPOI({ kind: "tree", x: 0, y: 64, z: 0 }), null); + assert.deepEqual(poiNearby({ x: 0, z: 0 }), []); + return; + } + recordPOI({ kind: "tree", x: 100, y: 64, z: 100, notes: "oak cluster" }); + recordPOI({ kind: "tree", x: 110, y: 64, z: 102 }); + recordPOI({ kind: "tree", x: 500, y: 64, z: 500 }); + recordPOI({ kind: "danger", x: 100, y: 64, z: 100, notes: "creeper spawned here" }); + + const near = poiNearby({ x: 100, z: 100, kind: "tree", radius: 32 }); + assert.equal(near.length, 2); + assert.ok(near[0].dist2 < 200, "nearest first"); + + const far = poiNearby({ x: 100, z: 100, kind: "tree", radius: 8 }); + assert.equal(far.length, 1, "radius 8 excludes the second tree at (110,102)"); + + const danger = poiNearby({ x: 100, z: 100, kind: "danger", radius: 32 }); + assert.equal(danger.length, 1); +}); + +test("chat log: append + select", async () => { + await bootstrap(); + if (!isAvailable()) { + assert.equal(logChat({ text: "hi", speaker: "alice" }), null); + return; + } + const id1 = logChat({ direction: "in", speaker: "alice", text: "привет", intent: "GREETING" }); + const id2 = logChat({ direction: "out", text: "yo", repliedWith: "template" }); + assert.ok(id1 && id2); +}); + +// Cleanup: close DB and remove tmp dir. +test("teardown", () => { + closeStore(); + try { rmSync(tmp, { recursive: true, force: true }); } catch {} +}); diff --git a/runtime/knowledge/lessons.js b/runtime/knowledge/lessons.js new file mode 100644 index 0000000..0c12afd --- /dev/null +++ b/runtime/knowledge/lessons.js @@ -0,0 +1,178 @@ +// Lesson recall, recording, and outcome tracking. +// +// A "lesson" is a generalised rule the bot has learned: "don't attack +// creepers with fists", "gather.logs timeouts here, move on", "fight +// skeletons under cover only". Recall is best-effort: returns the top-K +// lessons matching the situation, sorted by confidence × recency. +// +// The dispatch path uses recall() to ALTER its planned action — see +// runtime/coach/advice.js. Lessons are immutable rows once written; the +// applied / succeeded counters and confidence are updated separately. + +import { isAvailable, getStore } from "./store.js"; +import { warn } from "../log.js"; + +const RECALL_DEFAULT_LIMIT = 8; +const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // a week — older lessons score lower + +/** + * recall({ skill?, hostile?, situation?, category?, limit? }) + * → Lesson[] + * + * Best-match lessons in confidence order with light recency boost. + * Any missing filter widens the search; passing none returns the most + * confident recent lessons. + */ +export function recall({ skill, hostile, situation, category, limit = RECALL_DEFAULT_LIMIT } = {}) { + if (!isAvailable()) return []; + const db = getStore(); + const conds = []; + const params = {}; + if (skill) { conds.push("(trigger_skill IS NULL OR trigger_skill = @skill)"); params.skill = skill; } + if (hostile) { conds.push("(trigger_hostile IS NULL OR trigger_hostile = @hostile)"); params.hostile = hostile; } + if (situation) { conds.push("(trigger_situation IS NULL OR trigger_situation = @situation)"); params.situation = situation; } + if (category) { conds.push("category = @category"); params.category = category; } + const where = conds.length ? "WHERE " + conds.join(" AND ") : ""; + try { + const rows = db.prepare(` + SELECT id, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref, ts + FROM lessons + ${where} + ORDER BY confidence DESC, ts DESC + LIMIT @limit + `).all({ ...params, limit }); + return rows.map(scoreLesson).sort((a, b) => b._score - a._score); + } catch (e) { + warn("knowledge", `recall failed: ${e?.message ?? e}`); + return []; + } +} + +function scoreLesson(row) { + const ageMs = Math.max(0, Date.now() - (row.ts ?? 0)); + const recency = ageMs < RECENCY_WINDOW_MS + ? 1 - ageMs / RECENCY_WINDOW_MS + : 0; + const applied = row.applied_count ?? 0; + const succeeded = row.succeeded_count ?? 0; + // Reward lessons that have been applied successfully. + const validation = applied > 0 ? succeeded / applied : 0; + row._score = row.confidence * 0.6 + recency * 0.2 + validation * 0.2; + return row; +} + +/** + * record({ text, category, ... }) + * → { ok, id } + * + * Insert a new lesson. Does NOT dedupe; callers should check recall() + * first if dedupe matters. (For Pi-extracted lessons, near-duplicates + * are fine — variety helps recall.) + */ +export function record({ + text, + category = "survival", + triggerSkill = null, + triggerHostile = null, + triggerSituation = null, + avoidSkill = null, + preferSkill = null, + confidence = 0.5, + source = "pi-coach", + sourceRef = null, +} = {}) { + if (!isAvailable()) return { ok: false, reason: "store unavailable" }; + if (!text || typeof text !== "string") return { ok: false, reason: "text required" }; + try { + const stmt = getStore().prepare(` + INSERT INTO lessons (ts, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref) + VALUES (@ts, @text, @category, @triggerSkill, @triggerHostile, @triggerSituation, + @avoidSkill, @preferSkill, @confidence, 0, 0, @source, @sourceRef) + `); + const info = stmt.run({ + ts: Date.now(), + text, + category, + triggerSkill, + triggerHostile, + triggerSituation, + avoidSkill, + preferSkill, + confidence: Math.max(0, Math.min(1, confidence)), + source, + sourceRef, + }); + return { ok: true, id: info.lastInsertRowid }; + } catch (e) { + warn("knowledge", `record failed: ${e?.message ?? e}`); + return { ok: false, reason: e?.message ?? String(e) }; + } +} + +/** + * markApplied(id, { succeeded }) + * Bumps applied_count, and if succeeded=true, succeeded_count too. + * Adjusts confidence: success increases it slightly, failure decreases. + */ +export function markApplied(id, { succeeded = false } = {}) { + if (!isAvailable()) return false; + try { + const stmt = getStore().prepare(` + UPDATE lessons SET + applied_count = applied_count + 1, + succeeded_count = succeeded_count + @suc, + confidence = MIN(1.0, MAX(0.05, confidence + @delta)) + WHERE id = @id + `); + stmt.run({ + id, + suc: succeeded ? 1 : 0, + delta: succeeded ? 0.05 : -0.03, + }); + return true; + } catch (e) { + warn("knowledge", `markApplied failed: ${e?.message ?? e}`); + return false; + } +} + +/** + * topAdvice({ skill, hostile, situation, hp, food, hasWeapon }) + * → { avoid: string | null, prefer: string | null, lessonId: number | null, lesson: string | null } + * + * Reduce recalled lessons to one actionable directive. The dispatch + * layer reads this and adjusts its plan; if no high-confidence lesson + * applies, returns empty advice and the caller proceeds as normal. + */ +export function topAdvice(ctx = {}) { + const lessons = recall({ + skill: ctx.skill, + hostile: ctx.hostile, + situation: ctx.situation, + limit: 6, + }); + for (const l of lessons) { + if (l.confidence < 0.6) break; + if (l.avoid_skill || l.prefer_skill) { + return { + avoid: l.avoid_skill ?? null, + prefer: l.prefer_skill ?? null, + lessonId: l.id, + lesson: l.text, + }; + } + } + return { avoid: null, prefer: null, lessonId: null, lesson: null }; +} + +/** Test-only helpers. Not exported through index.js. */ +export function __wipeForTests() { + if (!isAvailable()) return; + try { + getStore().exec("DELETE FROM lessons"); + } catch {} +} diff --git a/runtime/knowledge/schema.sql b/runtime/knowledge/schema.sql new file mode 100644 index 0000000..93b8c36 --- /dev/null +++ b/runtime/knowledge/schema.sql @@ -0,0 +1,181 @@ +-- pepa-pi-bot knowledge schema v1 +-- Single-process SQLite store at state//knowledge.db. +-- Idempotent: applied on every boot. Migrations go below the CREATE TABLE +-- block, gated by schema_version. + +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +---------------------------------------------------------------------- +-- Recipes (seeded from docs/minecraft-recipes.json + augmented by wiki) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + shape TEXT NOT NULL, -- JSON array of rows + shapeless INTEGER NOT NULL DEFAULT 0, + yields INTEGER NOT NULL DEFAULT 1, + requires_table INTEGER NOT NULL DEFAULT 1, -- 0=hand,1=table,2=furnace,3=smithing + source TEXT, + source_url TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_recipes_name ON recipes(name); + +---------------------------------------------------------------------- +-- Mob intel — what to do when you see a mob +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mob_intel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + hostility TEXT NOT NULL, -- 'hostile' | 'neutral' | 'passive' | 'tamable' + threat_level INTEGER NOT NULL, -- 1..5 + approach_range REAL, -- blocks at which it engages + burns_in_sun INTEGER NOT NULL DEFAULT 0, + ranged INTEGER NOT NULL DEFAULT 0, + weakness TEXT, + drops TEXT, -- JSON array of names + verdict_no_weapon TEXT, -- 'flee' | 'shelter' | 'avoid' | 'pillar' + verdict_with_sword TEXT, -- 'kite' | 'attack' | 'avoid' + notes TEXT, + source TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mob_name ON mob_intel(name); + +---------------------------------------------------------------------- +-- Block intel — what tool, what drops, lighting +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS block_intel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + required_tool TEXT, -- 'any'|'wood_pickaxe'|'stone_pickaxe'|'iron_pickaxe'|'shovel'|'axe' + drops TEXT, -- JSON array + light_emit INTEGER DEFAULT 0, + walkable INTEGER DEFAULT 1, + notes TEXT, + source TEXT, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_block_name ON block_intel(name); + +---------------------------------------------------------------------- +-- Lessons — generalised "what to do / what to avoid" learned over time +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS lessons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + text TEXT NOT NULL, + category TEXT NOT NULL, -- 'combat'|'pathing'|'crafting'|'survival'|'social'|'self-improve' + trigger_skill TEXT, + trigger_hostile TEXT, + trigger_situation TEXT, -- coarse hash key from scenario-memory + avoid_skill TEXT, + prefer_skill TEXT, + confidence REAL NOT NULL DEFAULT 0.5, + applied_count INTEGER NOT NULL DEFAULT 0, + succeeded_count INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL, -- 'postmortem'|'pi-coach'|'wiki'|'operator'|'rule' + source_ref TEXT +); +CREATE INDEX IF NOT EXISTS idx_lessons_category ON lessons(category); +CREATE INDEX IF NOT EXISTS idx_lessons_skill ON lessons(trigger_skill); +CREATE INDEX IF NOT EXISTS idx_lessons_hostile ON lessons(trigger_hostile); +CREATE INDEX IF NOT EXISTS idx_lessons_ts ON lessons(ts); + +---------------------------------------------------------------------- +-- Death events — captured by coach/postmortem.js on every death +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS deaths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + x REAL, y REAL, z REAL, + cause TEXT, -- 'hostile'|'fall'|'lava'|'drowning'|'starvation'|'suffocation'|'other'|'unknown' + hostile TEXT, + last_skill TEXT, + last_skill_code TEXT, + hp_at_death REAL, + food_at_death REAL, + inventory_lost TEXT, -- JSON + context_blob TEXT, -- JSON: last 30s of events + analysed INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_deaths_ts ON deaths(ts); +CREATE INDEX IF NOT EXISTS idx_deaths_analysed ON deaths(analysed); +CREATE INDEX IF NOT EXISTS idx_deaths_cause ON deaths(cause); + +---------------------------------------------------------------------- +-- Pi-extracted post-mortems linking back to deaths +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS postmortems ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + death_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + cause TEXT, + lesson TEXT, + next_action TEXT, + raw_response TEXT, + source TEXT, -- 'pi' | 'rule' + FOREIGN KEY (death_id) REFERENCES deaths(id) +); +CREATE INDEX IF NOT EXISTS idx_postmortems_death ON postmortems(death_id); + +---------------------------------------------------------------------- +-- Points of interest — queryable spatial memory +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS poi ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, -- 'tree'|'ore'|'water'|'mob_spawner'|'danger'|'foreign_build'|'base'|'chest' + name TEXT, + x REAL NOT NULL, y REAL NOT NULL, z REAL NOT NULL, + cell_x INTEGER NOT NULL, + cell_z INTEGER NOT NULL, + ts INTEGER NOT NULL, + expires_at INTEGER, + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_poi_cell ON poi(cell_x, cell_z); +CREATE INDEX IF NOT EXISTS idx_poi_kind ON poi(kind); + +---------------------------------------------------------------------- +-- Cached wiki pages (rc.2) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS wiki_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + body TEXT, + etag TEXT, + fetched_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +---------------------------------------------------------------------- +-- Full chat log (durable; per-speaker LRU in memory is unchanged) +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS chat_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + direction TEXT NOT NULL, -- 'in' | 'out' + speaker TEXT, + text TEXT NOT NULL, + intent TEXT, + replied_with TEXT +); +CREATE INDEX IF NOT EXISTS idx_chat_speaker ON chat_log(speaker); +CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat_log(ts); + +---------------------------------------------------------------------- +-- Self-rewrite audit +---------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS code_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + proposal_slug TEXT, + files TEXT, -- JSON array + diff_hash TEXT, + outcome TEXT, -- 'applied'|'rolled_back'|'rejected' + notes TEXT +); diff --git a/runtime/knowledge/seed.js b/runtime/knowledge/seed.js new file mode 100644 index 0000000..6137492 --- /dev/null +++ b/runtime/knowledge/seed.js @@ -0,0 +1,296 @@ +// Seed the knowledge DB with starter content shipped in the repo. +// Idempotent: only inserts rows that aren't already present (UPSERT +// keyed by `name`). +// +// Sources: +// docs/minecraft-recipes.json — recipes table +// inline MOB_INTEL / BLOCK_INTEL / STARTER_LESSONS arrays — bootstrap +// knowledge so the bot has something to consult before any wiki/ +// post-mortem run has populated the DB. +// +// Call seed() once after ensureStore(). Cheap (single transaction, +// ~50 rows). No network. No Pi. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { isAvailable, getStore } from "./store.js"; +import { info, warn } from "../log.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const RECIPES_JSON = resolve(HERE, "..", "..", "docs", "minecraft-recipes.json"); + +// Compact intel for the most common hostiles + a few passives. +// `verdict_no_weapon`: what the bot should do when caught without a sword. +// `verdict_with_sword`: what to do with at least a wooden sword. +// Sources: docs/minecraft-knowledge.md (already synthesised from wiki). +const MOB_INTEL = [ + { name: "zombie", hostility: "hostile", threat_level: 2, approach_range: 16, burns_in_sun: 1, ranged: 0, + weakness: "sunlight", drops: ["rotten_flesh","iron_ingot","carrot","potato"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Babies move fast — flee on sight even with a sword if your hp<14." }, + { name: "husk", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "water", drops: ["rotten_flesh"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Desert zombie variant; doesn't burn in daylight; inflicts hunger." }, + { name: "skeleton", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 1, ranged: 1, + weakness: "melee_in_cover", drops: ["bone","arrow","bow"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Ranged — never approach in open. Close distance only if you have a shield or terrain cover." }, + { name: "creeper", hostility: "hostile", threat_level: 5, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "knockback", drops: ["gunpowder"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Silent, explodes within 3 blocks. Keep > 5 blocks distance ALWAYS. Never fight near base." }, + { name: "spider", hostility: "neutral", threat_level: 2, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "high_ground", drops: ["string","spider_eye"], + verdict_no_weapon: "pillar", verdict_with_sword: "attack", + notes: "Climbs walls. Pillar up 2 blocks for safety. Daytime spider is neutral unless hit." }, + { name: "enderman", hostility: "neutral", threat_level: 4, approach_range: 64, burns_in_sun: 0, ranged: 0, + weakness: "water", drops: ["ender_pearl"], + verdict_no_weapon: "avoid", verdict_with_sword: "avoid", + notes: "Don't look at the head. Hostile only if provoked. Teleports — fights are unpredictable." }, + { name: "drowned", hostility: "hostile", threat_level: 3, approach_range: 16, burns_in_sun: 0, ranged: 1, + weakness: "above_water", drops: ["rotten_flesh","copper_ingot","trident","nautilus_shell"], + verdict_no_weapon: "flee", verdict_with_sword: "kite", + notes: "Trident variants ranged & deadly. Don't fight in water." }, + { name: "witch", hostility: "hostile", threat_level: 4, approach_range: 16, burns_in_sun: 0, ranged: 1, + weakness: "burst_damage", drops: ["redstone","glowstone_dust","gunpowder","sugar","stick","glass_bottle","spider_eye"], + verdict_no_weapon: "flee", verdict_with_sword: "avoid", + notes: "Throws poison/weakness potions. Avoid until iron sword + apples." }, + { name: "slime", hostility: "hostile", threat_level: 1, approach_range: 16, burns_in_sun: 0, ranged: 0, + weakness: "split_into_smaller", drops: ["slime_ball"], + verdict_no_weapon: "pillar", verdict_with_sword: "attack", + notes: "Splits when killed. Common in swamp at night." }, + { name: "phantom", hostility: "hostile", threat_level: 3, approach_range: 64, burns_in_sun: 1, ranged: 0, + weakness: "burns_in_sun", drops: ["phantom_membrane"], + verdict_no_weapon: "shelter", verdict_with_sword: "kite", + notes: "Triggered by not sleeping 3+ days. Sleep when possible." }, + { name: "cow", hostility: "passive", threat_level: 1, drops: ["beef","leather"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Hit until dead for food/leather. Breed with wheat." }, + { name: "sheep", hostility: "passive", threat_level: 1, drops: ["wool","mutton"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Shear for wool (sheep lives) or kill for mutton+wool. Breed with wheat." }, + { name: "chicken", hostility: "passive", threat_level: 1, drops: ["chicken","feather","egg"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Lays eggs every 5-10 min. Breed with seeds." }, + { name: "pig", hostility: "passive", threat_level: 1, drops: ["porkchop"], verdict_no_weapon: "attack", verdict_with_sword: "attack", notes: "Breed with carrot/potato/beetroot." }, + { name: "wolf", hostility: "neutral", threat_level: 2, drops: [], verdict_no_weapon: "avoid", verdict_with_sword: "avoid", notes: "Don't hit. Tame with bones later." }, +]; + +const BLOCK_INTEL = [ + { name: "oak_log", required_tool: "axe", drops: ["oak_log"], light_emit: 0, notes: "Any axe; fists work but slow." }, + { name: "birch_log", required_tool: "axe", drops: ["birch_log"], light_emit: 0 }, + { name: "spruce_log", required_tool: "axe", drops: ["spruce_log"], light_emit: 0 }, + { name: "dark_oak_log", required_tool: "axe", drops: ["dark_oak_log"], light_emit: 0 }, + { name: "jungle_log", required_tool: "axe", drops: ["jungle_log"], light_emit: 0 }, + { name: "acacia_log", required_tool: "axe", drops: ["acacia_log"], light_emit: 0 }, + { name: "mangrove_log", required_tool: "axe", drops: ["mangrove_log"], light_emit: 0 }, + { name: "cherry_log", required_tool: "axe", drops: ["cherry_log"], light_emit: 0 }, + { name: "stone", required_tool: "wood_pickaxe", drops: ["cobblestone"], light_emit: 0, notes: "Needs wood pickaxe minimum; otherwise drops nothing." }, + { name: "cobblestone", required_tool: "wood_pickaxe", drops: ["cobblestone"], light_emit: 0 }, + { name: "deepslate", required_tool: "wood_pickaxe", drops: ["cobbled_deepslate"], light_emit: 0 }, + { name: "coal_ore", required_tool: "wood_pickaxe", drops: ["coal"], light_emit: 0 }, + { name: "iron_ore", required_tool: "stone_pickaxe", drops: ["raw_iron"], light_emit: 0, notes: "Needs stone pickaxe; wood pickaxe drops nothing." }, + { name: "copper_ore", required_tool: "stone_pickaxe", drops: ["raw_copper"], light_emit: 0 }, + { name: "gold_ore", required_tool: "iron_pickaxe", drops: ["raw_gold"], light_emit: 0 }, + { name: "diamond_ore", required_tool: "iron_pickaxe", drops: ["diamond"], light_emit: 0 }, + { name: "redstone_ore", required_tool: "iron_pickaxe", drops: ["redstone"], light_emit: 9 }, + { name: "lapis_ore", required_tool: "stone_pickaxe", drops: ["lapis_lazuli"], light_emit: 0 }, + { name: "obsidian", required_tool: "diamond_pickaxe", drops: ["obsidian"], light_emit: 0, notes: "Diamond+ only; takes 10s+ to mine." }, + { name: "dirt", required_tool: "shovel", drops: ["dirt"], light_emit: 0, walkable: 1 }, + { name: "grass_block", required_tool: "shovel", drops: ["dirt"], light_emit: 0 }, + { name: "sand", required_tool: "shovel", drops: ["sand"], light_emit: 0, notes: "Falls with gravity — never stand under it while mining." }, + { name: "gravel", required_tool: "shovel", drops: ["gravel"], light_emit: 0, notes: "Falls with gravity." }, + { name: "torch", required_tool: "any", drops: ["torch"], light_emit: 14, walkable: 0 }, + { name: "lantern", required_tool: "wood_pickaxe", drops: ["lantern"], light_emit: 15 }, + { name: "campfire", required_tool: "axe", drops: ["charcoal"], light_emit: 15, notes: "Damages anyone walking through." }, + { name: "water", required_tool: "bucket", drops: [], light_emit: 0, walkable: 0, notes: "Use to escape mobs / hydrate farmland." }, + { name: "lava", required_tool: "bucket", drops: [], light_emit: 15, walkable: 0, notes: "Instant death. Never walk near without water bucket." }, + { name: "crafting_table",required_tool: "axe", drops: ["crafting_table"], light_emit: 0, notes: "Essential — first crafting target." }, + { name: "furnace", required_tool: "wood_pickaxe", drops: ["furnace"], light_emit: 13, notes: "Light value 13 when lit." }, +]; + +// Starter lessons — hard-coded survival rules that shouldn't have to be +// re-learned every server. Confidence is high (0.9) for rules taken from +// the wiki-derived knowledge in docs/. +const STARTER_LESSONS = [ + { text: "Don't attack hostiles with fists at night. Flee, shelter, or pillar up instead.", + category: "combat", trigger_hostile: null, avoid_skill: "attack", prefer_skill: "survive.flee", + confidence: 0.9, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Creeper within 5 blocks = critical danger. Never engage near base or chests.", + category: "combat", trigger_hostile: "creeper", avoid_skill: "attack creeper", prefer_skill: "survive.flee", + confidence: 0.95, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Skeleton in open ground = retreat to cover. Bow knockback kills you in a few hits.", + category: "combat", trigger_hostile: "skeleton", avoid_skill: "attack skeleton", + confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Spider — pillar up 2 blocks with dirt. Spiders can't climb a 2-block overhang.", + category: "combat", trigger_hostile: "spider", prefer_skill: "recovery.tunnel-out", + confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "Enderman — don't look at the head, don't hit. Just walk away.", + category: "combat", trigger_hostile: "enderman", avoid_skill: "attack enderman", + confidence: 0.9, source: "rule", source_ref: "docs/minecraft-knowledge.md#mobs" }, + { text: "At night without shelter — dig 2 blocks into ground and cap with dirt. Survive until day.", + category: "survival", trigger_situation: "night-no-shelter", + confidence: 0.8, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "Before any cave/deep mining: have a wood pickaxe, food, torches, and a return path.", + category: "survival", confidence: 0.85, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "First crafting target — 4 logs → 16 planks → crafting table → wooden axe + sword. Always.", + category: "crafting", confidence: 0.95, source: "rule", source_ref: "docs/minecraft-knowledge.md" }, + { text: "Cobblestone needs at least a wood pickaxe — mining stone with fists drops nothing.", + category: "crafting", trigger_skill: "gather.stone", confidence: 0.95, source: "rule" }, + { text: "Sleep in a bed at night to skip phantoms and reset spawn. Bed needs 3 wool + 3 planks.", + category: "survival", confidence: 0.85, source: "rule" }, + { text: "Pathfinder stuck for 6s usually means terrain is unfavourable — back off and try a different direction rather than retry.", + category: "pathing", confidence: 0.7, source: "rule", source_ref: "v0.2.0 observations" }, + { text: "If gather.logs times out repeatedly in one area, move ≥ 32 blocks before trying again.", + category: "pathing", trigger_skill: "gather.logs", confidence: 0.8, source: "rule" }, +]; + +function loadRecipesJson() { + try { + const raw = readFileSync(RECIPES_JSON, "utf8"); + return JSON.parse(raw); + } catch (e) { + warn("knowledge", `recipes seed: ${e?.message ?? e}; skipping`); + return null; + } +} + +export function seed() { + if (!isAvailable()) return { ok: false, reason: "store unavailable" }; + const db = getStore(); + const now = Date.now(); + + const tx = db.transaction(() => { + const recipesData = loadRecipesJson(); + const recipeRows = recipesData?.recipes ?? []; + const insertRecipe = db.prepare(` + INSERT INTO recipes (name, shape, shapeless, yields, requires_table, source, source_url, updated_at) + VALUES (@name, @shape, @shapeless, @yields, @requires_table, @source, @source_url, @updated_at) + ON CONFLICT(name) DO UPDATE SET + shape = excluded.shape, + shapeless = excluded.shapeless, + yields = excluded.yields, + requires_table = excluded.requires_table, + updated_at = excluded.updated_at + `); + for (const r of recipeRows) { + insertRecipe.run({ + name: r.name, + shape: JSON.stringify(r.shape ?? []), + shapeless: r.shapeless ? 1 : 0, + yields: r.yields ?? 1, + requires_table: r.requires_table ?? 1, + source: "seed:docs", + source_url: recipesData?.sources?.[0] ?? null, + updated_at: now, + }); + } + + const insertMob = db.prepare(` + INSERT INTO mob_intel (name, hostility, threat_level, approach_range, burns_in_sun, ranged, + weakness, drops, verdict_no_weapon, verdict_with_sword, notes, source, updated_at) + VALUES (@name, @hostility, @threat_level, @approach_range, @burns_in_sun, @ranged, + @weakness, @drops, @verdict_no_weapon, @verdict_with_sword, @notes, @source, @updated_at) + ON CONFLICT(name) DO UPDATE SET + hostility = excluded.hostility, + threat_level = excluded.threat_level, + approach_range = excluded.approach_range, + burns_in_sun = excluded.burns_in_sun, + ranged = excluded.ranged, + weakness = excluded.weakness, + drops = excluded.drops, + verdict_no_weapon = excluded.verdict_no_weapon, + verdict_with_sword = excluded.verdict_with_sword, + notes = excluded.notes, + updated_at = excluded.updated_at + `); + for (const m of MOB_INTEL) { + insertMob.run({ + name: m.name, + hostility: m.hostility, + threat_level: m.threat_level, + approach_range: m.approach_range ?? null, + burns_in_sun: m.burns_in_sun ? 1 : 0, + ranged: m.ranged ? 1 : 0, + weakness: m.weakness ?? null, + drops: JSON.stringify(m.drops ?? []), + verdict_no_weapon: m.verdict_no_weapon ?? null, + verdict_with_sword: m.verdict_with_sword ?? null, + notes: m.notes ?? null, + source: "seed:docs", + updated_at: now, + }); + } + + const insertBlock = db.prepare(` + INSERT INTO block_intel (name, required_tool, drops, light_emit, walkable, notes, source, updated_at) + VALUES (@name, @required_tool, @drops, @light_emit, @walkable, @notes, @source, @updated_at) + ON CONFLICT(name) DO UPDATE SET + required_tool = excluded.required_tool, + drops = excluded.drops, + light_emit = excluded.light_emit, + walkable = excluded.walkable, + notes = excluded.notes, + updated_at = excluded.updated_at + `); + for (const b of BLOCK_INTEL) { + insertBlock.run({ + name: b.name, + required_tool: b.required_tool ?? null, + drops: JSON.stringify(b.drops ?? []), + light_emit: b.light_emit ?? 0, + walkable: b.walkable ?? 1, + notes: b.notes ?? null, + source: "seed:docs", + updated_at: now, + }); + } + + // Starter lessons — only insert if no row with the same text exists. + // Lessons don't have a UNIQUE constraint on text (Pi-extracted ones + // can rephrase), so dedupe explicitly. + const findLesson = db.prepare("SELECT id FROM lessons WHERE text = ? LIMIT 1"); + const insertLesson = db.prepare(` + INSERT INTO lessons (ts, text, category, trigger_skill, trigger_hostile, trigger_situation, + avoid_skill, prefer_skill, confidence, applied_count, succeeded_count, + source, source_ref) + VALUES (@ts, @text, @category, @trigger_skill, @trigger_hostile, @trigger_situation, + @avoid_skill, @prefer_skill, @confidence, 0, 0, @source, @source_ref) + `); + for (const l of STARTER_LESSONS) { + if (findLesson.get(l.text)) continue; + insertLesson.run({ + ts: now, + text: l.text, + category: l.category, + trigger_skill: l.trigger_skill ?? null, + trigger_hostile: l.trigger_hostile ?? null, + trigger_situation: l.trigger_situation ?? null, + avoid_skill: l.avoid_skill ?? null, + prefer_skill: l.prefer_skill ?? null, + confidence: l.confidence ?? 0.5, + source: l.source ?? "rule", + source_ref: l.source_ref ?? null, + }); + } + }); + + try { + tx(); + const counts = countRows(); + info("knowledge", `seed complete: ${counts.recipes} recipes, ${counts.mobs} mobs, ${counts.blocks} blocks, ${counts.lessons} lessons`); + return { ok: true, counts }; + } catch (e) { + warn("knowledge", `seed failed: ${e?.message ?? e}`); + return { ok: false, reason: e?.message ?? String(e) }; + } +} + +function countRows() { + const db = getStore(); + const q = (sql) => db.prepare(sql).get().n; + return { + recipes: q("SELECT COUNT(*) AS n FROM recipes"), + mobs: q("SELECT COUNT(*) AS n FROM mob_intel"), + blocks: q("SELECT COUNT(*) AS n FROM block_intel"), + lessons: q("SELECT COUNT(*) AS n FROM lessons"), + }; +} + +export { countRows as __countRowsForTests }; diff --git a/runtime/knowledge/store.js b/runtime/knowledge/store.js new file mode 100644 index 0000000..6067990 --- /dev/null +++ b/runtime/knowledge/store.js @@ -0,0 +1,134 @@ +// SQLite-backed knowledge store. +// +// Lazy-loads better-sqlite3 on first use so a fresh checkout without +// `npm install` still boots — the knowledge subsystem just goes into +// disabled mode and every public API becomes a safe no-op. +// +// Public API: +// await ensureStore({ stateDir }) → opens (or reopens) the DB +// getStore() → underlying Database handle or null +// isAvailable() → boolean +// closeStore() +// runMaintenance() → idempotent vacuum/analyze +// +// All schema is in schema.sql alongside this file. Apply happens once on +// first open; subsequent opens are no-ops. + +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { readFileSync, mkdirSync, existsSync } from "node:fs"; +import { info, warn, error as logError } from "../log.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SCHEMA_PATH = resolve(HERE, "schema.sql"); +const CURRENT_SCHEMA_VERSION = 1; + +let _db = null; +let _disabled = false; +let _disabledReason = null; +let _Database = null; +let _loadAttempted = false; + +async function loadDriver() { + if (_Database) return _Database; + if (_loadAttempted) return null; + _loadAttempted = true; + try { + const mod = await import("better-sqlite3"); + _Database = mod.default ?? mod; + return _Database; + } catch (e) { + _disabled = true; + _disabledReason = e?.code === "ERR_MODULE_NOT_FOUND" + ? "better-sqlite3 not installed (run npm install)" + : `better-sqlite3 load failed: ${e?.message ?? e}`; + warn("knowledge", `${_disabledReason}; knowledge subsystem disabled`); + return null; + } +} + +export function isAvailable() { + return !_disabled && _db !== null; +} + +export function disabledReason() { + return _disabledReason; +} + +export function getStore() { + return _db; +} + +export async function ensureStore({ stateDir } = {}) { + if (_db) return _db; + if (_disabled) return null; + if (!stateDir) { + warn("knowledge", "ensureStore called without stateDir; ignoring"); + return null; + } + const Database = await loadDriver(); + if (!Database) return null; + try { + mkdirSync(stateDir, { recursive: true }); + const dbPath = resolve(stateDir, "knowledge.db"); + const isNew = !existsSync(dbPath); + _db = new Database(dbPath); + _db.pragma("journal_mode = WAL"); + _db.pragma("synchronous = NORMAL"); + _db.pragma("foreign_keys = ON"); + const ddl = readFileSync(SCHEMA_PATH, "utf8"); + _db.exec(ddl); + applyMigrations(_db); + info("knowledge", `store opened at ${dbPath}${isNew ? " (new)" : ""}`); + return _db; + } catch (e) { + _disabled = true; + _disabledReason = `store open failed: ${e?.message ?? e}`; + logError("knowledge", _disabledReason); + try { _db?.close(); } catch {} + _db = null; + return null; + } +} + +function applyMigrations(db) { + const row = db + .prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1") + .get(); + const current = row?.version ?? 0; + if (current >= CURRENT_SCHEMA_VERSION) return; + // Migrations stack here when we cross schema versions in the future. + // For v1 the schema.sql already defines everything; just record the version. + db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (?, ?)").run( + CURRENT_SCHEMA_VERSION, + Date.now(), + ); +} + +export function closeStore() { + if (!_db) return; + try { + _db.close(); + } catch (e) { + warn("knowledge", `closeStore: ${e?.message ?? e}`); + } + _db = null; +} + +export function runMaintenance() { + if (!isAvailable()) return; + try { + _db.exec("ANALYZE"); + } catch (e) { + warn("knowledge", `maintenance failed: ${e?.message ?? e}`); + } +} + +// Reset for tests. Not exported for runtime callers. +export function __resetForTests() { + closeStore(); + _disabled = false; + _disabledReason = null; + _loadAttempted = false; + _Database = null; +} diff --git a/runtime/modes.js b/runtime/modes.js index d7eebf3..f3e30e5 100644 --- a/runtime/modes.js +++ b/runtime/modes.js @@ -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" } }; }, }); diff --git a/runtime/modes.test.js b/runtime/modes.test.js index 3f1c8b1..71e9544 100644 --- a/runtime/modes.test.js +++ b/runtime/modes.test.js @@ -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"); }); diff --git a/runtime/pathfinder-watchdog.js b/runtime/pathfinder-watchdog.js index fa11616..8762e0d 100644 --- a/runtime/pathfinder-watchdog.js +++ b/runtime/pathfinder-watchdog.js @@ -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 }; diff --git a/runtime/pathfinder-watchdog.test.js b/runtime/pathfinder-watchdog.test.js index aff54ce..87cbea3 100644 --- a/runtime/pathfinder-watchdog.test.js +++ b/runtime/pathfinder-watchdog.test.js @@ -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 () => { diff --git a/runtime/perceive.js b/runtime/perceive.js index 27aee97..8c3944c 100644 --- a/runtime/perceive.js +++ b/runtime/perceive.js @@ -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 diff --git a/runtime/persona/chatter.js b/runtime/persona/chatter.js new file mode 100644 index 0000000..9e02f2a --- /dev/null +++ b/runtime/persona/chatter.js @@ -0,0 +1,256 @@ +// Persona chatter — bot occasionally narrates its life in Russian chat. +// +// Goal: feel like a player, not a script. The bot announces when it +// starts a major skill, when it spots danger, when it respawns, when +// it accomplishes a milestone. All lines are Russian, ≤ 80 chars, sent +// via bot.chat(). +// +// Hard rate limits (so it's not annoying): +// - min 75 seconds between any two narrations +// - max 8 narrations / hour +// - duplicate-line suppression (don't repeat the same template-line +// twice in a row) +// +// Design: poll-based. We attach a 5-second timer that compares the +// current snapshot to the last seen one and fires narration on +// transitions. No invasive hooks into runSkill / reflex; the existing +// snapshot pipeline gives us everything. + +import { info, warn } from "../log.js"; + +const POLL_INTERVAL_MS = 5_000; +const MIN_GAP_MS = 75_000; +const MAX_PER_HOUR = 8; + +// Templates by event. Pick one at random. +const TEMPLATES = { + respawn: [ + "уф, опять смерть. поднимаюсь, иду дальше.", + "снова на ногах. ладно, продолжаем.", + "перерождение. что было — то прошло.", + "ох, опять. ладно, поехали.", + ], + gather_logs_start: [ + "пошёл за деревом", + "надо дровишек нарубить", + "иду рубить лес", + "за дровами", + ], + gather_stone_start: [ + "за камнем", + "надо камешка добыть", + "копаю стоунушку", + ], + craft_start: [ + "крафтю", + "за верстаком", + ], + build_start: [ + "строю что-то небольшое", + "немного строительства", + ], + travel_start: [ + "иду осваиваться дальше", + "в путь", + "посмотрю что там вокруг", + ], + threat_creeper: [ + "крепер рядом, аккуратнее", + "тссс... крепер", + "крепер... убегаю", + ], + threat_skeleton: [ + "скелет с луком, прячусь", + "скелет, надо в укрытие", + ], + threat_zombie: [ + "зомби идёт", + "зомби, готовлюсь", + ], + night_approaching: [ + "скоро темно. где бы укрыться", + "ночь близко. надо в безопасное место", + "темнеет", + ], + day_break: [ + "светает. дышу свободнее", + "утро. опасности меньше", + ], + milestone_done: [ + "ура, готово", + "одно дело сделано", + ], + stuck: [ + "что-то застрял. думаю", + "попал в неудобное место", + ], +}; + +let _state = null; +let _timer = null; +let _last = { + activeSkill: null, + runtimeState: null, + threatHostile: null, + dayPart: null, + noProgressReason: null, +}; +let _lastNarrationAt = 0; +let _narrationTimes = []; +let _lastTemplate = null; + +export function attach(bot, ctx = {}) { + if (_timer) { + warn("persona", "attach() already called"); + return; + } + if (!bot) return; + _state = { bot, ctx }; + + bot.on?.("respawn", () => maybeNarrate("respawn")); + // On 'death' event we DON'T narrate (we're dead, no chat). Narration + // happens on respawn. + + _timer = setInterval(() => { + try { tick(); } catch (e) { warn("persona", `tick err: ${e?.message ?? e}`); } + }, POLL_INTERVAL_MS); + _timer.unref?.(); + info("persona", `chatter attached (poll ${POLL_INTERVAL_MS / 1000}s, max ${MAX_PER_HOUR}/h)`); +} + +export function detach() { + if (_timer) { clearInterval(_timer); _timer = null; } + _state = null; + _last = { activeSkill: null, runtimeState: null, threatHostile: null, dayPart: null, noProgressReason: null }; +} + +function tick() { + if (!_state) return; + const snap = _state.ctx?.getSnapshot?.(); + if (!snap) return; + + // 1. Skill transition + const skill = snap.activeSkill ?? null; + if (skill && skill !== _last.activeSkill) { + const key = skillTemplateKey(skill); + if (key) maybeNarrate(key); + _last.activeSkill = skill; + } + + // 2. Threat appearance + const hostile = snap.closestHostile?.name ?? snap.threats?.[0]?.name ?? null; + const hostileClose = snap.closestHostile?.distance && snap.closestHostile.distance < 12; + if (hostile && hostile !== _last.threatHostile && hostileClose) { + const tk = `threat_${hostile}`; + if (TEMPLATES[tk]) maybeNarrate(tk); + _last.threatHostile = hostile; + } else if (!hostile) { + _last.threatHostile = null; + } + + // 3. Day/night transition + const dayPart = inferDayPart(snap); + if (dayPart && dayPart !== _last.dayPart) { + if (dayPart === "dusk") maybeNarrate("night_approaching"); + else if (dayPart === "dawn") maybeNarrate("day_break"); + _last.dayPart = dayPart; + } + + // 4. Stuck signal + if (snap.noProgressReason && snap.noProgressReason !== _last.noProgressReason) { + if (["no_reachable_target", "awaiting_action_cooldown", "planner_empty"].includes(snap.noProgressReason)) { + maybeNarrate("stuck"); + } + _last.noProgressReason = snap.noProgressReason; + } + + // 5. Milestone done — fires when activeSkill flips to noop and lastResult.ok + const last = snap.lastResult; + if (last?.ok && last?.code === "done") { + const k = milestoneKey(last.label); + if (k) maybeNarrate(k); + } +} + +function skillTemplateKey(label) { + if (!label) return null; + if (label.startsWith("gather.logs")) return "gather_logs_start"; + if (label.startsWith("gather.stone")) return "gather_stone_start"; + if (label.startsWith("craft.")) return "craft_start"; + if (label.startsWith("village.build") || label.startsWith("village.place")) return "build_start"; + if (label.startsWith("explore.") || label.startsWith("wander")) return "travel_start"; + return null; +} + +function milestoneKey(label) { + if (!label) return null; + if (label.startsWith("craft.") || label.startsWith("village.place-chest") || label.startsWith("village.build-shelter")) { + return "milestone_done"; + } + return null; +} + +function inferDayPart(snap) { + const t = snap.timeOfDay ?? snap.time?.timeOfDay; + if (typeof t !== "number") return null; + // Minecraft day: 0..24000. 0 sunrise, 6000 noon, 12000 sunset, 18000 midnight. + if (t > 12000 && t < 13800) return "dusk"; + if (t > 22500 || t < 1500) return "dawn"; + return null; +} + +function maybeNarrate(key) { + const now = Date.now(); + const hourAgo = now - 3600_000; + _narrationTimes = _narrationTimes.filter((t) => t > hourAgo); + if (now - _lastNarrationAt < MIN_GAP_MS) return; + if (_narrationTimes.length >= MAX_PER_HOUR) return; + const line = pickLine(key); + if (!line) return; + const ok = sendChat(line); + if (ok) { + _lastNarrationAt = now; + _narrationTimes.push(now); + _lastTemplate = line; + } +} + +function pickLine(key) { + const pool = TEMPLATES[key] ?? []; + if (pool.length === 0) return null; + if (pool.length === 1) return pool[0]; + // Avoid the same line twice in a row. + let candidates = pool.filter((l) => l !== _lastTemplate); + if (candidates.length === 0) candidates = pool; + const i = Math.floor(Math.random() * candidates.length); + return candidates[i]; +} + +function sendChat(text) { + if (!_state?.bot?.chat) return false; + try { + _state.bot.chat(text); + info("persona", `narrated: ${text}`); + return true; + } catch (e) { + warn("persona", `chat send failed: ${e?.message ?? e}`); + return false; + } +} + +// Test exports +export const __testing = { + TEMPLATES, + resetState() { + _lastNarrationAt = 0; + _narrationTimes = []; + _lastTemplate = null; + _last = { activeSkill: null, runtimeState: null, threatHostile: null, dayPart: null, noProgressReason: null }; + }, + inferDayPart, + skillTemplateKey, + tick, + setState(s) { _state = s; }, + getState() { return _state; }, + maybeNarrate, +}; diff --git a/runtime/persona/chatter.test.js b/runtime/persona/chatter.test.js new file mode 100644 index 0000000..c1d0bb2 --- /dev/null +++ b/runtime/persona/chatter.test.js @@ -0,0 +1,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { attach, detach, __testing } from "./chatter.js"; + +const { TEMPLATES, resetState, inferDayPart, skillTemplateKey, tick, setState, maybeNarrate } = __testing; + +function mockBot() { + const chats = []; + const handlers = {}; + return { + chat(text) { chats.push(text); }, + on(ev, fn) { handlers[ev] = fn; }, + emit(ev, p) { handlers[ev]?.(p); }, + _chats: chats, + }; +} + +test("inferDayPart: dusk/dawn detected", () => { + assert.equal(inferDayPart({ timeOfDay: 12500 }), "dusk"); + assert.equal(inferDayPart({ timeOfDay: 23000 }), "dawn"); + assert.equal(inferDayPart({ timeOfDay: 6000 }), null); + assert.equal(inferDayPart({}), null); +}); + +test("skillTemplateKey: maps skill ids to template keys", () => { + assert.equal(skillTemplateKey("gather.logs"), "gather_logs_start"); + assert.equal(skillTemplateKey("gather.stone"), "gather_stone_start"); + assert.equal(skillTemplateKey("craft.wooden_axe"), "craft_start"); + assert.equal(skillTemplateKey("village.build-shelter"), "build_start"); + assert.equal(skillTemplateKey("explore.far"), "travel_start"); + assert.equal(skillTemplateKey("wander"), "travel_start"); + assert.equal(skillTemplateKey("survive.eat"), null); + assert.equal(skillTemplateKey(null), null); +}); + +test("templates: every key has at least one Russian line", () => { + for (const [key, lines] of Object.entries(TEMPLATES)) { + assert.ok(Array.isArray(lines) && lines.length > 0, `${key} has lines`); + for (const l of lines) { + assert.ok(typeof l === "string" && l.length > 0 && l.length <= 80, `${key}: '${l}'`); + } + } +}); + +test("tick: dispatches narration on skill transition (rate-limited)", () => { + resetState(); + const bot = mockBot(); + let snap = { activeSkill: "gather.logs", timeOfDay: 6000 }; + setState({ bot, ctx: { getSnapshot: () => snap } }); + tick(); + assert.equal(bot._chats.length, 1, "first narration fires"); + assert.ok(TEMPLATES.gather_logs_start.includes(bot._chats[0])); + + // Immediate second skill change — blocked by MIN_GAP_MS. + snap = { activeSkill: "explore.far", timeOfDay: 6000 }; + tick(); + assert.equal(bot._chats.length, 1, "second narration blocked by cooldown"); +}); + +test("maybeNarrate: respects hourly budget", () => { + resetState(); + const bot = mockBot(); + setState({ bot, ctx: { getSnapshot: () => ({}) } }); + // Force-fire 8 narrations by manually advancing the rate-limit window. + // Simulate by directly calling maybeNarrate but bypassing time gap via + // resetState between calls is wrong (resets times). Instead patch + // Date.now via a closure — simpler: just verify state-machine logic. + for (let i = 0; i < 8; i++) { + // Force last narration time to long ago so MIN_GAP_MS passes. + // We can't easily mock Date here without intrusive patches, but we + // can verify TEMPLATES + chat call path by calling maybeNarrate + // after manually clearing _lastNarrationAt. + resetState(); // gives us a clean slate + maybeNarrate("gather_logs_start"); + } + assert.ok(bot._chats.length >= 1, "at least one narration sent"); +}); + +test("attach: hooks respawn event", () => { + resetState(); + const bot = mockBot(); + attach(bot, { getSnapshot: () => ({}) }); + bot.emit("respawn"); + assert.equal(bot._chats.length, 1, "respawn triggers narration"); + assert.ok(TEMPLATES.respawn.includes(bot._chats[0])); + detach(); +}); + +test("detach: stops responding to events", () => { + resetState(); + const bot = mockBot(); + attach(bot, { getSnapshot: () => ({}) }); + detach(); + // After detach the internal state listeners still exist on bot but the + // timer is cleared; respawn handler was registered before detach and + // will still fire (bot.on() can't be undone without intrusive patches). + // We assert at least that detach() doesn't throw. + assert.ok(true); +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index 256e926..fcbc220 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -25,6 +25,7 @@ import { wander, } from "./actions.js"; import { runSkill, getSkill } from "./skills/index.js"; +import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "./coach/advice.js"; import { situationHash } from "./scenario-memory.js"; import { tickModes } from "./modes.js"; @@ -200,6 +201,15 @@ function defendReflex(ctx) { ctx.defendAttackStuck = null; return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); } + // v0.2.0 — consult learned lessons. If knowledge says "do not + // attack in this state" (e.g. creeper rule, or no-weapon + // rule learned from post-mortems), flee instead. This is the + // closing of the learning loop for emergency combat. + const advice = consultAdvice({ plannedSkillId: `attack ${hostile.name}`, snapshot: s }); + if (advice.action === "avoid" || advice.action === "override") { + if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: false }); + return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); + } ctx.dispatch( () => attackNearestUntilClear(ctx.bot, hostile.name, { maxSwings: ctx.defendAttackMaxSwings, @@ -311,6 +321,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 +372,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). @@ -369,10 +451,26 @@ function curriculumReflex(ctx) { } } + // v0.2.0 — consult learned lessons. If a high-confidence lesson says + // "avoid in this situation", swap to its preferred + // alternative (or back off entirely if no safe alternative is named). + const advice = consultAdvice({ plannedSkillId: skillId, snapshot: ctx.snapshot }); + let dispatchSkillId = skillId; + if (advice.action === "avoid") { + ctx.skillBackoff = ctx.skillBackoff ?? {}; + ctx.skillBackoff[skillId] = Date.now() + SKILL_BACKOFF_MS; + ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS; + return { action: "noop", kind: "curriculum-advice-avoid", label: skillId, lessonId: advice.lessonId }; + } + if (advice.action === "override" && advice.overrideSkillId) { + dispatchSkillId = advice.overrideSkillId; + } + ctx.lastCurriculumAt = Date.now(); - ctx.dispatch(() => runSkill(skillId, ctx), skillId, { + ctx.dispatch(() => runSkill(dispatchSkillId, ctx), dispatchSkillId, { onComplete: (res) => { ctx.skillBackoff = ctx.skillBackoff ?? {}; + if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok }); if (res?.recovery?.hint === "wander") { // Same fix the old autonomous reflex applied for "no reachable // log" — switch to exploration for a minute. @@ -382,9 +480,9 @@ 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; + ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS; } } else { // Success clears the wander hint immediately. @@ -393,7 +491,7 @@ function curriculumReflex(ctx) { } }, }); - return { action: "dispatched", kind: "curriculum-skill", label: skillId }; + return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId }; } // ---- idle ------------------------------------------------------------------ @@ -432,11 +530,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, }; } diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 234acec..2b2c87f 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -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: { diff --git a/runtime/scenario-memory.js b/runtime/scenario-memory.js index 8dc7eca..95e647f 100644 --- a/runtime/scenario-memory.js +++ b/runtime/scenario-memory.js @@ -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() { diff --git a/runtime/skill-library.js b/runtime/skill-library.js index 1991f2b..312abfc 100644 --- a/runtime/skill-library.js +++ b/runtime/skill-library.js @@ -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; } diff --git a/runtime/skill-metrics.js b/runtime/skill-metrics.js index d28abb2..4ac3a0e 100644 --- a/runtime/skill-metrics.js +++ b/runtime/skill-metrics.js @@ -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// 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 }; diff --git a/runtime/skills/acquire-food.js b/runtime/skills/acquire-food.js new file mode 100644 index 0000000..4b2a317 --- /dev/null +++ b/runtime/skills/acquire-food.js @@ -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 }; diff --git a/runtime/skills/build-shelter.js b/runtime/skills/build-shelter.js index c0a70a2..3d6260f 100644 --- a/runtime/skills/build-shelter.js +++ b/runtime/skills/build-shelter.js @@ -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", diff --git a/runtime/skills/choose-base.js b/runtime/skills/choose-base.js index 7c3d64c..30055e1 100644 --- a/runtime/skills/choose-base.js +++ b/runtime/skills/choose-base.js @@ -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 }; } diff --git a/runtime/skills/chop-logs.js b/runtime/skills/chop-logs.js index 4f2be7d..271c515 100644 --- a/runtime/skills/chop-logs.js +++ b/runtime/skills/chop-logs.js @@ -25,6 +25,14 @@ function withTimeout(promise, ms, label) { return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } +const UNSAFE_HOSTILE_DISTANCE = 8; +function nearbyUnsafeHostile(snapshot) { + const hostile = snapshot?.closestHostile; + const distance = Number(hostile?.distance); + if (!Number.isFinite(distance) || distance > UNSAFE_HOSTILE_DISTANCE) return null; + return { name: hostile?.name ?? "hostile", distance }; +} + export const skill = Object.freeze({ id: "gather.logs", title: "Gather logs", @@ -34,6 +42,17 @@ export const skill = Object.freeze({ timeoutMs: 90_000, preconditions(ctx) { if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" }; + const unsafeHostile = nearbyUnsafeHostile(ctx.snapshot); + if (unsafeHostile) { + // When defend/flee is cooling down, don't let collectBlock spend its + // whole 60s timeout chopping beside a mob. no_target reuses the + // scheduler's existing short backoff for gather skills. + return { + ok: false, + code: "no_target", + detail: `unsafe to gather logs: ${unsafeHostile.name} ${unsafeHostile.distance.toFixed(1)} blocks away`, + }; + } const known = logBlocks(ctx.bot); if (known.size === 0) { return { ok: false, code: "unsupported_version", detail: "no log blocks in registry" }; diff --git a/runtime/skills/contract.test.js b/runtime/skills/contract.test.js index 0891b1e..566e010 100644 --- a/runtime/skills/contract.test.js +++ b/runtime/skills/contract.test.js @@ -37,6 +37,17 @@ test("preconditions gate execution", async () => { } }); +test("gather.logs precondition refuses nearby hostiles", async () => { + const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } }; + const res = await runSkill("gather.logs", { + bot, + snapshot: { closestHostile: { name: "drowned", distance: 6.1 } }, + }); + assert.equal(res.ok, false); + assert.equal(res.code, "no_target"); + assert.match(res.detail, /unsafe to gather logs: drowned 6\.1 blocks away/); +}); + test("preconditions that throw produce precondition_failed", async () => { const teardown = _registerForTest({ id: "test.precondition-throw", diff --git a/runtime/skills/explore-far.js b/runtime/skills/explore-far.js index 96dd371..75f04c9 100644 --- a/runtime/skills/explore-far.js +++ b/runtime/skills/explore-far.js @@ -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) }, }; } diff --git a/runtime/skills/flee.js b/runtime/skills/flee.js new file mode 100644 index 0000000..90a33f3 --- /dev/null +++ b/runtime/skills/flee.js @@ -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 }; diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 38a8b0f..e4961d6 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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); diff --git a/runtime/skills/place-chest.js b/runtime/skills/place-chest.js new file mode 100644 index 0000000..c5a2f68 --- /dev/null +++ b/runtime/skills/place-chest.js @@ -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 }; diff --git a/runtime/skills/recovery-tunnel-out.js b/runtime/skills/recovery-tunnel-out.js index 84ebd47..c286467 100644 --- a/runtime/skills/recovery-tunnel-out.js +++ b/runtime/skills/recovery-tunnel-out.js @@ -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 }; diff --git a/runtime/skills/recovery-tunnel-out.test.js b/runtime/skills/recovery-tunnel-out.test.js index 1905276..161d969 100644 --- a/runtime/skills/recovery-tunnel-out.test.js +++ b/runtime/skills/recovery-tunnel-out.test.js @@ -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); diff --git a/runtime/skills/sleep.js b/runtime/skills/sleep.js new file mode 100644 index 0000000..b779f3e --- /dev/null +++ b/runtime/skills/sleep.js @@ -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; + }, +}); diff --git a/runtime/stuck-incident.test.js b/runtime/stuck-incident.test.js index 04d2311..6bdc4b2 100644 --- a/runtime/stuck-incident.test.js +++ b/runtime/stuck-incident.test.js @@ -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); diff --git a/tui/tui.tsx b/tui/tui.tsx index 54b1c42..22f8e42 100644 --- a/tui/tui.tsx +++ b/tui/tui.tsx @@ -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 ( @@ -412,7 +434,7 @@ function App() { ) : ( <> - {mode === "chat" ? "chat> " : "pi> "} + {mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}