v0.2.0-rc.1: self-learning agent — knowledge DB + post-mortem coach + persona

Major iteration. The bot now:

1. Maintains a per-server SQLite knowledge.db with structured memory:
   recipes, mob_intel, block_intel, lessons, deaths, postmortems, poi,
   wiki_pages, chat_log, code_changes. Seeded from docs/.

2. Captures every death event into the `deaths` table with full context
   (last skill, hostile, recent dispatches, snapshot). A periodic coach
   loop (≤3 Pi calls/hour) extracts generalised lessons from batches of
   unanalysed deaths and writes them to the `lessons` table.

3. Honours learned lessons at dispatch time. curriculumReflex and
   defendReflex consult knowledge.topAdvice before action: if a
   high-confidence lesson says "avoid <skill>" or "prefer <alternative>",
   the dispatcher swaps or backs off. Closes the learning loop.

4. Narrates its actions in Russian MC chat (≤8 lines/hour, ≥75s gap):
   skill starts, threat sightings, dusk/dawn, respawn, milestones.

5. Includes pre-v0.2.0 WIP in the baseline commit: pathfinder watchdog
   refinements that avoid interrupting collectBlock, metric-driven
   reflex recovery, persistent skill metrics, new skills (flee,
   acquire-food, place-chest, sleep), gather.logs hostile-proximity
   bail-out (auto-patch).

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