v0.3.1: survival behaviour overhaul — storyline, biome-aware scout, wedge-relocate, food/perf fixes, monitor TUI (#28)

* docs(v0.3.1): PRD — LLM prompt cost optimization

Design-only commit; no runtime changes. Spec for the next patch iteration.

Goal: cut per-advise() input tokens from ~800 to ≤300, preserving the
LLM's ability to produce valid registered skill ids and useful rationale.

Five proposed changes ranked by impact:

  P1  Compact registry format (saves ~350t/call) — group by namespace,
      comma-list ids, drop human titles. Default mode for advisor;
      verbose mode kept for postmortem/reflect.
  P2  Need-scoped registry (~50t additional) — show LLM only skills
      relevant to the active Maslow need + always-available safety
      skills (survive.flee, pillar-up, recovery.tunnel-out, explore.*).
  P3  Snapshot pruning (~50t) — drop weather/experience/dimension/biome/
      players from the user prompt; the LLM doesn't consult them.
  P4  Prompt caching probe — check if TimeWeb passes through
      prompt_tokens_details.cached_tokens. If yes, restructure prefix
      to maximize cache hits (cached input is ~10x cheaper at OpenAI).
  P5  Per-trigger cost telemetry in scripts/list-improvements.js --stats:
      avg_in / avg_out / cost_₽ / share% per trigger_reason, using
      TIMEWEB_PRICE_IN_RUB_PER_M and TIMEWEB_PRICE_OUT_RUB_PER_M env.

Trigger: TimeWeb admin panel after first day of v0.3.0 live showed
34K tokens / day at low activity. At cap budget that projects to
~480₽/month (101₽/M in, 608₽/M out for gpt-5.4-mini). Manageable
but the savings are mostly free — repeated infra tokens, not signal.

All changes are additive; runtime behaviour stays the same. If the
LLM produces worse advice with the compact registry, flip back via a
single constant in fast-advisor.js.

Acceptance: re-run scripts/check-timeweb.js probe 3 — expect
tokens_in ≤ 300 (was ~800). Live for 1h, check --stats: avg_in ≤ 300
per trigger group. Existing 360 tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): storyline — canonical Minecraft survival quest

The bot has been stuck in a loop for two days:
  acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat

Diagnosis: manifesto + LLM advisor both correctly identify "you need
food" but neither expresses *what concretely to do next*. Manifesto is
a priority ladder (need-detection), not a narrative arc.

This commit adds the missing narrative layer — an ordered list of
operational steps that mirror the vanilla Minecraft survival path:

  1. orient_self      — Понять где я
  2. first_wood       — Собрать 8 поленьев
  3. crafting_basics  — Сделать верстак и палки
  4. first_tools      — Деревянные орудия
  5. first_food       — Найти первую еду
  6. shelter_minimal  — Простой шелтер с кроватью
  7. stone_tier       — Каменные орудия
  8. food_security    — Запас еды на 16+
  9. iron_age         — Железо и печь
  10. settle_base     — Постоянная база
  11. village_grow    — Развивать деревню (ongoing)

Each step has:
  - completed(snapshot) → bool — detects achievement from snapshot
  - suggestSkill(snapshot) → { skillId, args? } — concrete next dispatch
  - emergencyPause(snapshot) → bool — defers to manifesto L0 alive
    emergencies (low HP near hostile, lava under foot, food = 0)
  - narration_ru — chat-friendly Russian one-liner spoken on entry

Components:

- runtime/goal/storyline.js — 11-step canonical quest catalogue
- runtime/goal/state.js — pickCurrentStep(snapshot) walks the list,
  returns first non-completed step + its suggestion. 3s cache.
  Validates suggestSkill's skillId against the live registry.
- runtime/reflex.js — curriculumReflex dispatch priority is now:
    1. manifesto (L0 alive emergencies always win)
    2. storyline (concrete operational subgoal)
    3. curriculum plan (legacy fallback)
  Tests pass ctx.disableStoryline=true for isolation.
- runtime/bot.js — snapshot.storyStep populated each tick so
  chatter/advisor/reflect observers see the same view.
- runtime/coach/fast-advisor.js — buildUserPrompt now embeds the
  current step + its suggested skill, so LLM advice is anchored
  ("step 5 first_food, storyline wants survive.acquire-food, but
  recent dispatches show it's failing — try explore.far + scout").
- runtime/coach/advisor-trigger.js — forwards ctx.storyStep into
  advise() and logs step id at trigger time.
- runtime/coach/reflect.js — reflection prompt includes storyline
  progress so 30-min self-assessment is anchored.
- runtime/persona/chatter.js — narrates step.narration_ru on
  transition. Rate-limited via existing maybeNarrateRaw().

New operator CLI:

- scripts/show-story.js — fetches the live snapshot via IPC sock and
  prints step progress with ✓/→/ markers, current skill, inventory.
  Falls back to --plain catalogue view when bot offline.

Token cost impact: ~+30 input tokens per advise() call (one extra
line in user prompt). Trivial vs the value of grounding LLM advice
in a concrete narrative.

Operator usage:

  node scripts/show-story.js          # live progress + which step + why
  node scripts/show-story.js --plain  # static catalogue of all 11 steps

Tests: 376 green (was 360, +16 storyline tests).

Also in this branch (already committed): dev/v0.3.1/PRD.md —
LLM prompt cost optimization design doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): storyline beats manifesto L1+ (only L0 alive emergencies override)

Found in live logs after the previous commit deployed:
  storyline: step 1/11: orient_self → explore.wander
  advisor-trigger: firing because wedged (planned=survive.acquire-food, ...)

Manifesto was still picking survive.acquire-food (L1 food) over the
storyline's orient_self → explore.wander. That's the wrong precedence —
storyline expresses a *concrete operational subgoal* and L1+ manifesto
needs are just "you'd benefit from food" priorities, not emergencies.

New dispatch precedence in curriculumReflex:
  1. manifesto L0 (alive emergencies: lava, low-HP+hostile, food=0)
  2. storyline (concrete narrative subgoal — beats L1+ manifesto)
  3. manifesto L1+ (fallback when storyline has no concrete suggestion)
  4. curriculum plan (legacy fallback)

This way the bot starts following the narrative arc even while
manifesto's L1 food is technically unsatisfied — orient_self runs to
completion before pursuing food explicitly. Storyline already handles
food as step 5 (first_food), so we're not skipping it.

Tests: 378 green (+2 priority-ordering tests):
- L0 manifesto emergency: upstream reflex (defend/modes) catches before
  curriculum dispatch
- storyline beats manifesto when both have suggestions: well-fed bot
  with logs → craft.planks (storyline crafting_basics), not gather.logs
  (manifesto L2)
- updated "manifesto fallback" test to require disableStoryline=true

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(tui): fullscreen monitor-only TUI (opencode-style)

Replaces the old tui/tui.tsx hotkey-heavy dashboard with a read-only
observability screen. Operator actions live in scripts/* now —
TUI is for watching, not driving.

Layout (top to bottom, all auto-resizing to terminal):
  1. Header     — MC/IPC status, pos, HP, food, day/night, hostiles
  2. Storyline  — current step + 11-step quest map (✓/→/○)
  3. Activity   — last N skill dispatches (colour by outcome)
  4. MC Chat    — last N chat lines (cyan for bot, yellow for players)
  5. Advisor    — last N LLM recommendations (trigger + outcome + tokens)
  6. Improvements — open requests from knowledge.improvement_requests
  7. Footer     — 24h token usage + cost in ₽ + q-to-quit

Data sources:
  - IPC sock: snapshot frames, log frames, chat frames (push)
  - SQLite knowledge.db: advisor_recommendations + improvement_requests
    polled every 5s (pull)

Token cost displayed live using TIMEWEB_PRICE_IN_RUB_PER_M /
TIMEWEB_PRICE_OUT_RUB_PER_M env vars (defaults: 101 / 608 for
gpt-5.4-mini).

Switches:
  - npm run tui          → new monitor (this file)
  - npm run tui:legacy   → old action-driven tui/tui.tsx (kept for now)

Implementation notes:
  - Uses ink + alternate-screen-buffer ANSI for proper "opencode-feel"
    fullscreen behaviour; restores prior terminal contents on quit.
  - Skips alt-screen and useInput when stdin/stdout isn't a TTY
    (smoke tests, piped output) — both gracefully degrade.
  - Stable React keys via per-event uid counter, avoids reconciler
    duplicate-key warnings as logs/chat/dispatches stream in.
  - Resize handled via 1s stdout-dimension poll, NOT direct
    'resize' listener (which conflicts with ink's own listener and
    triggers MaxListenersExceededWarning).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(tui): compact 4-section monitor (was 6) — fits 1080p without zoom

Operator reported the TUI overflowed the screen unless terminal was
zoomed way out. The 11-step storyline list alone was eating ~13
rows, and each advisor/improvement entry took 2-3 rows. Now:

- Header + storyline collapsed into one panel (2 lines):
    line 1: pepa · ●MC ●IPC · 1m50s · pepa_bot · (697,61,702) · HP 20 · food 5 · ☀ · ⚔60(creeper@58b)
    line 2: story ▓▒░░░░░░░░░ 1/11 orient_self · Понять где я → explore.wander
  The 11-step ladder is now a unicode progress bar (▓ done, ▒ current,
  ░ pending) — same info, fits in one row.

- Advisor entries: one line each instead of two.
    ✓ wedged_60s → survive.flee  802t 1900ms
    (outcome mark / trigger / target skill / tokens / latency)

- Improvements entries: one line each instead of two.
    #1 P2 ×3  Add craft.iron-pickaxe skill
  Description dropped from the row — use `node scripts/list-improvements.js`
  for full text.

- Sections: 4 (was 6).
    [header+story] · [activity | chat] · [advisor | improvements] · [footer]

Tested on a typical 1080p terminal — fits comfortably without zoom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(v0.3.1): real survival patterns — biome-aware scout, wedge-relocate, escape-pit-safe

Operator reported the bot wandered the same 50×50 patch for 2 hours
without making any progress toward food. Diagnosis showed three root
causes; this commit addresses all five open improvement_requests
the LLM (postmortem + tuner) flagged automatically.

Research basis (`Voyager`, `Plan4MC`, `GITM`, `Mindcraft`):
  - Coverage / commit-to-cardinal exploration when local scan fails
  - Biome-aware strategy switching using a static affordance table
  - Wedge detector above the skill layer that triggers RELOCATE not
    RETRY (per-skill stuck checks reset on re-entry — useless)
  - Time-in-region bbox heuristic + need-duration AND skill-cycle gate

Concrete changes:

1. `runtime/goal/storyline.js`
   - orient_self.completed: added timeout fallback (HP=full + session
     >120s → done) so barren biomes don't block the bot on step 1.
     Closes improvement #2 'Нет навыка оценки когда сменить район'.
   - first_food.suggestSkill: now picks survive.scout-food (new) when
     no passive mob is nearby; falls back to survive.acquire-food only
     when something is in immediate range.

2. `runtime/biome-affordances.js` (new)
   - Static table: 40+ biomes → {has_passive_mobs, has_trees,
     has_water, has_crops, livable}.
   - Unknown biomes return optimistic defaults to avoid regressions.
   - Closes improvement #1 'Нет навыка целевого поиска еды по биому'.

3. `runtime/skills/scout-food.js` (new — survive.scout-food)
   - Tiered strategy: biome check → scan 32 → scan 64 → commit a
     cardinal for 200 blocks rescanning every 16. On cardinal
     exhaustion, returns code:"exhausted" so the curriculum can
     escalate to village.relocate.
   - In barren biomes (desert/ocean/snowy_plains) the scan is
     SKIPPED — bot walks straight toward the nearest neighbour
     biome that affords passive mobs (8-direction biome probe at
     radius 64).

4. `runtime/awareness/wedge-detector.js` (new)
   - Rolling 10-min position bbox tracker. observe() called every
     tick; isWedged() returns true when bbox<50 AND active need
     unmet >5min AND skill cycles ≥3.
   - markRelocationStarted() suppresses further wedge firings
     until the bot has displaced ≥200b — prevents stack overflow
     of relocate calls.
   - Lives ABOVE the skill layer (in runtime/reflex.js), because
     any per-skill stuck check resets on re-entry.

5. `runtime/skills/relocate.js` (new — village.relocate)
   - 300-block walk in least-recently-used cardinal (per-incident
     memory in ctx.recentRelocations).
   - Re-paths every 32 blocks, soft-tolerates pathfinder failures
     (3 consecutive throws → exit with code:"stuck_in_place").
   - Closes improvement #2 + #4 ('low success rate trigger').

6. `runtime/skills/escape-pit-safe.js` (new — recovery.escape-pit-safe)
   - Surveys 4 cardinals AND ceiling height before committing.
     Picks the direction with most open blocks (≥3, no lava).
     Falls through to pillar-up only if ceiling clear ≥4b. Returns
     code:"no_strategy" if both blocked so curriculum can escalate
     to relocate.
   - Closes improvement #3 'Нет навыка для безопасного выхода'.

7. `runtime/reflex.js`
   - Wedge detector wired before manifesto/storyline. If wedge.wedged
     is true, dispatches village.relocate directly and returns —
     bypasses every other branch.
   - ctx.disableWedge flag for tests.

8. `runtime/coach/advisor-trigger.js`
   - LLM provider outage backoff: 3 consecutive http_400 / timeout /
     network_error → suppress advisor for 10 min. Today's TimeWeb
     gpt-5.4-mini was 400'ing for an hour straight; we were spending
     trigger budget on dead calls. Closes improvement implicit gap
     in #5.

9. `runtime/bot.js`
   - Tracks botSpawnedAt; snapshot._sessionMs exposed for storyline
     orient_self timeout fallback.

Tests: 396 green (was 378, +18):
  - runtime/biome-affordances.test.js — 8 tests
  - runtime/awareness/wedge-detector.test.js — 9 tests
  - runtime/goal/storyline.test.js — 1 new test (orient_self timeout)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(coach/trigger-tuner): crash after ~1h — runOnce is sync, not a Promise

The live bot died overnight with:
  TypeError: runOnce(...).catch is not a function
  at trigger-tuner.js:42  →  [supervisor] child exited code=1

attach() wrapped the timer body as `runOnce().catch(...)` but
runOnce() returns a plain {ok, flagged, ...} object (pure SQL, no
await). The first tuner tick (60min after spawn) threw → killed the
whole bot process. Never surfaced before because the bot rarely ran
uninterrupted for a full hour during development.

Fix: guard the synchronous call with try/catch, matching how
persona/chatter.js already does its sync tick. (postmortem.drainOnce
and reflect.runOnce ARE async, so their .catch is correct — audited.)

Regression test added: captures the setInterval callback and invokes
it synchronously, asserting it does not throw.

Tests: 397 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): mechanical food/stuck fixes — bot reaches the chicken now

The wedge wasn't only in the manifesto layer; several mechanical bugs
kept the bot in a dead random-walk:

- storyline / manifesto / curriculum: "local food" now means an edible
  passive mob within <=32 blocks. A distant chicken or a cod no longer
  fools the bot into dispatching acquire-food (which then fails on
  no_path). Long-range food goes through scout-food instead.

- scout-food: partial approach to a target now counts as progress
  (approached_target, e.g. moved:14); a blocked heading is NOT counted
  as movement; added blind/tunnel fallback so it doesn't die when the
  pathfinder can't route cleanly.

- acquire-food: on no_path it now also tries a blind/tunnel approach to
  the animal; no_drop routes back into food scouting instead of giving
  up.

- explore.far / relocate / flee: fewer false "done" results (micro-steps
  no longer counted as success), more genuine escapes from stuck.

- scripts/show-story.js: live IPC now actually renders the current
  storyline step.

Verification: scripts/lint-patch.js clean; npm test 404/404 green; bot
relaunched in tmux `pepa`. Live logs show real progress — bot switched
to survive.scout-food, approached the chicken (approached_target
moved:14), then reached survive.acquire-food: hunting chicken. Food
isn't fully closed yet but the remaining issue is concrete pickup/drop,
not dead random-walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(v0.3.1): sated bot stops chasing food + perf-leak + fuzzy improvement dedup

Third day of "bot just walks back and forth burning tokens". Root
causes were mechanical, not the manifesto:

1. SATED BOT CHASING FOOD (the big one)
   Bot had food=17 (nearly full) but storyline first_food + manifesto
   L1 required 2+ food ITEMS in inventory, so it looped scout-food /
   acquire-food for hours instead of working. Now both treat a hunger
   bar >= 14 (SATED_FOOD) as satisfied even with empty food inventory —
   a full bot chops wood / makes tools and grabs food opportunistically,
   only hard-pursuing food when actually hungry (< 14).
   manifesto/needs.js foodDetect + goal/storyline.js first_food.completed.

2. perf_hooks MEMORY LEAK (overnight OOM suspect)
   "MaxPerformanceEntryBufferExceededWarning: 1,000,001 measure entries".
   mineflayer/pathfinder emit perf marks we never consume. Added a
   60s reaper in bot.js (performance.clearMeasures/clearMarks). unref'd.

3. IMPROVEMENT QUEUE SELF-DUPLICATING
   The LLM re-filed closed gaps with reworded titles (#5/#8/#9 were
   dupes of implemented #1/#2/#3). Exact-title dedup missed them.
   Replaced with token-set fuzzy match (isDuplicateTitle): jaccard>=0.75
   OR >=3 shared meaningful tokens with jaccard>=0.5. Also: a re-filed
   gap that's already implemented/rejected is NOT resurrected as a new
   open row. Cleared all 5 open requests (now genuinely implemented).

Also confirmed (no change needed):
- canDig=true is a DELIBERATE codebase-wide choice ("without it the bot
  gets permanently stuck", actions.js). The stale memory recommending
  canDig=false is updated. ViaBackwards dig works partially (dug:1
  moved:1.8 observed); false would trap the bot in every pit.
- scout-food already has blind/tunnel fallback + 12s step timeout
  (operator's earlier edits) so trapped-pathfinder degrades instead of
  hanging 30s.

Tests: 407 green (was 404). Updated needs/state/storyline tests for the
SATED_FOOD threshold; added fuzzy-dedup + tokenize/jaccard tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
Yuriy Mayatnikov
2026-05-28 09:43:57 +03:00
committed by GitHub
co-authored by Claude Opus 4.7 mayatnikov
parent 7d96e44804
commit 15b6c11002
37 changed files with 3259 additions and 71 deletions
+192
View File
@@ -0,0 +1,192 @@
# pepa v0.3.1 — PRD: LLM prompt cost optimization
**Status**: Design draft. No code in this version yet — this PRD is the
spec future commits implement against. Owner: operator.
**Trigger**: TimeWeb admin panel after first day of v0.3.0 live:
~34K tokens used in a half-day session (mostly bot + some smoke).
At the 6-calls/hour cap that projects to **~480 ₽/month** (101 ₽/M in,
608 ₽/M out for gpt-5.4-mini). Manageable but worth shrinking — most
of the per-call cost is repeated infrastructure tokens, not the
situational signal the model actually uses.
## Goals
1. Cut per-advise() input tokens from ~800 → ≤300 (target 250).
2. Preserve correctness: the LLM must still see enough context to
produce a valid `skill_id` from the registry and a useful rationale.
3. Keep all changes transparent to the rest of the runtime — the
public `advise()` / `complete()` surface area doesn't change.
Non-goals:
- Switching providers. TimeWeb stays.
- Caching the LLM's *responses* (cache key would be situational, too
many misses to be worth the bookkeeping).
- Touching the analytical loops (postmortem / reflect). They're called
less often and need fuller context; cost there is acceptable.
## Cost breakdown — what we're optimizing
Measured on live advise() calls (TimeWeb gpt-5.4-mini, single advisor
trigger):
| Block | tokens (avg) | % of call |
|--------------------------------------|--------------|-----------|
| `skillRegistryPrompt({limit:1800})` | ~450 | 56% |
| System instructions (rules + JSON) | ~200 | 25% |
| User snapshot + threats + need + recent | ~150 | 19% |
| **Total input** | **~800** | **100%** |
| Output (JSON answer) | ~40-50 | — |
The registry block dominates. It currently lists all 30+ registered
skills with their human titles. The model rarely needs the full list —
most decisions are within 5-8 plausible skills per trigger.
## Proposed changes
### 1. Compact registry format (P1, biggest win)
Drop human titles and the per-skill descriptions; switch to
namespace-grouped, comma-separated id lists.
**Before** (~450 tokens):
```
Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill):
craft:
- craft.bed — Craft bed
- craft.chest — Craft chest
- craft.furnace — Craft furnace
...
survive:
- survive.acquire-food — Acquire food
- survive.eat — Eat
...
```
**After** (~100 tokens):
```
Valid skill ids (USE EXACTLY one of these or null):
craft: bed, chest, furnace, planks, sticks, torch, wooden-axe,
wooden-pickaxe, wooden-sword, stone-axe, stone-pickaxe, stone-sword
survive: acquire-food, eat, flee, pillar-up, sleep
gather: logs, stone, wool
recovery: tunnel-out
explore: far, wander
village: build-shelter, choose-base, deposit-surplus, place-chest
farm: wheat
diag: physics, scan, match
```
Saving: **~350 tokens/call**.
Implementation: add `skillRegistryPrompt({ mode: "compact" })` mode in
`runtime/skill-registry.js`. Default mode stays for slow analytical
loops (postmortem / reflect) which can afford the verbose form.
### 2. Need-scoped registry (P2, additional ~50 token saving)
When `activeNeed` is set, filter the registry to skills plausibly
relevant to that level + always-available safety skills.
Relevance table (manually curated, lives in `runtime/manifesto/needs.js`):
| Need | Relevant skills (in addition to ALWAYS set) |
|-------------------|------------------------------------------------------------|
| alive | survive.flee, survive.eat, recovery.tunnel-out |
| food | survive.acquire-food, survive.eat, farm.wheat |
| tools_wood | gather.logs, craft.planks, craft.sticks, craft.wooden-* |
| shelter_basic | gather.wool, craft.bed, village.build-shelter, village.choose-base |
| tools_stone | gather.stone, craft.sticks, craft.stone-* |
| armor_basic | gather.wool (placeholder) |
| food_security | farm.wheat, survive.acquire-food |
| tools_iron | gather.stone |
| armor_iron | (none — no skill yet) |
| village_seed | craft.chest, village.deposit-surplus, village.build-shelter |
| village_full | (full registry) |
| ALWAYS | survive.flee, survive.pillar-up, recovery.tunnel-out, |
| | explore.far, explore.wander |
Compact + scoped = **~50 tokens** for the registry block (down from 450).
Add a `prompt-builder.test.js` checking that:
- `survive.flee` is always present (emergency safety)
- The recommended skill from the previous call would still be in the
scoped registry (regression protection)
### 3. Snapshot pruning (P3, ~50 tokens)
The user-prompt snapshot includes fields the LLM rarely consults:
`weather`, `experience`, `dimension`, `biome`, `players[]`. Drop them
from the advise() user-prompt builder. Keep `position`, `hp`, `food`,
`isDay`, `closestHostile`, `activeNeed`, `recent dispatches`,
`hazards.footBlock` (lava detection), top inventory keys.
### 4. Prompt caching — investigation (P4)
OpenAI and Anthropic both support implicit prompt caching: when ≥1024
prefix tokens are identical across consecutive requests, the prefix
is billed once. TimeWeb's docs are silent on this.
Task: probe whether TimeWeb passes through OpenAI's `prompt_tokens_details.cached_tokens`
field. If yes, *increase* the system prefix length (keep verbose registry)
because cached input is ~10x cheaper than fresh. If no, full optimization
1+2+3 still wins.
Add a one-off check in `scripts/check-timeweb.js`: print
`payload?.usage?.prompt_tokens_details?.cached_tokens` if present.
### 5. Telemetry — per-trigger token attribution (P5)
Today `advisor_recommendations` records `tokens_in` per row but the
operator has no easy view of *which trigger types* are most expensive.
Extend `scripts/list-improvements.js --stats` to also print per-trigger:
```
trigger_reason total applied ok fail avg_in avg_out cost_₽ share%
wedged_* 20 18 3 15 280 45 2.1 45%
emergency_* 3 3 2 1 240 50 0.3 6%
repeat_* 8 7 0 7 260 42 0.8 17%
preempt_retry_* 14 12 2 10 290 44 1.5 32%
```
`cost_₽` = avg_in × calls × IN_PRICE + avg_out × calls × OUT_PRICE,
with prices read from env (`TIMEWEB_PRICE_IN_RUB_PER_M`,
`TIMEWEB_PRICE_OUT_RUB_PER_M`).
## Acceptance
After v0.3.1 lands:
- Re-run `node scripts/check-timeweb.js` probe 3 (`advise()`):
expect `tokens_in` ≤ 300 (was ~800).
- Re-run probe 4 (auto-trigger flow): rationale still references the
registered skill correctly.
- Run live for 1 hour, check `node scripts/list-improvements.js --stats`:
per-trigger `avg_in` ≤ 300.
- Existing 360 tests still green; new prompt-builder tests cover the
scoped registry behaviour.
## Out of scope (later versions)
- Tool/function-calling instead of free-form JSON (TimeWeb support unclear).
- Custom model selection per trigger (gpt-5.4-nano for routine repeats,
gpt-5.4-mini for emergencies). Defer until cost/quality data points
exist.
- Embedding-based prior-recommendation similarity check ("we already
told the bot to tunnel-out at this exact wedge 10 minutes ago, skip").
## Implementation order
When this version is greenlit, work in this order on a single branch
`v0.3.1` (one PR per session, per recently-updated workflow memory):
1. P1 compact registry mode + prompt-builder test
2. P2 need-scoped registry (extend `runtime/manifesto/needs.js` with
`relevantSkills`)
3. P3 snapshot pruning in `fast-advisor.js#buildUserPrompt`
4. P4 caching probe (one-off)
5. P5 cost telemetry in CLI viewer
6. STATUS.md + smoke retest + PR
All changes are additive; no behaviour regression expected. If
real-world after v0.3.1 shows the LLM giving worse advice with the
compact registry, fall back to default mode by flipping a single
constant in `fast-advisor.js`.
+3 -2
View File
@@ -13,10 +13,11 @@
"agent:resume": "pi -c",
"bot": "node runtime/supervisor.js",
"bot:bare": "node runtime/bot.js",
"tui": "tsx tui/tui.tsx",
"tui": "tsx tui/monitor.tsx",
"tui:legacy": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh",
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.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/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.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/skills/pillar-up.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/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
+25 -1
View File
@@ -173,7 +173,31 @@ export async function fleeFrom(bot, fromEntity, distance = 16) {
);
return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } };
} catch (e) {
warn("action", `flee failed: ${e.message}`);
warn("action", `flee path failed: ${e.message}; trying blind retreat`);
const before = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try { bot.pathfinder?.stop?.(); } catch {}
try { await bot.look(-Math.atan2(dx / len, dz / len), 0, true); } catch {}
bot.setControlState("forward", true);
bot.setControlState("jump", true);
try {
await new Promise((r) => setTimeout(r, 7_000));
} finally {
bot.setControlState("forward", false);
bot.setControlState("jump", false);
}
const after = bot.entity.position;
const moved = Math.hypot(after.x - before.x, after.z - before.z);
if (moved >= 4) {
return {
ok: true,
detail: {
to: { x: Math.round(after.x), y: Math.round(after.y), z: Math.round(after.z) },
mode: "blind-retreat",
moved,
},
};
}
warn("action", `flee blind retreat moved only ${moved.toFixed(2)} blocks`);
return { ok: false, detail: e.message };
}
}
+135
View File
@@ -0,0 +1,135 @@
// Wedge detector — rolling-10min position bounding box.
//
// Sits above the skill layer (per the v0.3.1 design research). The
// reflex calls observe() with the latest position once per tick; on
// every call we evict older entries and recompute the bbox over the
// trailing window. If the bbox stays small for long enough AND a need
// has been unmet for long enough AND the same skill cycled enough
// times, isWedged() returns true and the reflex injects a `relocate`
// task that supersedes the current need until the bot has displaced
// ≥200 blocks from the wedge centre.
//
// This is intentionally NOT inside any single skill — every skill
// resets its own counters when re-entered, so per-skill stuck checks
// can't break a multi-skill cycle.
const WINDOW_MS = 10 * 60 * 1000; // 10 minutes
const MIN_BBOX_FOR_WEDGE = 50; // <50 blocks max-dim → wedge
const MIN_UNMET_NEED_MS = 5 * 60 * 1000; // need unsatisfied for 5+ min
const MIN_SKILL_CYCLES = 3; // same need's skill restarted ≥3x
let _samples = []; // { t, x, z }
let _activeRelocation = null; // { startedAt, fromCenter:{x,z}, headingName }
let _needStartedAt = new Map(); // needId → t when first detected
let _lastNeedId = null;
export function _resetForTest() {
_samples = [];
_activeRelocation = null;
_needStartedAt = new Map();
_lastNeedId = null;
}
/**
* observe({ x, z, now, activeNeedId, recentSkillIds })
*
* Lightweight: called every reflex tick (~1-2s). Updates sliding
* window and need-duration accounting.
*/
export function observe({ x, z, now = Date.now(), activeNeedId = null, recentSkillIds = [] } = {}) {
if (typeof x !== "number" || typeof z !== "number") return;
_samples.push({ t: now, x, z });
// evict
const cutoff = now - WINDOW_MS;
while (_samples.length && _samples[0].t < cutoff) _samples.shift();
// Track need duration
if (activeNeedId !== _lastNeedId) {
_lastNeedId = activeNeedId;
if (activeNeedId && !_needStartedAt.has(activeNeedId)) {
_needStartedAt.set(activeNeedId, now);
}
}
if (activeNeedId && !_needStartedAt.has(activeNeedId)) {
_needStartedAt.set(activeNeedId, now);
}
// If a relocation is in progress, check whether we've travelled
// far enough to clear it.
if (_activeRelocation && typeof _activeRelocation.fromCenter?.x === "number") {
const dx = x - _activeRelocation.fromCenter.x;
const dz = z - _activeRelocation.fromCenter.z;
if (Math.hypot(dx, dz) >= 200) {
_activeRelocation = null;
// Reset all need-duration timers so the post-relocation env
// gets a fair shot at being labelled satisfied / unsatisfied.
_needStartedAt = new Map();
}
}
}
/**
* isWedged({ activeNeedId, recentSkillIds, now })
* → { wedged: true, bboxDim, needAgeMs, skillCycles, centerX, centerZ } | { wedged: false }
*/
export function isWedged({ activeNeedId, recentSkillIds = [], now = Date.now() } = {}) {
if (_activeRelocation) return { wedged: false, reason: "relocating" };
if (_samples.length < 8) return { wedged: false, reason: "insufficient_samples" };
let xs = Infinity, xb = -Infinity, zs = Infinity, zb = -Infinity, cx = 0, cz = 0;
for (const s of _samples) {
if (s.x < xs) xs = s.x;
if (s.x > xb) xb = s.x;
if (s.z < zs) zs = s.z;
if (s.z > zb) zb = s.z;
cx += s.x; cz += s.z;
}
cx /= _samples.length; cz /= _samples.length;
const bboxDim = Math.max(xb - xs, zb - zs);
if (bboxDim >= MIN_BBOX_FOR_WEDGE) return { wedged: false, reason: "bbox_ok", bboxDim };
const needAgeMs = (activeNeedId && _needStartedAt.has(activeNeedId))
? now - _needStartedAt.get(activeNeedId)
: 0;
if (needAgeMs < MIN_UNMET_NEED_MS) return { wedged: false, reason: "need_recent", needAgeMs, bboxDim };
// Skill cycle count: how many distinct dispatches of the same skill
// appear in the recent rolling window.
const skillCycles = countCycles(recentSkillIds);
if (skillCycles < MIN_SKILL_CYCLES) return { wedged: false, reason: "few_cycles", skillCycles, bboxDim };
return { wedged: true, bboxDim, needAgeMs, skillCycles, centerX: cx, centerZ: cz };
}
/**
* markRelocationStarted({ x, z, heading })
* The reflex calls this when it dispatches village.relocate. While
* a relocation is in flight, isWedged() returns false (relocating)
* so we don't fire a SECOND relocation on top.
*/
export function markRelocationStarted({ x, z, heading } = {}) {
_activeRelocation = {
startedAt: Date.now(),
fromCenter: { x, z },
headingName: heading?.name ?? "?",
};
}
export function activeRelocation() { return _activeRelocation; }
function countCycles(ids) {
if (!Array.isArray(ids) || ids.length === 0) return 0;
// A "cycle" = a transition like A → B → A. Count those.
let cycles = 0;
for (let i = 2; i < ids.length; i++) {
if (ids[i] === ids[i - 2] && ids[i] !== ids[i - 1]) cycles++;
if (ids[i] === ids[i - 1] && ids[i - 1] === ids[i - 2]) cycles++;
}
return cycles;
}
// Test exports
export const __testing = {
WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES,
countCycles,
};
+104
View File
@@ -0,0 +1,104 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
observe,
isWedged,
markRelocationStarted,
activeRelocation,
_resetForTest,
__testing,
} from "./wedge-detector.js";
const { WINDOW_MS, MIN_BBOX_FOR_WEDGE, MIN_UNMET_NEED_MS, MIN_SKILL_CYCLES, countCycles } = __testing;
test("countCycles: empty / short → 0", () => {
assert.equal(countCycles([]), 0);
assert.equal(countCycles(["a"]), 0);
assert.equal(countCycles(["a", "b"]), 0);
});
test("countCycles: A→B→A counts as cycle", () => {
assert.equal(countCycles(["a", "b", "a"]), 1);
assert.equal(countCycles(["a", "b", "a", "b", "a"]), 3);
});
test("countCycles: same skill 3+ times in a row also counts", () => {
assert.equal(countCycles(["a", "a", "a"]), 1);
assert.equal(countCycles(["a", "a", "a", "a"]), 2);
});
test("isWedged: insufficient samples → not wedged", () => {
_resetForTest();
const r = isWedged({ activeNeedId: "food" });
assert.equal(r.wedged, false);
assert.equal(r.reason, "insufficient_samples");
});
test("isWedged: large bbox → not wedged", () => {
_resetForTest();
const t0 = 1_000_000_000_000;
// scatter across 200 blocks
for (let i = 0; i < 12; i++) {
observe({ x: i * 30, z: i * 25, now: t0 + i * 1000, activeNeedId: "food", recentSkillIds: [] });
}
const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 13_000 });
assert.equal(r.wedged, false);
assert.equal(r.reason, "bbox_ok");
});
test("isWedged: tight bbox + old need + cycles → WEDGED", () => {
_resetForTest();
const t0 = 1_000_000_000_000;
// 12 samples within a 30-block bbox, spread across ~9 min so they
// stay inside the 10-min sliding window.
for (let i = 0; i < 12; i++) {
observe({
x: 500 + (i % 4) * 8,
z: 500 + Math.floor(i / 4) * 8,
now: t0 + i * 45_000,
activeNeedId: "food",
recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food"],
});
}
const r = isWedged({
activeNeedId: "food",
recentSkillIds: ["acquire-food", "explore.far", "acquire-food", "explore.far", "acquire-food", "explore.far"],
now: t0 + 12 * 45_000,
});
assert.equal(r.wedged, true, `expected wedged, got ${JSON.stringify(r)}`);
assert.ok(r.bboxDim < MIN_BBOX_FOR_WEDGE);
assert.ok(r.needAgeMs >= MIN_UNMET_NEED_MS);
assert.ok(r.skillCycles >= MIN_SKILL_CYCLES);
});
test("isWedged: recent need (under threshold) → not wedged", () => {
_resetForTest();
const t0 = 1_000_000_000_000;
for (let i = 0; i < 12; i++) {
observe({ x: 500, z: 500, now: t0 + i * 10_000, activeNeedId: "food" });
}
const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"], now: t0 + 60_000 });
assert.equal(r.wedged, false);
});
test("markRelocationStarted blocks subsequent wedge for 200b", () => {
_resetForTest();
const t0 = 1_000_000_000_000;
markRelocationStarted({ x: 500, z: 500, heading: { name: "N" } });
assert.ok(activeRelocation());
// Stay tight bbox after relocation start
for (let i = 0; i < 12; i++) {
observe({ x: 510, z: 510, now: t0 + i * 60_000, activeNeedId: "food" });
}
const r = isWedged({ activeNeedId: "food", recentSkillIds: ["a", "b", "a", "b"] });
assert.equal(r.wedged, false);
assert.equal(r.reason, "relocating");
});
test("relocation clears after travelling ≥200 blocks", () => {
_resetForTest();
markRelocationStarted({ x: 0, z: 0, heading: { name: "N" } });
observe({ x: 250, z: 0, activeNeedId: "food" });
assert.equal(activeRelocation(), null, "relocation cleared after 250b displacement");
});
+150
View File
@@ -0,0 +1,150 @@
// Static knowledge: what each Minecraft biome reliably affords the bot.
//
// Why: pre-v0.3.1 the bot's "find food" skill scanned a 32-block radius
// for passive mobs and gave up. In a desert/ocean/snowy biome there's
// nothing to scan — the bot looped local searches for hours. This
// table lets skills check the *current* biome and pick a strategy
// before reaching for `pathfinder` blindly.
//
// Coverage is informed by vanilla mob spawn rules
// (https://minecraft.fandom.com/wiki/Spawn) — not exhaustive but
// covers the biomes the bot is realistically going to land in on
// 1.21.4 overworld spawn.
//
// Each entry is conservative: a `true` is "the bot has a real shot at
// finding this here", a `false` is "almost never bother scanning".
/**
* Affordance shape:
* has_passive_mobs — cows / pigs / chickens / sheep spawn here
* has_trees — oak/birch/spruce/jungle logs grow naturally
* has_water — open surface water that can be fished
* has_crops — natural berries / pumpkins / melons / sweet_berry_bush
* livable — bot can stand on the surface (not in lava, not
* perpetually underwater)
*/
const DEFAULT = Object.freeze({
has_passive_mobs: true,
has_trees: false,
has_water: false,
has_crops: false,
livable: true,
});
const BIOMES = Object.freeze({
// Forest family — trees + cows/pigs/chickens
forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
dark_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true },
old_growth_birch_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
old_growth_pine_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true },
old_growth_spruce_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true },
taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true },
snowy_taiga: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
flower_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
pale_garden: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true },
// Plains family — open spawn, lots of mobs, scattered trees
plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
sunflower_plains: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
meadow: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
cherry_grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
// Savanna / jungle — passive mobs + trees
savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
savanna_plateau: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
windswept_savanna: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: true, livable: true },
sparse_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
bamboo_jungle: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
// Swamp / mangrove — has water + berries
swamp: { has_passive_mobs: true, has_trees: true, has_water: true, has_crops: false, livable: true },
mangrove_swamp: { has_passive_mobs: false, has_trees: true, has_water: true, has_crops: false, livable: true },
// Desert / badlands — NO passive mobs, no trees, no surface water.
// Action plan when the bot is here: walk a cardinal until biome
// boundary is detected (sample bot.world.getBiome at radius 64).
desert: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true },
badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true },
eroded_badlands: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true },
wooded_badlands: { has_passive_mobs: false, has_trees: true, has_water: false, has_crops: false, livable: true },
// Snowy biomes — no passive mobs (rabbits sometimes), strangled trees
snowy_plains: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
ice_spikes: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: true },
frozen_river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true },
frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
deep_frozen_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
// Mountains — sparse trees, goats
stony_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
jagged_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
frozen_peaks: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
snowy_slopes: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
grove: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
windswept_hills: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
windswept_gravelly_hills: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
windswept_forest: { has_passive_mobs: true, has_trees: true, has_water: false, has_crops: false, livable: true },
// Beaches / ocean — passive mobs scarce, water everywhere
beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true },
stony_shore: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true },
snowy_beach: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true },
ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
deep_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
deep_cold_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
deep_lukewarm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
warm_ocean: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: false },
river: { has_passive_mobs: false, has_trees: false, has_water: true, has_crops: false, livable: true },
// Mushroom — mooshrooms only, no other passive mobs but they ARE food
mushroom_fields: { has_passive_mobs: true, has_trees: false, has_water: false, has_crops: false, livable: true },
// Caves / unsupported dimensions — bot should leave
dripstone_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false },
lush_caves: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: true, livable: false },
deep_dark: { has_passive_mobs: false, has_trees: false, has_water: false, has_crops: false, livable: false },
});
/**
* affordancesFor(biomeName) → affordance object
*
* Unknown / null / undefined names return the optimistic DEFAULT so
* skills don't get crippled when a new 1.x biome shows up; they just
* fall back to the existing local-scan behaviour.
*/
export function affordancesFor(biomeName) {
if (!biomeName || typeof biomeName !== "string") return DEFAULT;
return BIOMES[biomeName] ?? DEFAULT;
}
export function hasPassiveMobs(biomeName) {
return affordancesFor(biomeName).has_passive_mobs;
}
export function hasTrees(biomeName) {
return affordancesFor(biomeName).has_trees;
}
export function hasWater(biomeName) {
return affordancesFor(biomeName).has_water;
}
export function isLivable(biomeName) {
return affordancesFor(biomeName).livable;
}
// True if this biome is barren enough that the bot's priority should
// be "leave biome" rather than "search local".
export function isBarren(biomeName) {
const a = affordancesFor(biomeName);
return !a.has_passive_mobs && !a.has_trees && !a.has_crops;
}
// True if the biome can't be stood on (deep ocean, caves at y=Y).
export function isUnlivable(biomeName) {
return !affordancesFor(biomeName).livable;
}
// Test exports
export const __testing = { BIOMES, DEFAULT };
+66
View File
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
affordancesFor,
hasPassiveMobs,
hasTrees,
hasWater,
isLivable,
isBarren,
isUnlivable,
__testing,
} from "./biome-affordances.js";
test("plains: full affordances (mobs + scattered trees)", () => {
const a = affordancesFor("plains");
assert.equal(a.has_passive_mobs, true);
assert.equal(a.has_trees, true);
assert.equal(a.livable, true);
});
test("desert: barren (no mobs, no trees, no water)", () => {
assert.equal(hasPassiveMobs("desert"), false);
assert.equal(hasTrees("desert"), false);
assert.equal(hasWater("desert"), false);
assert.equal(isBarren("desert"), true);
assert.equal(isLivable("desert"), true, "desert is walkable, just empty");
});
test("ocean / deep_ocean: unlivable + has water", () => {
assert.equal(isUnlivable("ocean"), true);
assert.equal(isUnlivable("deep_ocean"), true);
assert.equal(hasWater("ocean"), true);
assert.equal(hasPassiveMobs("ocean"), false);
});
test("mushroom_fields: passive mobs (mooshroom) even though no other animals", () => {
assert.equal(hasPassiveMobs("mushroom_fields"), true);
assert.equal(isBarren("mushroom_fields"), false);
});
test("forest variants: trees + mobs", () => {
for (const b of ["forest", "birch_forest", "dark_forest", "taiga", "snowy_taiga", "jungle", "swamp"]) {
assert.equal(hasTrees(b), true, `${b} should have trees`);
assert.equal(hasPassiveMobs(b), true, `${b} should have passive mobs`);
}
});
test("badlands variants: no mobs, no trees (except wooded_badlands)", () => {
assert.equal(hasPassiveMobs("badlands"), false);
assert.equal(hasTrees("badlands"), false);
assert.equal(hasTrees("wooded_badlands"), true, "wooded variant has trees");
assert.equal(isBarren("badlands"), true);
});
test("unknown biome: optimistic defaults (don't cripple skills)", () => {
const a = affordancesFor("not_a_real_biome_2026");
assert.equal(a.has_passive_mobs, true);
assert.equal(a.livable, true);
});
test("null / undefined: optimistic defaults", () => {
assert.deepEqual(affordancesFor(null), __testing.DEFAULT);
assert.deepEqual(affordancesFor(undefined), __testing.DEFAULT);
assert.deepEqual(affordancesFor(42), __testing.DEFAULT);
});
+29
View File
@@ -63,6 +63,7 @@ import { attach as attachReflect } from "./coach/reflect.js";
import { attach as attachTuner } from "./coach/trigger-tuner.js";
import { attach as attachChatter } from "./persona/chatter.js";
import { attachAwareness } from "./awareness/events.js";
import { pickCurrentStep } from "./goal/state.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -79,6 +80,7 @@ const ESCALATE_AFTER_NOOPS = 20;
const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000;
let bot = null;
let botSpawnedAt = 0;
let pathWatchdog = null;
let awarenessState = null;
let reflexPaused = false;
@@ -676,6 +678,7 @@ function connect() {
reflexCtx.bot = bot;
bot.once("spawn", () => {
botSpawnedAt = Date.now();
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`);
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
@@ -844,6 +847,13 @@ function tick() {
}
const curriculumEarly = nextCurriculumMilestone(lastSnapshot);
lastSnapshot.curriculum = curriculumEarly;
// Time since spawn — used by storyline orient_self to fall through
// when bot is in a barren biome that never produces "saw blocks".
lastSnapshot._sessionMs = botSpawnedAt ? Date.now() - botSpawnedAt : 0;
// Storyline current step — surfaced in snapshot so chatter and
// other observers can react to step transitions without
// re-importing the picker.
try { lastSnapshot.storyStep = pickCurrentStep(lastSnapshot); } catch {}
reflexCtx.snapshot = lastSnapshot;
if (!reflexPaused) {
const result = runTick(reflexCtx);
@@ -936,6 +946,25 @@ function tick() {
function startTickLoop() {
if (tickTimer) clearInterval(tickTimer);
tickTimer = setInterval(tick, config.tickIntervalMs);
startPerfBufferReaper();
}
// mineflayer + mineflayer-pathfinder emit performance.mark/measure
// entries that accumulate in the global perf_hooks buffer with no
// upper bound. Over a multi-hour run this grew past 1,000,000 entries
// ("MaxPerformanceEntryBufferExceededWarning") and is a prime suspect
// for the overnight OOM. We don't consume those entries, so clear the
// buffer on a slow interval.
let perfReaperTimer = null;
function startPerfBufferReaper() {
if (perfReaperTimer) return;
perfReaperTimer = setInterval(() => {
try {
performance.clearMeasures?.();
performance.clearMarks?.();
} catch {}
}, 60_000);
perfReaperTimer.unref?.();
}
// ---- IPC commands ----------------------------------------------------------
+6
View File
@@ -21,10 +21,13 @@ const SAFE_OVERRIDES = new Set([
"survive.flee",
"survive.sleep",
"survive.eat",
"survive.acquire-food",
"survive.scout-food",
"survive.pillar-up",
"recovery.tunnel-out",
"explore.far",
"explore.wander",
"village.relocate",
"village.build-shelter",
"village.choose-base",
]);
@@ -45,6 +48,9 @@ const MODE_TO_SKILL = Object.freeze({
"tunnel-out": "recovery.tunnel-out",
explore: "explore.far",
wander: "explore.far",
scout_food: "survive.scout-food",
"scout-food": "survive.scout-food",
relocate: "village.relocate",
});
function normalisePreferSkill(raw) {
+2
View File
@@ -115,6 +115,8 @@ test("normalisePreferSkill: 'survive_flee' shape gets translated to dot form", (
test("normalisePreferSkill: passes through known dot-form skills unchanged", () => {
assert.equal(normalisePreferSkill("survive.flee"), "survive.flee");
assert.equal(normalisePreferSkill("explore.far"), "explore.far");
assert.equal(normalisePreferSkill("survive.scout-food"), "survive.scout-food");
assert.equal(normalisePreferSkill("village.relocate"), "village.relocate");
});
test("normalisePreferSkill: unknown values rejected (returns null)", () => {
+29 -2
View File
@@ -34,13 +34,27 @@ const PREEMPT_WINDOW_MS = 30_000;
const EMERGENCY_HP = 6;
const EMERGENCY_HOSTILE_DIST = 8;
const EMERGENCY_COOLDOWN_MS = 20_000;
// LLM outage backoff — if 3 consecutive advise() calls return
// http_400 / network_error, suppress further calls for 10 minutes.
const PROVIDER_OUTAGE_FAILS = 3;
const PROVIDER_OUTAGE_BACKOFF_MS = 10 * 60 * 1000;
let _lastTriggerAt = 0;
let _inFlight = false;
let _consecutiveFails = 0;
let _providerOutageUntil = 0;
export function _resetForTest() {
_lastTriggerAt = 0;
_inFlight = false;
_consecutiveFails = 0;
_providerOutageUntil = 0;
}
function isProviderError(code) {
return code === "network_error"
|| code === "timeout"
|| (typeof code === "string" && code.startsWith("http_"));
}
export function getTriggerState() {
@@ -63,6 +77,9 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) {
if (_inFlight) return { fired: false, reason: "in_flight" };
const now = Date.now();
if (_providerOutageUntil > now) {
return { fired: false, reason: "provider_outage", retryAt: _providerOutageUntil };
}
// Drop a recommendation that's already aged out.
if (ctx.advisorRecommendation && now - ctx.advisorRecommendation.at > RECOMMENDATION_TTL_MS) {
@@ -85,10 +102,11 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) {
const snapshot = ctx.snapshot ?? null;
const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8);
const activeNeed = ctx.activeNeed ?? null;
const storyStep = ctx.storyStep ?? null;
info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"})`);
info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"}, step=${storyStep?.step?.id ?? "?"})`);
// Fire-and-forget. The promise's resolution writes ctx.advisorRecommendation.
advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, force: true })
advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, storyStep, force: true })
.then((result) => {
_inFlight = false;
const needLabel = activeNeed
@@ -141,6 +159,15 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) {
info("advisor-trigger", `recommendation: ${result.action} (${result.latencyMs}ms)`);
} else if (!result.ok) {
warn("advisor-trigger", `advise failed: ${result.code} (${result.detail})`);
if (isProviderError(result.code)) {
_consecutiveFails += 1;
if (_consecutiveFails >= PROVIDER_OUTAGE_FAILS) {
_providerOutageUntil = Date.now() + PROVIDER_OUTAGE_BACKOFF_MS;
warn("advisor-trigger", `LLM provider outage (${_consecutiveFails} fails in a row); backing off ${Math.round(PROVIDER_OUTAGE_BACKOFF_MS / 60000)}min`);
}
}
} else {
_consecutiveFails = 0;
}
})
.catch((e) => {
+8 -3
View File
@@ -67,6 +67,7 @@ export async function advise({
recentSkillIds = [],
lessonsTail = [],
activeNeed = null,
storyStep = null,
force = false,
} = {}) {
if (!isAvailable()) {
@@ -83,7 +84,7 @@ export async function advise({
}
const system = buildSystemPrompt();
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed });
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep });
_callTimes.push(now);
_lastCallAt = now;
@@ -164,7 +165,7 @@ function buildSystemPrompt() {
].join("\n");
}
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }) {
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }) {
const pos = snapshot?.position;
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
@@ -172,6 +173,9 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
const needLine = activeNeed
? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}`
: "(no active need)";
const storyLine = storyStep
? `step ${storyStep.index + 1} '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (storyline wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}`
: "(no current step)";
const hostile = snapshot?.closestHostile
? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b`
: "(none)";
@@ -180,6 +184,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
`Trigger: ${reason}`,
`Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`,
`HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`,
`Storyline progress: ${storyLine}`,
`Active need (Maslow ladder): ${needLine}`,
`Closest hostile: ${hostile}`,
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
@@ -190,7 +195,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active
"",
lessons ? `Relevant lessons:\n${lessons}\n` : "",
"What should the bot do RIGHT NOW? Return the JSON decision.",
"Prefer a skill that helps satisfy the active need unless an emergency forces another action.",
"Prefer a skill that advances the current storyline step. If a manifesto emergency fires, that wins over both. Don't repeat a skill that has been failing in the recent dispatches list.",
].filter(Boolean).join("\n");
}
+8 -2
View File
@@ -18,6 +18,7 @@ import { resolve } from "node:path";
import { isAvailable as knowledgeAvailable, record as recordLesson, createImprovementRequest } from "../knowledge/index.js";
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { pickActiveNeed } from "../manifesto/state.js";
import { pickCurrentStep } from "../goal/state.js";
import { isAvailable as llmAvailable } from "../llm/provider.js";
import { askAnalytical } from "./llm-call.js";
import { info, warn } from "../log.js";
@@ -81,8 +82,9 @@ export async function runOnce({ stateDir, getSnapshot, force = false, askAnalyti
const diary = readDiaryTail(stateDir);
const plan = readPlan(stateDir);
const activeNeed = pickActiveNeed(snap);
const storyStep = pickCurrentStep(snap);
const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed });
const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep });
_llmCallTimes.push(now);
const parsed = await askAnalyticalFn({ system, user, json: true });
@@ -172,13 +174,16 @@ function readPlan(stateDir) {
try { return readFileSync(f, "utf8"); } catch { return ""; }
}
function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) {
function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep }) {
const pos = snap?.position;
const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)";
const lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)";
const needLine = activeNeed
? `L${activeNeed.need.level} ${activeNeed.need.id}${activeNeed.skillId} (${activeNeed.need.title})`
: "(satisfied through L10 / no active need)";
const storyLine = storyStep
? `step ${storyStep.index + 1}/11 '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}`
: "(no current step)";
const system = [
"You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.",
@@ -216,6 +221,7 @@ function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) {
`- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`,
`- runtimeState: ${snap?.runtimeState ?? "?"}`,
`- activeSkill: ${snap?.activeSkill ?? "(idle)"}`,
`- storylineStep: ${storyLine}`,
`- activeNeed (Maslow ladder L0-L10): ${needLine}`,
`- currentMilestone: ${snap?.currentMilestone ?? "?"}`,
`- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`,
+9 -1
View File
@@ -39,7 +39,15 @@ export function attach({ intervalMs = TUNE_INTERVAL_MS } = {}) {
return;
}
_timer = setInterval(() => {
runOnce().catch((e) => warn("tuner", `tick err: ${e?.message ?? e}`));
// runOnce is synchronous (pure SQL, no await) — must NOT call
// .catch on its plain-object return. A try/catch here is the
// correct guard. (This exact bug crashed the live bot after
// ~1h uptime when the first tuner tick fired.)
try {
runOnce();
} catch (e) {
warn("tuner", `tick err: ${e?.message ?? e}`);
}
}, intervalMs);
_timer.unref?.();
info("tuner", `attached; tune every ${Math.round(intervalMs / 60000)} min`);
+22 -1
View File
@@ -6,10 +6,31 @@ import { join } from "node:path";
import { initKnowledge, isAvailable, listImprovements } from "../knowledge/index.js";
import { closeStore, __resetForTests } from "../knowledge/store.js";
import { runOnce, __testing } from "./trigger-tuner.js";
import { runOnce, attach, detach, __testing } from "./trigger-tuner.js";
const { MIN_SAMPLE } = __testing;
test("attach: timer tick does not crash (runOnce is sync, regression for .catch bug)", async () => {
// Reproduces the crash that killed the live bot after ~1h: the
// setInterval body called runOnce().catch(...) but runOnce returns
// a plain object, not a Promise. attach must guard with try/catch.
detach();
let threw = false;
const origSetInterval = globalThis.setInterval;
let captured = null;
// capture the interval callback without actually waiting
globalThis.setInterval = (fn) => { captured = fn; return { unref() {} }; };
try {
attach({ intervalMs: 999999 });
// invoke the captured tick synchronously — must not throw
try { captured?.(); } catch { threw = true; }
} finally {
globalThis.setInterval = origSetInterval;
detach();
}
assert.equal(threw, false, "tuner timer tick must not throw");
});
async function bootstrap() {
const tmp = mkdtempSync(join(tmpdir(), "pepa-tuner-test-"));
__resetForTests();
+10 -1
View File
@@ -72,6 +72,7 @@ function hasAny(inv, names) {
const WOODEN_TOOLS = ["wooden_axe", "wooden_pickaxe", "wooden_sword"];
const STONE_TOOLS = ["stone_axe", "stone_pickaxe", "stone_sword"];
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
// A "stage reached" predicate: once the bot has wooden tools, wood.16 is
// implicitly considered done even if the log stack is now empty (the bot
@@ -86,6 +87,12 @@ function hasStoneTier(inv) {
return STONE_TOOLS.some((n) => has(inv, n));
}
function hasVisibleFoodTarget(snap) {
const passives = snap?.nearbyEntities?.passives ?? [];
if (passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name))) return true;
return (snap?.nearbyEntities?.droppedItems?.length ?? 0) > 0;
}
const MILESTONES = [
{
id: "wood.16",
@@ -157,7 +164,9 @@ const MILESTONES = [
);
return carrying || (snap?.food ?? 20) >= 18;
},
suggest: () => ({ skillId: "survive.acquire-food" }),
suggest: (_inv, snap) => ({
skillId: hasVisibleFoodTarget(snap) ? "survive.acquire-food" : "survive.scout-food",
}),
},
{
id: "storage.chest",
+2 -2
View File
@@ -191,10 +191,10 @@ test("listMilestones exposes ordered ids for diary/TUI", () => {
}
});
test("food.basic with no carried food suggests acquire-food", () => {
test("food.basic with no carried food or visible target suggests scout-food", () => {
const got = nextMilestone(snapAfter("stone.tools", {}, { food: 8 }));
assert.equal(got.milestone.id, "food.basic");
assert.equal(got.plan.skillId, "survive.acquire-food");
assert.equal(got.plan.skillId, "survive.scout-food");
});
test("storage.chest crafts first, then places carried chest", () => {
+88
View File
@@ -0,0 +1,88 @@
// Storyline state: which step the bot is currently on.
//
// pickCurrentStep(snapshot) walks STORYLINE from the top and returns
// the first non-completed step. If an emergency condition fires
// (low HP near hostile, lava under foot, etc.) the step is paused and
// callers should defer to manifesto's L0 alive emergency dispatch
// instead of step.suggestSkill.
import { STORYLINE, getStep } from "./storyline.js";
import { isRegistered } from "../skill-registry.js";
import { info } from "../log.js";
const CACHE_TTL_MS = 3_000;
let _cache = null;
let _lastStepId = null;
export function _resetForTest() {
_cache = null;
_lastStepId = null;
}
/**
* pickCurrentStep(snapshot) →
* {
* step: { id, title, narration_ru },
* index: number, // 0-based position
* suggestion: { skillId, args } | null,
* emergency: boolean, // true if emergencyPause fires
* completedSteps: number // how many done so far
* } | null
*/
export function pickCurrentStep(snapshot) {
if (!snapshot?.connected) return null;
const now = Date.now();
if (_cache && _cache.snapshot === snapshot && now - _cache.ts < CACHE_TTL_MS) {
return _cache.result;
}
let completedSteps = 0;
let chosen = null;
for (let i = 0; i < STORYLINE.length; i++) {
const step = STORYLINE[i];
let done;
try { done = !!step.completed(snapshot); } catch { done = false; }
if (done) {
completedSteps += 1;
continue;
}
let emergency = false;
try { emergency = !!step.emergencyPause?.(snapshot); } catch {}
let suggestion = null;
if (!emergency) {
try { suggestion = step.suggestSkill(snapshot) ?? null; } catch { suggestion = null; }
if (suggestion?.skillId && !isRegistered(suggestion.skillId)) {
info("storyline", `step ${step.id}: suggested unknown skill ${suggestion.skillId}; dropping`);
suggestion = null;
}
}
chosen = {
step: { id: step.id, title: step.title, narration_ru: step.narration_ru },
index: i,
suggestion,
emergency,
completedSteps,
};
break;
}
if (chosen && _lastStepId !== chosen.step.id) {
info("storyline", `step ${chosen.index + 1}/${STORYLINE.length}: ${chosen.step.id}${chosen.step.title}${chosen.suggestion?.skillId ?? "(no concrete skill)"}`);
_lastStepId = chosen.step.id;
}
_cache = { snapshot, ts: now, result: chosen };
return chosen;
}
/**
* progressSummary(snapshot) → string, e.g.
* "step 3/11 'first_tools' — Деревянные орудия → craft.wooden-pickaxe"
*/
export function progressSummary(snapshot) {
const cur = pickCurrentStep(snapshot);
if (!cur) return "(no storyline progress — disconnected)";
const tail = cur.suggestion?.skillId ? `${cur.suggestion.skillId}` : "";
const emer = cur.emergency ? " [EMERGENCY PAUSE]" : "";
return `step ${cur.index + 1}/${STORYLINE.length} '${cur.step.id}' — ${cur.step.title}${tail}${emer}`;
}
export { STORYLINE, getStep };
+367
View File
@@ -0,0 +1,367 @@
// Storyline — canonical Minecraft survival quest the bot lives inside.
//
// Why this exists (rationale, 2026-05-27 evening):
//
// After v0.3.0 went live the bot got stuck in a loop:
// acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat
//
// Manifesto + LLM advisor both correctly say "you need food" but
// neither expresses *what concretely to do next*: scout 64 blocks N
// for cows; chop oak nearby; place a crafting table. The bot has no
// narrative arc, just a priority ranking of unsatisfied needs.
//
// Storyline fixes this by laying down the classic vanilla Minecraft
// survival path as an ordered list of *concrete* steps. Each step
// owns:
// - id, title, narration_ru (chat-friendly Russian one-liner)
// - completed(snapshot) → bool — detects if this step's goal has
// been achieved purely from snapshot
// - suggestSkill(snapshot) → { skillId, args? } | null — the
// concrete next dispatch for the step's pursuit
// - emergencyPause(snapshot) → bool — true if a higher-priority
// condition (low HP near hostile, lava under foot, etc.) means we
// should drop story progression for a tick
//
// The runtime/goal/state.js picker walks the list and returns the
// first non-completed step, with its suggestSkill. That feeds into:
// - reflex.js dispatch picking (storyline overrides curriculum, but
// manifesto L0 alive emergencies still win)
// - persona/chatter.js — narrates step start in MC chat
// - coach/fast-advisor.js — user prompt includes current step so
// the LLM advice is anchored in the actual narrative
// - postmortem / reflect — the LLM can flag missing skills using
// the current step as concrete context
//
// Storyline order mirrors manifesto levels but is more *operational*:
// where manifesto says "L2 tools_wood satisfied if you have a wood
// pickaxe", storyline says "step first_tools: place crafting table,
// craft wooden pickaxe + axe + sword, with these specific subgoals."
const PICKAXE_WOOD = new Set(["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]);
const AXE_WOOD = new Set(["wooden_axe", "stone_axe", "iron_axe", "diamond_axe", "netherite_axe"]);
const SWORD_WOOD = new Set(["wooden_sword", "stone_sword", "iron_sword", "diamond_sword", "netherite_sword"]);
const PICKAXE_STONE = new Set(["stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]);
const BED_ITEMS = [
"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",
];
const FOOD_ITEMS = [
"bread", "cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton",
"cooked_rabbit", "cooked_cod", "cooked_salmon", "baked_potato",
"apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries",
"golden_apple", "golden_carrot",
];
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
function hasSetItem(inv, set) {
if (!inv) return false;
for (const name of Object.keys(inv)) {
if (set.has(name) && inv[name] > 0) return true;
}
return false;
}
function hasAny(inv, names) {
if (!inv) return false;
for (const n of names) if ((inv[n] ?? 0) > 0) return true;
return false;
}
function countAny(inv, names) {
if (!inv) return 0;
let total = 0;
for (const n of names) total += inv[n] ?? 0;
return total;
}
function countLogs(inv) {
if (!inv) return 0;
let total = 0;
for (const [name, count] of Object.entries(inv)) {
if (name.endsWith("_log")) total += count;
}
return total;
}
function countPlanks(inv) {
if (!inv) return 0;
let total = 0;
for (const [name, count] of Object.entries(inv)) {
if (name.endsWith("_planks")) total += count;
}
return total;
}
function woodBudget(inv) {
if (!inv) return 0;
const placedOrCarriedTable = (inv.crafting_table ?? 0) > 0 ? 4 : 0;
return countLogs(inv) * 4 + countPlanks(inv) + placedOrCarriedTable;
}
function blockCount(snap, kind) {
const v = snap?.nearbyBlocks?.[kind];
if (typeof v === "number") return v;
if (v && typeof v.count === "number") return v.count;
return 0;
}
function hasLocalFoodMob(snap, maxDistance = 32) {
for (const e of snap?.nearbyEntities?.passives ?? []) {
if (!PASSIVE_FOOD_MOBS.has(e?.name)) continue;
if ((e.distance ?? Infinity) <= maxDistance) return true;
}
return false;
}
function emergencyPause(snap) {
if (!snap?.connected) return false;
const hp = snap.health ?? 20;
const food = snap.food ?? 20;
const hostile = snap.closestHostile;
if (hp <= 5) return true;
if (food <= 0) return true;
if (hostile && (hostile.distance ?? Infinity) <= 5 && hp <= 12) return true;
if (snap.hazards?.footBlock === "lava") return true;
return false;
}
// ---------------------------------------------------------------------------
export const STORYLINE = Object.freeze([
{
id: "orient_self",
title: "Понять где я",
narration_ru: "Где я? Осмотрюсь и оценю место.",
completed(snap) {
if (!snap?.connected) return false;
const hp = snap.health ?? 20;
// Two completion paths: (a) classic — full HP + saw tangible
// blocks within 16 blocks. (b) timeout — HP=full + session
// >120s. Path (b) exists because in desert/ocean biomes the
// scan radius might never see logs/stone/crops/beds, and we
// were getting stuck on step 1 for hours.
if (hp < 18) return false;
const sawBlocks = blockCount(snap, "logs")
+ blockCount(snap, "stone")
+ blockCount(snap, "crops")
+ blockCount(snap, "beds") > 0;
if (sawBlocks) return true;
// Fallback: settled for long enough → call orient done and let
// later steps drive forward into the biome.
const sessionMs = snap._sessionMs ?? 0;
return sessionMs > 120_000;
},
suggestSkill(snap) {
// Look around — wander a bit to get a snapshot of what's nearby.
return { skillId: "explore.wander", args: { radius: 12 } };
},
emergencyPause,
},
{
id: "first_wood",
title: "Собрать стартовое дерево",
narration_ru: "Нужно дерево для первого крафта. Доберу минимум и сразу к верстаку.",
completed(snap) {
const inv = snap?.inventory ?? {};
return woodBudget(inv) >= 16
|| hasSetItem(inv, PICKAXE_WOOD)
|| hasSetItem(inv, AXE_WOOD)
|| hasSetItem(inv, SWORD_WOOD);
},
suggestSkill(snap) {
const trees = blockCount(snap, "logs");
if (trees > 0) return { skillId: "gather.logs" };
// No tree in sight — scout further. In a biome with no trees
// (desert, ocean) the bot must commit to a long heading; the
// curriculum's wedge detector (v0.3.1+) elevates this to
// village.relocate after a few cycles.
return { skillId: "explore.far", args: { searchFor: "logs" } };
},
emergencyPause,
},
{
id: "crafting_basics",
title: "Сделать верстак и палки",
narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.",
completed(snap) {
const inv = snap?.inventory ?? {};
return ((inv.stick ?? 0) >= 2 && countPlanks(inv) >= 4)
|| hasSetItem(inv, PICKAXE_WOOD)
|| hasSetItem(inv, AXE_WOOD)
|| hasSetItem(inv, SWORD_WOOD);
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if (countPlanks(inv) < 4) return { skillId: "craft.planks" };
if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" };
return null;
},
emergencyPause,
},
{
id: "first_tools",
title: "Деревянные орудия",
narration_ru: "Крафчу деревянный пикакс, топор и меч.",
completed(snap) {
const inv = snap?.inventory ?? {};
return hasSetItem(inv, PICKAXE_WOOD)
&& hasSetItem(inv, AXE_WOOD)
&& hasSetItem(inv, SWORD_WOOD);
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if (countPlanks(inv) < 4) {
if (countLogs(inv) > 0) return { skillId: "craft.planks" };
return { skillId: "gather.logs" };
}
if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" };
if (!hasSetItem(inv, PICKAXE_WOOD)) return { skillId: "craft.wooden-pickaxe" };
if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" };
if (!hasSetItem(inv, SWORD_WOOD)) return { skillId: "craft.wooden-sword" };
return null;
},
emergencyPause,
},
{
id: "first_food",
title: "Найти первую еду",
narration_ru: "Нужна еда — ищу корову, курицу или ягоды.",
completed(snap) {
// Done if we have a stock (≥2 food items) OR the hunger bar is
// comfortable (≥14). A sated bot should be chopping wood, not
// chasing a chicken it doesn't need — it'll grab food
// opportunistically when one wanders close.
if (countAny(snap?.inventory, FOOD_ITEMS) >= 2) return true;
if ((snap?.food ?? 20) >= 14) return true;
return false;
},
suggestSkill(snap) {
// Only reached when the bot is actually hungry (food < 14) and
// has no stock. Local mob → acquire-food; else long-range scout.
if (hasLocalFoodMob(snap)) return { skillId: "survive.acquire-food" };
return { skillId: "survive.scout-food" };
},
emergencyPause,
},
{
id: "shelter_minimal",
title: "Простой шелтер с кроватью",
narration_ru: "Поставлю кровать и стены — пережить ночь.",
completed(snap) {
return blockCount(snap, "beds") > 0;
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if (!hasAny(inv, BED_ITEMS)) {
const wool = countAny(inv, [
"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",
]);
if (wool >= 3 && countPlanks(inv) >= 3) return { skillId: "craft.bed" };
if (wool < 3) return { skillId: "gather.wool" };
}
return { skillId: "village.build-shelter" };
},
emergencyPause,
},
{
id: "stone_tier",
title: "Каменные орудия",
narration_ru: "Шахта по камню — нужен каменный сет.",
completed(snap) {
return hasSetItem(snap?.inventory, PICKAXE_STONE);
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
const cobble = inv.cobblestone ?? 0;
if (cobble < 4) return { skillId: "gather.stone" };
if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" };
if (!hasSetItem(inv, PICKAXE_STONE)) return { skillId: "craft.stone-pickaxe" };
if (!hasAny(inv, ["stone_axe"])) return { skillId: "craft.stone-axe" };
return { skillId: "craft.stone-sword" };
},
emergencyPause,
},
{
id: "food_security",
title: "Запас еды на 16+",
narration_ru: "Делаю ферму или загон — еды должно быть с запасом.",
completed(snap) {
return countAny(snap?.inventory, FOOD_ITEMS) >= 16;
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if ((inv.wheat_seeds ?? 0) > 0 && blockCount(snap, "crops") > 0) {
return { skillId: "farm.wheat" };
}
return { skillId: "survive.acquire-food" };
},
emergencyPause,
},
{
id: "iron_age",
title: "Железо и печь",
narration_ru: "Иду за железом — пора в шахту глубже.",
completed(snap) {
const inv = snap?.inventory ?? {};
return (inv.iron_ingot ?? 0) >= 3 || (inv.iron_pickaxe ?? 0) > 0;
},
suggestSkill(snap) {
// No iron-specific gather skill yet — operator-facing improvement.
return { skillId: "gather.stone" };
},
emergencyPause,
},
{
id: "settle_base",
title: "Постоянная база",
narration_ru: "Выбираю место под деревню — нужно нормальное основание.",
completed(snap) {
return blockCount(snap, "beds") >= 1 && blockCount(snap, "storage") >= 1;
},
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if ((inv.chest ?? 0) === 0 && countPlanks(inv) >= 8) return { skillId: "craft.chest" };
if ((inv.chest ?? 0) > 0) return { skillId: "village.place-chest" };
return { skillId: "village.choose-base" };
},
emergencyPause,
},
{
id: "village_grow",
title: "Развивать деревню",
narration_ru: "Стою на ногах — теперь строю по плану деревни.",
completed() { return false; }, // ongoing — never auto-completes
suggestSkill(snap) {
const inv = snap?.inventory ?? {};
if ((inv.chest ?? 0) > 0 && countAny(inv, FOOD_ITEMS) > 0) {
return { skillId: "village.deposit-surplus" };
}
return { skillId: "village.build-shelter" };
},
emergencyPause,
},
]);
export function getStep(id) {
return STORYLINE.find((s) => s.id === id) ?? null;
}
// Test exports
export const __testing = {
countLogs, countPlanks, countAny, hasAny, hasSetItem,
FOOD_ITEMS, BED_ITEMS, emergencyPause, blockCount, woodBudget, hasLocalFoodMob,
};
+210
View File
@@ -0,0 +1,210 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { STORYLINE, getStep, __testing } from "./storyline.js";
import { pickCurrentStep, progressSummary, _resetForTest } from "./state.js";
function snap(overrides = {}) {
return {
connected: true,
health: 20,
food: 20,
hasFood: false,
inventory: {},
equipment: { hand: null, head: null, torso: null, legs: null, feet: null },
nearbyBlocks: {},
hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
isDay: true,
hostileCount: 0,
closestHostile: null,
_sessionMs: 60_000,
...overrides,
};
}
test("STORYLINE: 11 steps, all have id/title/narration/completed/suggestSkill", () => {
assert.equal(STORYLINE.length, 11);
for (const s of STORYLINE) {
assert.ok(s.id, `step missing id`);
assert.ok(s.title);
assert.ok(s.narration_ru);
assert.equal(typeof s.completed, "function");
assert.equal(typeof s.suggestSkill, "function");
}
// Ids unique
const ids = STORYLINE.map((s) => s.id);
assert.equal(new Set(ids).size, ids.length);
});
test("getStep: lookup by id", () => {
assert.equal(getStep("first_wood").title, "Собрать стартовое дерево");
assert.equal(getStep("does-not-exist"), null);
});
test("emergencyPause: low hp + close hostile → true", () => {
const { emergencyPause } = __testing;
assert.equal(emergencyPause(snap({ health: 4, closestHostile: { name: "zombie", distance: 3 } })), true);
assert.equal(emergencyPause(snap()), false);
assert.equal(emergencyPause(snap({ hazards: { footBlock: "lava" } })), true);
assert.equal(emergencyPause(snap({ food: 0 })), true);
});
test("step first_wood: completed when bootstrap wood budget is enough", () => {
const s = getStep("first_wood");
assert.equal(s.completed(snap()), false);
assert.equal(s.completed(snap({ inventory: { oak_log: 4 } })), true);
assert.equal(s.completed(snap({ inventory: { oak_log: 2, oak_planks: 8 } })), true);
assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), true);
});
test("step first_wood: suggest gather.logs if trees nearby, explore.far otherwise", () => {
const s = getStep("first_wood");
assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: 5 } })).skillId, "gather.logs");
assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: { count: 1 } } })).skillId, "gather.logs");
assert.equal(s.suggestSkill(snap()).skillId, "explore.far");
});
test("step first_tools: requires all three wood tools", () => {
const s = getStep("first_tools");
assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), false);
assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1 } })), false);
assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 } })), true);
// Higher tier also counts
assert.equal(s.completed(snap({ inventory: { stone_pickaxe: 1, stone_axe: 1, stone_sword: 1 } })), true);
});
test("step first_food: completed at ≥2 food items, OR when sated (food≥14)", () => {
const s = getStep("first_food");
// hungry + no food → not done
assert.equal(s.completed(snap({ food: 8 })), false);
// hungry + 2 food items → done (have a stock)
assert.equal(s.completed(snap({ food: 8, inventory: { bread: 2 } })), true);
// sated (food≥14) + no food → done (don't chase food while full)
assert.equal(s.completed(snap({ food: 17 })), true);
});
test("step first_food: local hunt only for edible passive mobs within acquire range", () => {
const s = getStep("first_food");
assert.equal(
s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 18 }] } })).skillId,
"survive.acquire-food",
);
assert.equal(
s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "chicken", distance: 51 }] } })).skillId,
"survive.scout-food",
);
assert.equal(
s.suggestSkill(snap({ nearbyEntities: { passives: [{ name: "cod", distance: 12 }] } })).skillId,
"survive.scout-food",
);
});
test("step shelter_minimal: completed when bed placed nearby", () => {
const s = getStep("shelter_minimal");
assert.equal(s.completed(snap()), false);
assert.equal(s.completed(snap({ nearbyBlocks: { beds: 1 } })), true);
assert.equal(s.completed(snap({ nearbyBlocks: { beds: { count: 1 } } })), true);
});
test("step stone_tier: needs cobblestone first", () => {
const s = getStep("stone_tier");
assert.equal(s.completed(snap()), false);
assert.equal(s.suggestSkill(snap()).skillId, "gather.stone");
assert.equal(s.suggestSkill(snap({ inventory: { cobblestone: 6, stick: 4 } })).skillId, "craft.stone-pickaxe");
});
test("village_grow: never auto-completes (ongoing)", () => {
const s = getStep("village_grow");
assert.equal(s.completed(snap({ inventory: { iron_pickaxe: 1, diamond_pickaxe: 1 } })), false);
});
test("pickCurrentStep: disconnected → null", () => {
_resetForTest();
assert.equal(pickCurrentStep({ connected: false }), null);
});
test("pickCurrentStep: fresh spawn → first non-completed step", () => {
_resetForTest();
const s = snap({ _sessionMs: 5_000, nearbyBlocks: {} });
const r = pickCurrentStep(s);
assert.ok(r);
// orient_self is the first; with no nearby blocks and short session,
// completed() returns false → picked.
assert.equal(r.step.id, "orient_self");
assert.equal(r.index, 0);
});
test("orient_self: barren biome timeout fallback completes step after 120s", () => {
const n = getStep("orient_self");
// path (a): full HP + no visible blocks + short session → NOT done (would block).
assert.equal(n.completed(snap({ _sessionMs: 30_000, nearbyBlocks: {} })), false);
// path (b): full HP + no visible blocks + long session → done (timeout fallback).
assert.equal(n.completed(snap({ _sessionMs: 150_000, nearbyBlocks: {} })), true);
// always: low HP → not done
assert.equal(n.completed(snap({ health: 8, _sessionMs: 150_000 })), false);
});
test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basics", () => {
_resetForTest();
const r = pickCurrentStep(snap({
_sessionMs: 60_000,
nearbyBlocks: { logs: 3 },
inventory: { oak_log: 10 },
}));
assert.ok(r);
assert.equal(r.step.id, "crafting_basics");
assert.equal(r.completedSteps, 2, "orient_self + first_wood done");
});
test("pickCurrentStep: bot with planks and sticks advances to first_tools", () => {
_resetForTest();
const r = pickCurrentStep(snap({
_sessionMs: 60_000,
nearbyBlocks: { logs: { count: 3 } },
inventory: { oak_planks: 16, stick: 4 },
}));
assert.ok(r);
assert.equal(r.step.id, "first_tools");
assert.equal(r.suggestion.skillId, "craft.wooden-pickaxe");
});
test("pickCurrentStep: emergency pauses suggestion", () => {
_resetForTest();
const r = pickCurrentStep(snap({
health: 4,
closestHostile: { name: "zombie", distance: 3 },
nearbyBlocks: { logs: 2 },
}));
assert.ok(r);
assert.equal(r.emergency, true);
assert.equal(r.suggestion, null, "no concrete suggestion while emergency holds");
});
test("pickCurrentStep: rejects unknown skill ids from suggestSkill", () => {
_resetForTest();
// Inject a synthetic step with bogus skill — but STORYLINE is frozen,
// so we just verify that real ids are valid (sanity check).
const r = pickCurrentStep(snap({
_sessionMs: 60_000,
nearbyBlocks: { logs: 2 },
}));
if (r?.suggestion?.skillId) {
// All real STORYLINE skill ids should be registered.
// (skill-registry imports a frozen list of skills/index.js.)
assert.ok(r.suggestion.skillId.includes("."), "skill id is namespaced");
}
});
test("progressSummary: formats step n/N + skill + emergency tag", () => {
_resetForTest();
const s1 = progressSummary(snap({ _sessionMs: 5_000 }));
assert.match(s1, /step 1\/11/);
assert.match(s1, /orient_self/);
_resetForTest();
const s2 = progressSummary(snap({
health: 3,
closestHostile: { name: "creeper", distance: 2 },
}));
assert.match(s2, /EMERGENCY/);
});
+63 -10
View File
@@ -334,16 +334,28 @@ export function createImprovementRequest({
} = {}) {
if (!_isAvailable() || !title) return null;
try {
// Dedup: if an open request with same title (case-insensitive) exists,
// bump its votes instead of inserting a new row.
const dup = _getStore().prepare(`
SELECT id, votes FROM improvement_requests
WHERE LOWER(title) = LOWER(?) AND status = 'open'
ORDER BY ts DESC LIMIT 1
`).get(title);
if (dup) {
_getStore().prepare(`UPDATE improvement_requests SET votes = votes + 1 WHERE id = ?`).run(dup.id);
return dup.id;
// Fuzzy dedup: the LLM re-files the same gap with reworded titles
// ("целевого поиска еды" vs "целевого дальнего поиска еды по биому").
// Exact-title match misses these, so we compare normalized token
// sets against ALL recent requests (any status — a closed/
// implemented one shouldn't reappear as a fresh open row). On a
// strong overlap we bump votes (if still open) and return, instead
// of inserting a near-duplicate.
const incoming = tokenize(title);
const candidates = _getStore().prepare(`
SELECT id, title, status, votes FROM improvement_requests
ORDER BY ts DESC LIMIT 60
`).all();
for (const c of candidates) {
if (isDuplicateTitle(incoming, tokenize(c.title))) {
// Re-flagged gap. If it's still open, count the vote. If it
// was implemented/rejected, do NOT resurrect it — just
// return its id so the caller treats it as "already known".
if (c.status === "open") {
_getStore().prepare(`UPDATE improvement_requests SET votes = votes + 1 WHERE id = ?`).run(c.id);
}
return c.id;
}
}
const res = _getStore().prepare(`
INSERT INTO improvement_requests
@@ -412,7 +424,48 @@ export function markImprovementStatus(id, { status, notes } = {}) {
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, Number(n) || lo)); }
// Normalize a title into a set of meaningful tokens for fuzzy dedup.
// Lowercase, strip punctuation, drop short / stop words (RU + EN) that
// carry no signal ("нет", "навыка", "для", "the", "a", …).
const _STOP = new Set([
"нет", "навык", "навыка", "для", "из", "по", "в", "на", "и", "с", "к",
"когда", "нужно", "это", "что", "the", "a", "an", "to", "of", "for",
"no", "skill", "has", "is", "low", "rate",
]);
function tokenize(s) {
if (!s || typeof s !== "string") return new Set();
const words = s.toLowerCase()
.replace(/["'`(),.:;!?\/\\-]+/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3 && !_STOP.has(w));
return new Set(words);
}
function jaccard(a, b) {
if (a.size === 0 || b.size === 0) return 0;
let inter = 0;
for (const w of a) if (b.has(w)) inter++;
return inter / (a.size + b.size - inter);
}
function intersectionSize(a, b) {
let n = 0;
for (const w of a) if (b.has(w)) n++;
return n;
}
// Two titles are "the same gap" when they either overlap very strongly
// (jaccard ≥ 0.75) OR share at least 3 meaningful tokens with moderate
// overlap (≥ 0.5). The 3-token floor stops short titles with one or two
// generic words in common ("low-prio thing" vs "high-prio thing") from
// false-matching, while still catching reworded long titles.
function isDuplicateTitle(a, b) {
const j = jaccard(a, b);
if (j >= 0.75) return true;
return intersectionSize(a, b) >= 3 && j >= 0.5;
}
function safeParse(s) {
if (!s) return null;
try { return JSON.parse(s); } catch { return null; }
}
// Test exports
export const __testing = { tokenize, jaccard, isDuplicateTitle };
+36
View File
@@ -320,6 +320,42 @@ test("improvement requests: create, dedup-by-title bumps votes, list filters", a
assert.ok(!stillOpen.some((r) => r.id === id1));
});
test("improvement requests: fuzzy dedup catches reworded titles + won't resurrect closed ones", async () => {
await bootstrap();
if (!isAvailable()) return;
const a = createImprovementRequest({
source: "postmortem", category: "skill",
title: "Нет навыка целевого поиска еды по биому", priority: 1,
});
assert.ok(a);
// Reworded near-duplicate — should map to the SAME row, bump votes.
const b = createImprovementRequest({
source: "reflect", category: "skill",
title: "Нет навыка целевого дальнего поиска еды по биому", priority: 1,
});
assert.equal(b, a, "reworded title deduped to original");
// Mark it implemented, then the LLM re-files the same gap reworded
// again — must NOT create a fresh open row (no resurrection).
markImprovementStatus(a, { status: "implemented", notes: "scout-food" });
const c = createImprovementRequest({
source: "reflect", category: "skill",
title: "Нужен навык дальнего поиска еды по биому", priority: 1,
});
assert.equal(c, a, "re-filed closed gap returns existing id, no new row");
const open = listImprovements({ status: "open" });
assert.ok(!open.some((r) => r.id === a), "closed gap stays closed");
});
test("tokenize / jaccard: similarity helpers", async () => {
const { tokenize, jaccard } = (await import("./index.js")).__testing;
const t1 = tokenize("Нет навыка целевого поиска еды по биому");
const t2 = tokenize("Нет навыка целевого дальнего поиска еды по биому");
assert.ok(jaccard(t1, t2) >= 0.6, `expected ≥0.6, got ${jaccard(t1, t2)}`);
const t3 = tokenize("Trigger wedged_96s has low success rate");
assert.ok(jaccard(t1, t3) < 0.3, "unrelated titles score low");
});
test("improvement requests: priority and status ordering", async () => {
await bootstrap();
if (!isAvailable()) return;
+31 -9
View File
@@ -37,6 +37,7 @@ const BED_ITEMS = [
"lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed",
"purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed",
];
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
function hasAny(inv, names) {
if (!inv) return false;
@@ -77,6 +78,18 @@ function hostileImminent(s) {
return (h.distance ?? Infinity) < 8;
}
function hasVisibleFoodTarget(s) {
const passives = s?.nearbyEntities?.passives ?? [];
return passives.some((e) => PASSIVE_FOOD_MOBS.has(e.name) && (e.distance ?? Infinity) <= 32);
}
function blockCount(s, kind) {
const v = s?.nearbyBlocks?.[kind];
if (typeof v === "number") return v;
if (v && typeof v.count === "number") return v.count;
return 0;
}
function aliveDetect(s) {
if (!s?.connected) return true; // not connected, nothing to do
const hp = s.health ?? 20;
@@ -98,7 +111,7 @@ function alivePursue(s) {
return { skillId: "survive.eat" };
}
if (food <= 0 && !s.hasFood) {
return { skillId: "survive.acquire-food" };
return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
}
if (hostileImminent(s)) {
return { skillId: "survive.flee" };
@@ -109,9 +122,18 @@ function alivePursue(s) {
return null;
}
// SATED_FOOD — above this hunger level the bot is NOT hungry; chasing
// food (scout/acquire) is wasted motion. The bot should keep working
// toward wood/tools/shelter and pick up food opportunistically. It
// only becomes a hard need again when the bar drops below this.
const SATED_FOOD = 14;
function foodDetect(s) {
if (!s?.connected) return true;
if ((s.food ?? 20) >= 18 && countAny(s.inventory, FOOD_ITEMS) >= 1) return true;
// Comfortable hunger bar → treat L1 as satisfied even with an empty
// food inventory. (Was: required 6+ food items, which made a sated
// bot loop scout/acquire-food for hours doing nothing else.)
if ((s.food ?? 20) >= SATED_FOOD) return true;
return countAny(s.inventory, FOOD_ITEMS) >= 6;
}
@@ -119,7 +141,7 @@ function foodPursue(s) {
if ((s.food ?? 20) < 16 && s.hasFood) {
return { skillId: "survive.eat" };
}
return { skillId: "survive.acquire-food" };
return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
}
function toolsWoodDetect(s) {
@@ -134,7 +156,7 @@ function toolsWoodPursue(s) {
const logs = countLogs(inv);
const sticks = inv.stick ?? 0;
const hasWb = (inv.crafting_table ?? 0) > 0
|| (s.nearbyBlocks?.craftingTable ?? 0) > 0;
|| blockCount(s, "craftingTable") > 0;
if (logs < 2 && planks < 4 && !hasWb) {
return { skillId: "gather.logs" };
@@ -159,7 +181,7 @@ function toolsWoodPursue(s) {
function shelterBasicDetect(s) {
const inv = s?.inventory ?? {};
const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0;
const bedPlaced = blockCount(s, "beds") > 0;
return bedPlaced || hasAny(inv, BED_ITEMS);
}
@@ -233,10 +255,10 @@ function foodSecurityDetect(s) {
}
function foodSecurityPursue(s) {
if ((s.inventory?.wheat_seeds ?? 0) > 0 && (s.nearbyBlocks?.crops ?? 0) > 0) {
if ((s.inventory?.wheat_seeds ?? 0) > 0 && blockCount(s, "crops") > 0) {
return { skillId: "farm.wheat" };
}
return { skillId: "survive.acquire-food" };
return { skillId: hasVisibleFoodTarget(s) ? "survive.acquire-food" : "survive.scout-food" };
}
function toolsIronDetect(s) {
@@ -265,7 +287,7 @@ function villageSeedDetect(s) {
// Heuristic: at least one chest placed AND one bed placed within
// nearby radius. Tightens later (POIs of kind "structure").
const nb = s?.nearbyBlocks ?? {};
return (nb.storage ?? 0) >= 1 && (nb.beds ?? 0) >= 1;
return blockCount(s, "storage") >= 1 && blockCount(s, "beds") >= 1;
}
function villageSeedPursue(s) {
@@ -310,5 +332,5 @@ export function getNeed(id) {
// Test exports
export const __testing = {
hasAny, countAny, countLogs, countPlanks,
FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY,
FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY, hasVisibleFoodTarget, blockCount,
};
+35 -1
View File
@@ -54,13 +54,38 @@ test("L0 alive: zero food and have food → eat", () => {
assert.equal(n.pursue(s).skillId, "survive.eat");
});
test("L0 alive: zero food and no food → acquire", () => {
test("L0 alive: zero food and no visible target → scout-food", () => {
const n = getNeed("alive");
const s = snap({ food: 0, hasFood: false });
assert.equal(n.detect(s), false);
assert.equal(n.pursue(s).skillId, "survive.scout-food");
});
test("L0 alive: zero food with visible passive → acquire", () => {
const n = getNeed("alive");
const s = snap({
food: 0,
hasFood: false,
nearbyEntities: { passives: [{ name: "cow", distance: 12 }], droppedItems: [] },
});
assert.equal(n.detect(s), false);
assert.equal(n.pursue(s).skillId, "survive.acquire-food");
});
test("L0 alive: far or non-food passives do not trigger local acquire", () => {
const n = getNeed("alive");
assert.equal(n.pursue(snap({
food: 0,
hasFood: false,
nearbyEntities: { passives: [{ name: "chicken", distance: 51 }], droppedItems: [] },
})).skillId, "survive.scout-food");
assert.equal(n.pursue(snap({
food: 0,
hasFood: false,
nearbyEntities: { passives: [{ name: "cod", distance: 12 }], droppedItems: [] },
})).skillId, "survive.scout-food");
});
test("L1 food: 6+ food items → satisfied", () => {
const n = getNeed("food");
assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true);
@@ -73,6 +98,13 @@ test("L1 food: full saturation + any food → satisfied (no panic gathering)", (
assert.equal(n.detect(snap({ food: 20, inventory: { bread: 3 } })), true);
});
test("L1 food: no local food target uses scout-food instead of local acquire loop", () => {
const n = getNeed("food");
const s = snap({ food: 10, hasFood: false, inventory: {} });
assert.equal(n.detect(s), false);
assert.equal(n.pursue(s).skillId, "survive.scout-food");
});
test("L2 tools_wood: starts with no logs → gather.logs", () => {
const n = getNeed("tools_wood");
const s = snap();
@@ -105,6 +137,7 @@ test("L2 tools_wood: progression to pickaxe → axe → sword", () => {
test("L3 shelter_basic: bed nearby → satisfied", () => {
const n = getNeed("shelter_basic");
assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1 } })), true);
assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 } } })), true);
assert.equal(n.detect(snap({ inventory: { red_bed: 1 } })), true);
assert.equal(n.detect(snap()), false);
});
@@ -169,6 +202,7 @@ test("L8 armor_iron: iron_chestplate equipped → satisfied", () => {
test("L9 village_seed: bed + storage nearby → satisfied", () => {
const n = getNeed("village_seed");
assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true);
assert.equal(n.detect(snap({ nearbyBlocks: { beds: { count: 1 }, storage: { count: 1 } } })), true);
});
test("L9 village_seed: no chest → craft.chest if enough planks", () => {
+11 -5
View File
@@ -30,14 +30,20 @@ test("pickActiveNeed: fresh spawn → L0 alive if zero food", () => {
_resetForTest();
const a = pickActiveNeed(snap({ food: 0 }));
assert.equal(a.need.id, "alive");
assert.equal(a.skillId, "survive.acquire-food");
assert.equal(a.skillId, "survive.scout-food");
});
test("pickActiveNeed: hp ok, no food in inventory → L1 food (acquire)", () => {
test("pickActiveNeed: hungry (food<14), no food in inventory → L1 food (scout)", () => {
_resetForTest();
const a = pickActiveNeed(snap({ food: 8 }));
assert.equal(a.need.id, "food");
assert.equal(a.skillId, "survive.scout-food");
});
test("pickActiveNeed: sated (food=20) + empty inventory → skips L1, goes to L2 tools_wood", () => {
_resetForTest();
const a = pickActiveNeed(snap());
assert.equal(a.need.id, "food");
assert.equal(a.skillId, "survive.acquire-food");
assert.equal(a.need.id, "tools_wood", "sated bot works toward tools, not chasing food");
});
test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => {
@@ -103,7 +109,7 @@ test("pickActiveNeed: hostile imminent + low HP → L0 takes over", () => {
test("describeActiveNeed: returns 'L<n> <id> → <skill>'", () => {
_resetForTest();
const s = describeActiveNeed(snap());
const s = describeActiveNeed(snap({ food: 8 }));
assert.match(s, /^L1 food → /);
});
+18 -2
View File
@@ -94,6 +94,7 @@ let _last = {
threatHostile: null,
dayPart: null,
noProgressReason: null,
storyStepId: null,
};
let _lastNarrationAt = 0;
let _narrationTimes = [];
@@ -164,6 +165,15 @@ function tick() {
_last.noProgressReason = snap.noProgressReason;
}
// 4b. Storyline step transition — narrate the *narration_ru* line
// straight from runtime/goal/storyline.js when the step changes.
// This is the bot speaking about its current quest concretely.
const story = snap.storyStep ?? null;
if (story?.step?.id && story.step.id !== _last.storyStepId && story.step.narration_ru) {
maybeNarrateRaw(story.step.narration_ru);
_last.storyStepId = story.step.id;
}
// 5. Milestone done — fires when activeSkill flips to noop and lastResult.ok
const last = snap.lastResult;
if (last?.ok && last?.code === "done") {
@@ -200,13 +210,19 @@ function inferDayPart(snap) {
}
function maybeNarrate(key) {
const line = pickLine(key);
if (!line) return;
maybeNarrateRaw(line);
}
function maybeNarrateRaw(line) {
if (!line) return;
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;
if (line === _lastTemplate) return;
const ok = sendChat(line);
if (ok) {
_lastNarrationAt = now;
+131 -10
View File
@@ -29,6 +29,8 @@ import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "
import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js";
import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js";
import { pickActiveNeed } from "./manifesto/state.js";
import { pickCurrentStep } from "./goal/state.js";
import { observe as observeWedge, isWedged } from "./awareness/wedge-detector.js";
import { situationHash } from "./scenario-memory.js";
import { tickModes } from "./modes.js";
@@ -373,6 +375,39 @@ function metricRecoverySkill(ctx, plannedSkillId) {
return null;
}
function checkSkillPreconditions(ctx, skillId, args = {}) {
const skill = getSkill(skillId);
if (!skill) return { ok: false, code: "unknown_skill", detail: skillId };
try {
return skill.preconditions(ctx, args) ?? { ok: true };
} catch (e) {
return { ok: false, code: "precondition_failed", detail: e?.message ?? String(e) };
}
}
function resolveAdvisorSkill(ctx, rec, currentSkillId) {
if (!rec?.skillId) return null;
const pre = checkSkillPreconditions(ctx, rec.skillId);
if (pre.ok) return rec.skillId;
// The most common stale/under-specified advice is "switch to local
// acquire-food" when no passive mob exists in the entity horizon.
// Treat that as the broader food-search intent and route to scout-food.
if (rec.skillId === "survive.acquire-food" && pre.code === "no_target") {
const scoutPre = checkSkillPreconditions(ctx, "survive.scout-food");
if (scoutPre.ok) {
info(REFLEX_LOG, `advisor correction: ${rec.skillId} has no local target; using survive.scout-food`);
return "survive.scout-food";
}
}
info(
REFLEX_LOG,
`advisor ignored: ${currentSkillId}${rec.skillId} failed preconditions (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`,
);
return null;
}
// v0.2.0-rc.3 — wedged-emergency escape. When the bot has not made
// meaningful horizontal progress for ≥ 60s AND there's no immediate
// hostile (defendReflex would have handled it) AND a placeable block
@@ -441,6 +476,10 @@ function curriculumReflex(ctx) {
const plan = s.curriculum?.plan;
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
const scoutFoodHintUntil = ctx.skillBackoff?.["__scout_food_hint__"] ?? 0;
const wantScoutFood = scoutFoodHintUntil && Date.now() < scoutFoodHintUntil;
const relocateHintUntil = ctx.skillBackoff?.["__relocate_hint__"] ?? 0;
const wantRelocate = relocateHintUntil && Date.now() < relocateHintUntil;
// v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the
// lowest unsatisfied need dictates the planned skill. The curriculum
@@ -457,6 +496,38 @@ function curriculumReflex(ctx) {
ctx.activeNeed = activeNeed;
}
const manifestoSkillId = activeNeed?.skillId ?? null;
// v0.3.1 — wedge detector. Feeds position into rolling-bbox tracker.
// When bot has been stuck in a <50-block bbox for 10 minutes with
// the same need cycling its skills, returns wedged=true and we
// short-circuit to village.relocate which walks 300 blocks in a
// fresh cardinal. Tests pass ctx.disableWedge=true to skip.
if (!ctx.disableWedge && s.position) {
observeWedge({
x: s.position.x, z: s.position.z,
activeNeedId: activeNeed?.need?.id ?? null,
recentSkillIds: ctx.recentSkillIds ?? [],
});
const wedge = isWedged({
activeNeedId: activeNeed?.need?.id ?? null,
recentSkillIds: ctx.recentSkillIds ?? [],
});
if (wedge.wedged) {
ctx.lastCurriculumAt = Date.now();
info(REFLEX_LOG, `wedged: bbox=${Math.round(wedge.bboxDim)}b need=${activeNeed?.need?.id} for ${Math.round(wedge.needAgeMs / 1000)}s → village.relocate`);
ctx.dispatch(() => runSkill("village.relocate", ctx), "village.relocate", {});
return { action: "dispatched", kind: "wedge-relocate", label: "village.relocate" };
}
}
// v0.3.1 — storyline: the canonical Minecraft survival quest. Gives
// the bot a concrete, narratable next-action ("collect 8 logs",
// "place crafting table"). Storyline yields to manifesto on L0
// alive emergencies but otherwise its suggestion is preferred over
// the curriculum plan when it picks a registered skill.
const storyStep = ctx.disableStoryline ? null : pickCurrentStep(s);
if (storyStep) ctx.storyStep = storyStep;
const storySkillId = (storyStep && !storyStep.emergency && storyStep.suggestion?.skillId) ? storyStep.suggestion.skillId : null;
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
if (metricRecovery) {
ctx.lastCurriculumAt = Date.now();
@@ -491,11 +562,29 @@ function curriculumReflex(ctx) {
}
}
if (wantRelocate || wantScoutFood) {
ctx.lastCurriculumAt = Date.now();
ctx.skillBackoff = ctx.skillBackoff ?? {};
const hintSkillId = wantRelocate ? "village.relocate" : "survive.scout-food";
const hintKey = wantRelocate ? "__relocate_hint__" : "__scout_food_hint__";
ctx.skillBackoff[hintKey] = 0;
const pre = checkSkillPreconditions(ctx, hintSkillId);
if (pre.ok) {
ctx.dispatch(() => runSkill(hintSkillId, ctx), hintSkillId, {});
return {
action: "dispatched",
kind: wantRelocate ? "curriculum-recovery-relocate" : "curriculum-recovery-scout-food",
label: hintSkillId,
};
}
info(REFLEX_LOG, `recovery hint ${hintSkillId} skipped (${pre.code}: ${String(pre.detail ?? "").slice(0, 80)})`);
}
// No skill plan from curriculum OR a recent skill asked us to wander.
// First hint → small wander (might just be 32-block reach issue).
// Every subsequent hint while still inside the backoff window → use
// explore.far so the bot actually leaves the patch it's stuck in.
if ((!plan?.skillId && !manifestoSkillId) || wantWander) {
if ((!plan?.skillId && !manifestoSkillId && !storySkillId) || wantWander) {
ctx.lastCurriculumAt = Date.now();
const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander";
// v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without
@@ -530,10 +619,29 @@ function curriculumReflex(ctx) {
return { action: "dispatched", kind: "curriculum-wander", label: "wander" };
}
// Pick what to dispatch: manifesto wins over curriculum plan because
// it expresses concrete needs rather than abstract "next milestone".
let skillId = manifestoSkillId ?? plan.skillId;
let skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
// Pick what to dispatch. Order:
// 1. manifesto L0 (alive emergencies: low HP near hostile, lava
// under foot, food=0) — absolute priority; do NOT let
// storyline overrule a "you're dying" signal.
// 2. storyline — concrete narrative subgoal ("collect 8 logs",
// "craft wooden pickaxe"). Beats manifesto L1+ because the
// ladder needs operational direction, not just "you need food
// → dispatch acquire-food forever".
// 3. manifesto L1+ — fallback when storyline has no concrete
// pursue (e.g. armor levels with pursue=null).
// 4. curriculum plan — legacy fallback.
const manifestoEmergency = activeNeed?.need?.level === 0;
let skillId, skillSource;
if (manifestoEmergency) {
skillId = manifestoSkillId ?? storySkillId ?? plan.skillId;
skillSource = `manifesto:${activeNeed.need.id}`;
} else if (storySkillId) {
skillId = storySkillId;
skillSource = `storyline:${storyStep.step.id}`;
} else {
skillId = manifestoSkillId ?? plan.skillId;
skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum";
}
// v0.3.0 fast-advisor: if a fresh recommendation is sitting on ctx
// (the result of a previous tick's async advise() call), use it.
@@ -542,11 +650,16 @@ function curriculumReflex(ctx) {
if (!ctx.disableAdvisor) {
const rec = consumeFreshRecommendation(ctx);
if (rec && rec.skillId) {
info(REFLEX_LOG, `advisor override: ${skillId}${rec.skillId} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`);
skillId = rec.skillId;
skillSource = `advisor:${rec.triggerReason}`;
appliedRecommendationId = rec.id ?? null;
if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId);
const resolved = resolveAdvisorSkill(ctx, rec, skillId);
if (resolved) {
info(REFLEX_LOG, `advisor override: ${skillId}${resolved} (${rec.triggerReason}, ${rec.rationale?.slice(0, 60)})`);
skillId = resolved;
skillSource = `advisor:${rec.triggerReason}`;
appliedRecommendationId = rec.id ?? null;
if (appliedRecommendationId) markRecommendationApplied(appliedRecommendationId);
} else if (rec.id) {
markRecommendationOutcome(rec.id, { ok: false, code: "precondition_failed" });
}
}
// Always fire-and-forget another advise() if triggers fire — the
// result lands on a future tick. tickAdvisor handles its own
@@ -619,6 +732,14 @@ function curriculumReflex(ctx) {
ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS;
consecutiveWanderHints++;
}
if (res?.recovery?.hint === "scout-food") {
ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS;
ctx.skillBackoff["__scout_food_hint__"] = Date.now() + SKILL_BACKOFF_MS;
}
if (res?.recovery?.hint === "relocate") {
ctx.skillBackoff[dispatchSkillId] = Date.now() + SKILL_BACKOFF_MS;
ctx.skillBackoff["__relocate_hint__"] = Date.now() + SKILL_BACKOFF_MS;
}
if (!res?.ok) {
// missing_tool / missing_material / no_target shouldn't be
// retried on the very next tick. Hold for SKILL_BACKOFF_MS.
+63 -2
View File
@@ -51,6 +51,8 @@ function makeCtx({
// runtime/manifesto/state.test.js separately.
disableAdvisor = true, // advisor-trigger fires real async LLM calls,
// tested directly in advisor-trigger.test.js.
disableStoryline = true, // storyline tested in goal/storyline.test.js
disableWedge = true, // wedge tested in awareness/wedge-detector.test.js
} = {}) {
const dispatches = [];
const ctx = {
@@ -65,6 +67,8 @@ function makeCtx({
metrics,
disableManifesto,
disableAdvisor,
disableStoryline,
disableWedge,
dispatch(fn, label, opts = {}) {
dispatches.push({ fn, label, opts });
},
@@ -240,9 +244,10 @@ test("curriculum dispatches suggested skill by id", () => {
assert.ok(typeof dispatches[0].opts.onComplete === "function");
});
test("manifesto: hungry bot with no food drives survive.acquire-food (overrides curriculum plan)", () => {
test("manifesto: hungry bot with no visible food drives survive.scout-food (manifesto fallback when storyline disabled)", () => {
const { ctx, dispatches } = makeCtx({
disableManifesto: false,
disableStoryline: true,
snapshot: {
connected: true,
health: 20,
@@ -258,10 +263,66 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (overrides
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
assert.equal(dispatches[0].label, "survive.acquire-food", "manifesto L1 food took over");
assert.equal(dispatches[0].label, "survive.scout-food", "manifesto L1 food took over");
assert.equal(ctx.activeNeed?.need?.id, "food");
});
test("L0 manifesto emergency: defend/modes layer catches the threat BEFORE curriculum", () => {
// HP=4 + creeper@3m fires `modes` (self_preservation) or defendReflex
// before curriculum even runs — which is the right outcome: alive
// emergencies don't reach the storyline/manifesto branch at all.
const { ctx, dispatches } = makeCtx({
disableManifesto: false,
disableStoryline: false,
bot: { entities: { z1: { name: "creeper", height: 1.7, position: { x: 3, y: 64, z: 0, distanceTo: () => 3 } } }, entity: { position: { x: 0, y: 64, z: 0 } } },
snapshot: {
connected: true,
health: 4,
food: 12,
hasFood: false,
inventory: { oak_log: 10 },
equipment: {},
nearbyBlocks: { logs: 4 },
hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
closestHostile: { name: "creeper", distance: 3 },
threats: [{ name: "creeper", distance: 3, position: { x: 3, y: 64, z: 0 } }],
isDay: true,
curriculum: { plan: { skillId: "gather.logs" } },
},
});
const out = runTick(ctx);
assert.notEqual(out?.kind, "curriculum-skill", "an upstream reflex caught the emergency before curriculum");
});
test("storyline beats manifesto L1+ when both have suggestions", () => {
// Snapshot: well-fed (food=20 + 8 bread → manifesto L1 satisfied, L2
// tools_wood unmet) AND storyline orient_self complete (HP=20, blocks
// visible). Storyline should drive a wood-tier crafting step, not
// manifesto's gather.logs (which would also be valid but less concrete).
const { ctx, dispatches } = makeCtx({
disableManifesto: false,
disableStoryline: false,
snapshot: {
connected: true,
health: 20,
food: 20,
hasFood: true,
inventory: { bread: 8, oak_log: 10 }, // logs done, no planks
equipment: {},
nearbyBlocks: { logs: 3 },
hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
isDay: true,
_sessionMs: 60_000,
curriculum: { plan: { skillId: "explore.far" } },
},
});
const out = runTick(ctx);
assert.equal(out.reflex, "curriculum");
// Storyline step crafting_basics suggests craft.planks (10 logs, no planks yet).
assert.equal(dispatches[0].label, "craft.planks");
assert.equal(ctx.storyStep?.step?.id, "crafting_basics");
});
test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => {
const { ctx, dispatches } = makeCtx({
disableManifesto: false,
+51 -4
View File
@@ -8,6 +8,7 @@ const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
import { foods } from "./groups.js";
import { blindWalkOrTunnelOut } from "./explore-far.js";
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
@@ -61,6 +62,41 @@ function nearbyDroppedItems(bot, maxDistance = 8) {
.sort((a, b) => a.distance - b.distance);
}
function horizontalDistance(a, b) {
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
}
function yawToward(from, to) {
if (!from || !to) return null;
const dx = to.x - from.x;
const dz = to.z - from.z;
if (Math.hypot(dx, dz) < 0.5) return null;
return -Math.atan2(dx, dz);
}
async function fallbackApproachFoodMob(bot, target, err) {
try { bot.pathfinder?.stop?.(); } catch {}
const start = bot.entity.position.clone?.() ?? { ...bot.entity.position };
const alreadyMoved = horizontalDistance(start, bot.entity.position);
if (alreadyMoved >= 4) {
return { ok: true, moved: alreadyMoved, mode: "pathfinder_partial", error: err?.message ?? "path failed" };
}
const yaw = yawToward(bot.entity.position, target.entity.position);
if (yaw === null) return { ok: false, moved: 0, error: err?.message ?? "path failed" };
const blind = await blindWalkOrTunnelOut(bot, {
yaw,
dirName: `toward-${target.entity.name}`,
blindMs: 8_000,
minMove: 4,
reason: `acquire-food target ${target.entity.name}`,
});
const moved = horizontalDistance(start, bot.entity.position);
if (blind.ok || moved >= 4) {
return { ok: true, moved, mode: "blind_target", error: err?.message ?? "path failed" };
}
return { ok: false, moved, error: err?.message ?? "path failed" };
}
async function pickupNearbyDrops(bot) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
@@ -115,7 +151,18 @@ export const skill = Object.freeze({
"pathToFoodMob",
);
} catch (e) {
return { ok: false, code: "no_path", detail: e.message, worldDelta: null };
const approached = await fallbackApproachFoodMob(bot, target, e);
const current = Object.values(bot.entities ?? {}).find((entity) => entity.id === target.entity.id);
const dist = current?.position?.distanceTo(bot.entity.position) ?? Infinity;
if (!approached.ok) return { ok: false, code: "no_path", detail: e.message, worldDelta: null };
if (dist > 4) {
return {
ok: false,
code: "approached_target",
detail: { target: target.entity.name, moved: Math.round(approached.moved), mode: approached.mode, error: approached.error },
worldDelta: { moved: Math.round(approached.moved), target: target.entity.name, mode: approached.mode },
};
}
}
info("action", `survive.acquire-food: hunting ${target.entity.name} (${target.distance.toFixed(1)}m)`);
@@ -153,11 +200,11 @@ export const skill = Object.freeze({
}
},
recover(ctx, result) {
if (result.code === "no_target" || result.code === "no_path") {
return { hint: "wander", reason: "need to search for passive food mobs" };
if (result.code === "no_target" || result.code === "no_path" || result.code === "approached_target" || result.code === "no_drop") {
return { hint: "scout-food", reason: "need a long-range food search, not local acquire-food retry" };
}
return null;
},
});
export const _internal = { foodCount, nearestPassiveFoodMob };
export const _internal = { foodCount, nearestPassiveFoodMob, yawToward, horizontalDistance };
+34
View File
@@ -8,6 +8,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js";
import { __testing as scoutFoodTesting } from "./scout-food.js";
const ctx = {}; // skills under test ignore ctx fully
@@ -37,6 +38,27 @@ test("preconditions gate execution", async () => {
}
});
test("precondition failures can return recovery hints", async () => {
const teardown = _registerForTest({
id: "test.precondition-recover",
title: "blocked with recovery",
timeoutMs: 1000,
preconditions: () => ({ ok: false, code: "no_target", detail: "none nearby" }),
execute: async () => {
throw new Error("should not run");
},
recover: (_ctx, result) => ({ hint: "scout-food", saw: result.code }),
});
try {
const res = await runSkill("test.precondition-recover", ctx);
assert.equal(res.ok, false);
assert.equal(res.code, "no_target");
assert.deepEqual(res.recovery, { hint: "scout-food", saw: "no_target" });
} finally {
teardown();
}
});
test("gather.logs precondition refuses nearby hostiles", async () => {
const bot = { registry: { blocksByName: { oak_log: { id: 1 } } } };
const res = await runSkill("gather.logs", {
@@ -48,6 +70,18 @@ test("gather.logs precondition refuses nearby hostiles", async () => {
assert.match(res.detail, /unsafe to gather logs: drowned 6\.1 blocks away/);
});
test("scout-food progress counts intended cardinal, not sideways tunnel drift", () => {
const north = scoutFoodTesting.CARDINALS.find((c) => c.name === "N");
assert.deepEqual(
scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 0, z: -9 }, north),
{ along: 9, total: 9, driftName: "N" },
);
assert.deepEqual(
scoutFoodTesting.cardinalProgress({ x: 0, z: 0 }, { x: 9, z: 0 }, north),
{ along: 0, total: 9, driftName: "E" },
);
});
test("preconditions that throw produce precondition_failed", async () => {
const teardown = _registerForTest({
id: "test.precondition-throw",
+159
View File
@@ -0,0 +1,159 @@
// recovery.escape-pit-safe — multi-strategy escape from a hole / pit.
//
// Why this exists alongside recovery.tunnel-out and survive.pillar-up:
//
// tunnel-out tries to dig sideways through walls — fails on
// unbreakable terrain or when there's no clear horizontal exit.
// pillar-up places blocks under the bot and jumps — fails when
// there's a ceiling block above the column.
//
// Both fail silently in a deep cave or 2×2 hole with a ceiling.
// escape-pit-safe surveys options first, then commits:
//
// 1. Scan 4 cardinals at head height + foot height. Pick the one
// with the closest open path to surface (defined as: column
// with sky visibility above OR ≥3 air blocks horizontal followed
// by stairs / slope up).
// 2. If a clear horizontal path exists → recovery.tunnel-out toward it.
// 3. If no horizontal path BUT ceiling is open above us → pillar-up.
// 4. If both blocked → return "stuck" and let the LLM-advisor flag a
// genuine improvement (e.g. "need water-bucket-MLG", "need to
// mine through stone").
import { info, warn } from "../log.js";
import { runSkill } from "./index.js";
const SCAN_RADIUS = 6;
const HEAD_OFFSET_Y = 1;
function blockNameAt(bot, x, y, z) {
try {
const b = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) });
return b?.name ?? null;
} catch { return null; }
}
function isAir(name) { return name === "air" || name === "cave_air" || name === "void_air"; }
function isWater(name) { return name === "water" || name === "flowing_water"; }
function isLava(name) { return name === "lava" || name === "flowing_lava"; }
function isPassable(name) { return isAir(name) || (name && /carpet|button|torch|grass$/.test(name)); }
// Look up to 32 blocks straight up from (x,y,z). Return distance to first
// non-air block, or Infinity if open all the way (this is a sky-visible
// column we could pillar-up out of).
function ceilingDistance(bot, x, y, z) {
for (let dy = HEAD_OFFSET_Y + 1; dy <= 32; dy++) {
const n = blockNameAt(bot, x, y + dy, z);
if (!n) return dy; // unloaded chunk = no info; treat conservatively
if (!isAir(n)) return dy;
}
return Infinity;
}
// Scan SCAN_RADIUS blocks in each cardinal at head height. Return
// summary per direction: open-block count, lava/water hits, first
// non-air block name.
function scanCardinals(bot) {
const pos = bot?.entity?.position;
if (!pos) return [];
const head = pos.offset?.(0, HEAD_OFFSET_Y, 0) ?? { x: pos.x, y: pos.y + HEAD_OFFSET_Y, z: pos.z };
const dirs = [
{ name: "N", dx: 0, dz: -1 },
{ name: "E", dx: 1, dz: 0 },
{ name: "S", dx: 0, dz: 1 },
{ name: "W", dx: -1, dz: 0 },
];
return dirs.map((d) => {
let openBlocks = 0;
let lavaAt = -1;
let waterAt = -1;
let firstBlock = null;
for (let r = 1; r <= SCAN_RADIUS; r++) {
const x = head.x + d.dx * r;
const z = head.z + d.dz * r;
const n = blockNameAt(bot, x, head.y, z);
if (isLava(n) && lavaAt < 0) lavaAt = r;
if (isWater(n) && waterAt < 0) waterAt = r;
if (isPassable(n)) {
openBlocks++;
if (firstBlock === null) firstBlock = "(air)";
} else {
if (firstBlock === null) firstBlock = n ?? "(unknown)";
break;
}
}
return { ...d, openBlocks, lavaAt, waterAt, firstBlock };
});
}
export const skill = Object.freeze({
id: "recovery.escape-pit-safe",
title: "Escape a pit — survey directions and pick the safest exit",
timeoutMs: 90_000,
preconditions(ctx) {
if (!ctx?.bot?.entity?.position) return { ok: false, code: "no_bot", detail: "bot missing" };
return { ok: true };
},
async execute(ctx) {
const bot = ctx.bot;
const pos = bot.entity.position;
const dirs = scanCardinals(bot);
const ceiling = ceilingDistance(bot, pos.x, pos.y, pos.z);
info(
"action",
`escape-pit-safe: cardinals=${dirs.map((d) => `${d.name}:${d.openBlocks}/${d.firstBlock}`).join(" ")} ceiling=${ceiling === Infinity ? "open" : ceiling + "b"}`,
);
// 1. Choose the direction with most open blocks (≥3) and no lava.
const horizontal = dirs
.filter((d) => d.openBlocks >= 3 && d.lavaAt < 0)
.sort((a, b) => b.openBlocks - a.openBlocks)[0];
if (horizontal) {
info("action", `escape-pit-safe: walking out via ${horizontal.name} (${horizontal.openBlocks}b clear)`);
// Delegate to wander step or simple controlled walk. Easier:
// invoke recovery.tunnel-out with a hint of the chosen direction.
const res = await runSkill("recovery.tunnel-out", ctx, { preferredDir: horizontal.name });
return {
ok: !!res?.ok,
code: res?.code ?? (res?.ok ? "done" : "tunnel_failed"),
detail: { strategy: "horizontal_walk", direction: horizontal.name, tunnelResult: res?.detail },
worldDelta: res?.worldDelta ?? { strategy: "horizontal_walk", direction: horizontal.name },
};
}
// 2. No horizontal exit — try pillar-up only if ceiling is open
// for the bot's height (≥3 blocks: head + 1 build + 1 ceiling slack).
if (ceiling >= 4) {
info("action", `escape-pit-safe: ceiling clear (${ceiling}b), trying pillar-up`);
const res = await runSkill("survive.pillar-up", ctx);
return {
ok: !!res?.ok,
code: res?.code ?? (res?.ok ? "done" : "pillar_failed"),
detail: { strategy: "pillar_up", ceiling, pillarResult: res?.detail },
worldDelta: res?.worldDelta ?? { strategy: "pillar_up" },
};
}
// 3. Both blocked — surrender. The LLM-advisor will see this in
// recent dispatches and can suggest village.relocate or flag a
// new skill request.
warn("action", `escape-pit-safe: no viable strategy (horizontal blocked + ceiling ${ceiling}b)`);
return {
ok: false,
code: "no_strategy",
detail: { horizontal: dirs.map((d) => ({ d: d.name, open: d.openBlocks, lava: d.lavaAt })), ceiling },
worldDelta: null,
};
},
recover(ctx, result) {
if (result.code === "no_strategy") {
return { hint: "wander", reason: "no viable pit-escape; let curriculum try a different action" };
}
return null;
},
});
// Test exports
export const __testing = { scanCardinals, ceilingDistance, isAir, isPassable, SCAN_RADIUS };
+3 -12
View File
@@ -90,16 +90,6 @@ export const skill = Object.freeze({
}
info("action", `explore.far: cardinal probe trials=${trials.map((t) => `${t.name}:${t.dist.toFixed(1)}`).join(" ")} best=${best.name}`);
const probeMoved = horizontalDistance(beforeProbe, bot.entity.position);
if (probeMoved >= 2) {
return {
ok: true,
code: "done",
detail: { mode: "probe-moved", dir: best.name, moved: probeMoved },
worldDelta: { movedTo: clonePos(bot.entity.position) },
};
}
if (best.dist < 0.5) {
// All cardinals blocked. Try the cheap vertical escape first; if it
// does not actually move us, carve a short horizontal tunnel. The
@@ -122,7 +112,8 @@ export const skill = Object.freeze({
return blindWalkOrTunnelOut(bot, {
yaw: best.yaw,
dirName: best.name,
blindMs: args.blindMs ?? 7_000,
blindMs: args.blindMs ?? 20_000,
minMove: args.minMove ?? Math.min(14, Math.max(8, dist * 0.25)),
tunnelPushMs: args.tunnelPushMs,
reason: `explore.far blind ${best.name}`,
intended: { x: tx, y: ty, z: tz },
@@ -130,7 +121,7 @@ export const skill = Object.freeze({
},
});
async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) {
export async function blindWalkOrTunnelOut(bot, { yaw, dirName, blindMs = 7_000, minMove = 0.75, tunnelPushMs, reason = "blind fallback", intended = null } = {}) {
const before = clonePos(bot.entity.position);
try { await bot.look(yaw, 0, true); } catch {}
bot.setControlState("forward", true);
+15 -1
View File
@@ -30,11 +30,14 @@ 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 pillarUp } from "./pillar-up.js";
import { skill as escapePitSafe } from "./escape-pit-safe.js";
import { skill as diagPhysics } from "./diagnose-physics.js";
import { skill as diagScan, matchSkill as diagMatch } from "./diagnose-scan.js";
import { skill as gatherStone } from "./gather-stone.js";
import { skill as gatherWool } from "./gather-wool.js";
import { skill as acquireFood } from "./acquire-food.js";
import { skill as scoutFood } from "./scout-food.js";
import { skill as relocate } from "./relocate.js";
import { skill as chooseBase } from "./choose-base.js";
import { skill as buildShelter } from "./build-shelter.js";
import { skill as placeChest } from "./place-chest.js";
@@ -74,12 +77,15 @@ register(flee);
register(sleep);
register(tunnelOut);
register(pillarUp);
register(escapePitSafe);
register(diagPhysics);
register(diagScan);
register(diagMatch);
register(gatherStone);
register(gatherWool);
register(acquireFood);
register(scoutFood);
register(relocate);
register(chooseBase);
register(buildShelter);
register(placeChest);
@@ -198,12 +204,20 @@ export async function runSkill(id, ctx, args = {}) {
};
}
if (!pre.ok) {
return {
const result = {
ok: false,
code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED,
detail: pre.detail ?? "preconditions failed",
worldDelta: null,
};
if (typeof skill.recover === "function") {
try {
result.recovery = skill.recover(ctx, result) ?? null;
} catch (e) {
warn("skill", `${id}.recover threw: ${e.message}`);
}
}
return result;
}
const timeoutMs = skill.timeoutMs ?? 30_000;
+146
View File
@@ -0,0 +1,146 @@
// village.relocate — commit-and-walk skill that breaks the bot out of
// "I've been wandering the same 50×50 area for 2 hours" failure mode.
//
// Heuristic: when the wedge-detector says we're stuck, this skill is
// dispatched with no biome preference; it picks the least-recently-
// visited cardinal (or any cardinal if no history) and walks ~300
// blocks toward it, with a hard time budget. While it runs, the
// reflex's wedge-detector knows a relocation is in flight and won't
// fire another one on top.
//
// The skill ignores the active need entirely for its duration — its
// only job is to displace the bot far enough that the surrounding
// biome is fresh and skills like survive.acquire-food / gather.logs
// have new local context to work with.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info } from "../log.js";
import { markRelocationStarted } from "../awareness/wedge-detector.js";
import { blindWalkOrTunnelOut } from "./explore-far.js";
const CARDINALS = [
{ name: "N", dx: 0, dz: -1, yaw: Math.PI },
{ name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 },
{ name: "S", dx: 0, dz: 1, yaw: 0 },
{ name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 },
];
const DEFAULT_DISTANCE = 300;
const STEP_BLOCKS = 32; // re-path every N blocks for liveness
const STEP_TIMEOUT_MS = 30_000;
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 pickCardinal(ctx, args) {
// Explicit override wins
if (args?.heading) {
const found = CARDINALS.find((c) => c.name === args.heading);
if (found) return found;
}
// Otherwise pick a cardinal not recently used. ctx may carry a
// recentRelocations array {name, ts}; default = N.
const recent = new Set((ctx?.recentRelocations ?? []).map((r) => r.name));
const untried = CARDINALS.filter((c) => !recent.has(c.name));
return untried[0] ?? CARDINALS[Math.floor(Math.random() * CARDINALS.length)];
}
export const skill = Object.freeze({
id: "village.relocate",
title: "Walk 300 blocks in a fresh cardinal to break a wedge",
timeoutMs: 180_000,
preconditions(ctx) {
if (!ctx?.bot?.entity?.position) {
return { ok: false, code: "no_bot", detail: "bot or entity missing" };
}
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
const distance = Math.max(64, Math.min(args?.distance ?? DEFAULT_DISTANCE, 600));
const cardinal = pickCardinal(ctx, args);
const start = { x: bot.entity.position.x, z: bot.entity.position.z };
markRelocationStarted({ x: start.x, z: start.z, heading: cardinal });
ctx.recentRelocations = (ctx.recentRelocations ?? []).slice(-3);
ctx.recentRelocations.push({ name: cardinal.name, ts: Date.now() });
ensurePathfinder(bot);
setMovementsForTravel(bot);
info("action", `relocate: heading ${cardinal.name} for ${distance}b from (${Math.round(start.x)}, ${Math.round(start.z)})`);
let travelled = 0;
const errors = [];
while (travelled < distance) {
if (ctx?.abortSignal?.aborted) {
return {
ok: travelled >= distance / 2, // partial counts if we got at least half
code: travelled >= distance / 2 ? "partial" : "preempted",
detail: { travelled: Math.round(travelled), heading: cardinal.name },
worldDelta: { moved: Math.round(travelled), heading: cardinal.name },
};
}
const stepDist = Math.min(STEP_BLOCKS, distance - travelled);
const targetX = start.x + cardinal.dx * (travelled + stepDist);
const targetZ = start.z + cardinal.dz * (travelled + stepDist);
const targetY = Math.floor(bot.entity.position.y);
try {
await Promise.race([
bot.pathfinder.goto(new goals.GoalNear(Math.floor(targetX), targetY, Math.floor(targetZ), 4)),
new Promise((_, rej) => setTimeout(() => rej(new Error("step timeout")), STEP_TIMEOUT_MS)),
]);
} catch (e) {
errors.push(e?.message ?? String(e));
info("action", `relocate: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`);
const blind = await blindWalkOrTunnelOut(bot, {
yaw: cardinal.yaw,
dirName: cardinal.name,
blindMs: 12_000,
minMove: 8,
reason: `relocate ${cardinal.name}`,
});
if (!blind.ok && errors.length >= 3) break;
}
// Measure actual progress (pathfinder might have routed around)
const dx = bot.entity.position.x - start.x;
const dz = bot.entity.position.z - start.z;
travelled = Math.hypot(dx, dz);
}
const endPos = bot.entity.position;
const finalDist = Math.hypot(endPos.x - start.x, endPos.z - start.z);
if (finalDist < 50) {
return {
ok: false,
code: "stuck_in_place",
detail: { travelled: Math.round(finalDist), heading: cardinal.name, errors: errors.slice(0, 3) },
worldDelta: { moved: Math.round(finalDist) },
};
}
return {
ok: true,
code: "done",
detail: { travelled: Math.round(finalDist), heading: cardinal.name },
worldDelta: { moved: Math.round(finalDist), heading: cardinal.name, mode: "relocate" },
};
},
recover(ctx, result) {
if (result.code === "stuck_in_place") {
return { hint: "wander", reason: "relocate could not gain ground; let wander try a new tactic" };
}
return null;
},
});
// Test exports
export const __testing = { CARDINALS, DEFAULT_DISTANCE, pickCardinal };
+408
View File
@@ -0,0 +1,408 @@
// survive.scout-food — longer-range, biome-aware food search.
//
// Why this exists alongside survive.acquire-food:
//
// acquire-food is the "I see a cow, hunt it" skill. Its precondition
// requires a passive food mob within ~32 blocks; if there isn't one,
// the skill bails immediately. In v0.3.0 that produced two failure
// modes that kept the bot looping for hours:
//
// 1. Biome with NO passive mobs (desert, ocean, snowy peaks, deep
// caves). acquire-food can never succeed there — the bot just
// kept scanning the same 32-block sphere.
//
// 2. Biome WITH passive mobs but the immediate area is empty. The
// bot wandered 1-8 blocks at a time around the same 50×50 patch
// and never committed to a direction long enough to leave it.
//
// scout-food applies the "commit-to-cardinal" pattern (per the v0.3.1
// design research): scan(32) → patrol(64, time-budget) → if still
// nothing, walk a chosen cardinal for ~200 blocks, rescanning every
// 16 blocks. On exhaustion of all 4 cardinals it surrenders to the
// curriculum so the operator-driven `village.relocate` or LLM advisor
// can pick up.
//
// Biome-awareness:
// - If current biome's affordance table has has_passive_mobs=false
// AND has_water=false, skip the local scan entirely and pick the
// most plausible "leave biome" heading: sample neighbour biomes at
// radius 64 in 8 directions, pick the first one whose affordances
// say has_passive_mobs=true.
// - In water-bearing barren biomes (ocean shores, frozen rivers)
// fishing isn't implemented yet — operator-facing improvement.
import pathfinderPkg from "mineflayer-pathfinder";
const { pathfinder, goals, Movements } = pathfinderPkg;
import { info, warn } from "../log.js";
import { foods } from "./groups.js";
import { affordancesFor, hasPassiveMobs, isBarren } from "../biome-affordances.js";
import { blindWalkOrTunnelOut } from "./explore-far.js";
const PASSIVE_FOOD_MOBS = new Set(["cow", "pig", "chicken", "sheep", "rabbit", "mooshroom"]);
const CARDINALS = [
{ name: "N", dx: 0, dz: -1, yaw: Math.PI },
{ name: "E", dx: 1, dz: 0, yaw: -Math.PI / 2 },
{ name: "S", dx: 0, dz: 1, yaw: 0 },
{ name: "W", dx: -1, dz: 0, yaw: Math.PI / 2 },
];
const PATROL_TICK_DISTANCE = 16;
const PATROL_STEP_TIMEOUT_MS = 12_000;
const DEFAULT_COMMIT_DISTANCE = 200;
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 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) {
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 currentBiomeName(bot) {
try {
const block = bot.blockAt?.(bot.entity?.position);
const b = block?.biome;
if (typeof b === "string") return b;
if (b?.name) return b.name;
return null;
} catch { return null; }
}
function biomeNameAt(bot, x, y, z) {
try {
const block = bot.blockAt?.({ x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) });
const b = block?.biome;
if (typeof b === "string") return b;
if (b?.name) return b.name;
return null;
} catch { return null; }
}
// Sample biomes in 8 compass directions at the given radius; return
// the heading whose biome has passive mobs.
function scanForFoodCapableNeighbourBiome(bot, radius = 64) {
const here = bot?.entity?.position;
if (!here) return null;
const dirs = [
{ name: "N", dx: 0, dz: -1 },
{ name: "NE", dx: 0.71, dz: -0.71 },
{ name: "E", dx: 1, dz: 0 },
{ name: "SE", dx: 0.71, dz: 0.71 },
{ name: "S", dx: 0, dz: 1 },
{ name: "SW", dx: -0.71, dz: 0.71 },
{ name: "W", dx: -1, dz: 0 },
{ name: "NW", dx: -0.71, dz: -0.71 },
];
for (const d of dirs) {
const b = biomeNameAt(bot, here.x + d.dx * radius, here.y, here.z + d.dz * radius);
if (b && hasPassiveMobs(b)) return { heading: d, biome: b };
}
return null;
}
function scoutState(ctx, bot) {
const here = bot?.entity?.position;
const now = Date.now();
const prev = ctx.scoutFoodState;
const expired = !prev || now - (prev.ts ?? 0) > 10 * 60_000;
const displaced = prev?.origin && here
? Math.hypot(here.x - prev.origin.x, here.z - prev.origin.z) > 128
: false;
if (expired || displaced) {
ctx.scoutFoodState = {
ts: now,
origin: here ? { x: here.x, z: here.z } : null,
tried: new Set(),
};
return ctx.scoutFoodState;
}
prev.ts = now;
if (!(prev.tried instanceof Set)) prev.tried = new Set(prev.tried ?? []);
return prev;
}
async function patrolCardinal(bot, cardinal, distance, ctx) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
const start = bot.entity.position.clone?.() ?? { ...bot.entity.position };
let travelled = 0;
while (travelled < distance) {
if (ctx?.abortSignal?.aborted) return { aborted: true, travelled };
const tx = start.x + cardinal.dx * (travelled + PATROL_TICK_DISTANCE);
const tz = start.z + cardinal.dz * (travelled + PATROL_TICK_DISTANCE);
const goal = new goals.GoalNear(Math.floor(tx), Math.floor(bot.entity.position.y), Math.floor(tz), 2);
try {
await Promise.race([
bot.pathfinder.goto(goal),
new Promise((_, rej) => setTimeout(() => rej(new Error("patrol step timeout")), PATROL_STEP_TIMEOUT_MS)),
]);
} catch (e) {
info("action", `scout-food: path step failed (${e?.message ?? e}); blind fallback ${cardinal.name}`);
try { bot.pathfinder?.stop?.(); } catch {}
const blind = await blindWalkOrTunnelOut(bot, {
yaw: cardinal.yaw ?? -Math.atan2(cardinal.dx, cardinal.dz),
dirName: cardinal.name,
blindMs: 12_000,
minMove: 6,
reason: `scout-food ${cardinal.name}`,
});
const progress = cardinalProgress(start, bot.entity.position, cardinal);
travelled = progress.along;
const target = nearestPassiveFoodMob(bot, 32);
if (target) return { aborted: false, travelled, target };
if (progress.total >= 4 && progress.along < 4) {
info("action", `scout-food: ${cardinal.name} blocked; drifted ${progress.driftName ?? "sideways"} ${progress.total.toFixed(1)}b`);
return {
aborted: false,
travelled,
blocked: true,
drifted: progress.driftName,
error: `blocked_${cardinal.name}`,
};
}
if (!blind.ok && progress.total < 4) {
return { aborted: false, travelled, error: e?.message ?? String(e) };
}
continue;
}
const progress = cardinalProgress(start, bot.entity.position, cardinal);
travelled = Math.max(travelled + PATROL_TICK_DISTANCE, progress.along);
// Rescan after every step.
const target = nearestPassiveFoodMob(bot, 32);
if (target) return { aborted: false, travelled, target };
}
return { aborted: false, travelled };
}
function cardinalProgress(start, pos, cardinal) {
const dx = (pos?.x ?? 0) - (start?.x ?? 0);
const dz = (pos?.z ?? 0) - (start?.z ?? 0);
const along = Math.max(0, dx * cardinal.dx + dz * cardinal.dz);
const total = Math.hypot(dx, dz);
return { along, total, driftName: dominantCardinal(dx, dz, cardinal.name) };
}
function dominantCardinal(dx, dz, fallback = null) {
if (Math.abs(dx) < 0.5 && Math.abs(dz) < 0.5) return fallback;
if (Math.abs(dx) >= Math.abs(dz)) return dx >= 0 ? "E" : "W";
return dz >= 0 ? "S" : "N";
}
export const skill = Object.freeze({
id: "survive.scout-food",
title: "Scout for food at long range (biome-aware)",
timeoutMs: 240_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" };
}
return { ok: true };
},
async execute(ctx, args = {}) {
const bot = ctx.bot;
const before = foodCount(bot);
const state = scoutState(ctx, bot);
const triedCardinals = new Set([...(args?._triedCardinals ?? []), ...(state.tried ?? [])]);
// Step 0: biome check. If barren, head toward a food-capable neighbour.
const biome = currentBiomeName(bot);
const aff = affordancesFor(biome);
info("action", `scout-food: biome=${biome ?? "?"} mobs=${aff.has_passive_mobs} barren=${isBarren(biome)}`);
if (!aff.has_passive_mobs) {
const next = scanForFoodCapableNeighbourBiome(bot, 64);
if (next) {
info("action", `scout-food: leaving barren biome ${biome}${next.biome} via ${next.heading.name}`);
const result = await patrolCardinal(bot, next.heading, DEFAULT_COMMIT_DISTANCE, ctx);
if (result.aborted) return { ok: false, code: "preempted", worldDelta: null };
if (result.target) {
return await tryHunt(bot, result.target, before);
}
if (result.blocked) {
state.tried.add(next.heading.name);
return {
ok: false,
code: "blocked_heading",
detail: `blocked ${next.heading.name}, drifted ${result.drifted ?? "sideways"}`,
worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, drifted: result.drifted ?? null },
};
}
return {
ok: false,
code: "no_target",
detail: `walked ${Math.round(result.travelled)}b ${next.heading.name} toward ${next.biome}, still no food`,
worldDelta: { moved: Math.round(result.travelled), heading: next.heading.name, from_biome: biome, to_biome: next.biome },
};
}
// Ringed by barren biomes; pick the first cardinal not yet tried.
}
// Step 1: scan radius 32 (cheap).
let target = nearestPassiveFoodMob(bot, 32);
if (target) return await tryHunt(bot, target, before);
// Step 2: scan radius 64 — entities frequently spawn just outside
// our local horizon.
target = nearestPassiveFoodMob(bot, 64);
if (target) return await tryHunt(bot, target, before);
// Step 3: commit to a cardinal we haven't tried in this incident.
const untried = CARDINALS.filter((c) => !triedCardinals.has(c.name));
if (untried.length === 0) {
return {
ok: false,
code: "exhausted",
detail: "tried all 4 cardinals without finding a food mob — switch to village.relocate",
worldDelta: null,
};
}
const cardinal = untried[0];
state.tried.add(cardinal.name);
info("action", `scout-food: commit cardinal ${cardinal.name} for ${DEFAULT_COMMIT_DISTANCE}b`);
const result = await patrolCardinal(bot, cardinal, DEFAULT_COMMIT_DISTANCE, ctx);
if (result.aborted) return { ok: false, code: "preempted", worldDelta: null };
if (result.target) return await tryHunt(bot, result.target, before);
if (result.blocked) {
return {
ok: false,
code: "blocked_heading",
detail: { tried: cardinal.name, travelled: Math.round(result.travelled), drifted: result.drifted ?? null, error: result.error ?? null },
worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, drifted: result.drifted ?? null, from_biome: biome },
};
}
return {
ok: false,
code: "no_target",
detail: { tried: cardinal.name, travelled: Math.round(result.travelled), error: result.error ?? null },
worldDelta: { moved: Math.round(result.travelled), heading: cardinal.name, from_biome: biome },
};
},
recover(ctx, result) {
if (result.code === "exhausted") {
return { hint: "relocate", reason: "scout-food exhausted all 4 cardinals; needs a long jump" };
}
if (result.code === "blocked_heading") {
return { hint: "scout-food", reason: "chosen scout heading is blocked; retry another cardinal" };
}
if (result.code === "approached_target" || result.code === "no_path") {
return { hint: "scout-food", reason: "made or attempted progress toward food target; rescan from current position" };
}
if (result.code === "no_target") {
return { hint: "wander", reason: "scout completed leg without finding mob; try another cardinal" };
}
return null;
},
});
async function tryHunt(bot, target, before) {
ensurePathfinder(bot);
setMovementsForTravel(bot);
const start = bot.entity.position.clone?.() ?? { ...bot.entity.position };
try {
await Promise.race([
bot.pathfinder.goto(new goals.GoalFollow(target.entity, 2)),
new Promise((_, rej) => setTimeout(() => rej(new Error("path-to-mob timeout")), 30_000)),
]);
} catch (e) {
try { bot.pathfinder?.stop?.(); } catch {}
const moved = horizontalDistance(start, bot.entity.position);
if (moved >= 6) {
return {
ok: false,
code: "approached_target",
detail: { target: target.entity.name, moved: Math.round(moved), mode: "pathfinder_partial", error: e?.message ?? "path failed" },
worldDelta: { moved: Math.round(moved), target: target.entity.name, mode: "pathfinder_partial" },
};
}
const yaw = yawToward(bot.entity.position, target.entity.position);
if (yaw !== null) {
const blind = await blindWalkOrTunnelOut(bot, {
yaw,
dirName: `toward-${target.entity.name}`,
blindMs: 8_000,
minMove: 4,
reason: `scout-food target ${target.entity.name}`,
});
const afterBlind = horizontalDistance(start, bot.entity.position);
if (blind.ok || afterBlind >= 4) {
return {
ok: false,
code: "approached_target",
detail: { target: target.entity.name, moved: Math.round(afterBlind), mode: "blind_target", error: e?.message ?? "path failed" },
worldDelta: { moved: Math.round(afterBlind), target: target.entity.name, mode: "blind_target" },
};
}
}
return { ok: false, code: "no_path", detail: e?.message ?? "path failed", worldDelta: null };
}
info("action", `scout-food: engaging ${target.entity.name}@${target.distance.toFixed(1)}b`);
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 Promise.race([
bot.pathfinder.goto(new goals.GoalFollow(current, 2)),
new Promise((_, rej) => setTimeout(() => rej(new Error("repath timeout")), 8_000)),
]);
} catch {}
}
bot.attack(current);
await new Promise((r) => setTimeout(r, 700));
}
await new Promise((r) => setTimeout(r, 1_000));
const after = foodCount(bot);
if (after <= before) {
return { ok: false, code: "no_drop", detail: `hunted ${target.entity.name} but 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 },
};
}
function horizontalDistance(a, b) {
return Math.hypot((b?.x ?? 0) - (a?.x ?? 0), (b?.z ?? 0) - (a?.z ?? 0));
}
function yawToward(from, to) {
if (!from || !to) return null;
const dx = to.x - from.x;
const dz = to.z - from.z;
if (Math.hypot(dx, dz) < 0.5) return null;
return -Math.atan2(dx, dz);
}
// Test exports
export const __testing = {
CARDINALS, PATROL_TICK_DISTANCE, DEFAULT_COMMIT_DISTANCE, PATROL_STEP_TIMEOUT_MS,
nearestPassiveFoodMob, currentBiomeName, scanForFoodCapableNeighbourBiome,
cardinalProgress, dominantCardinal, horizontalDistance, yawToward,
};
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Operator-facing view of the bot's storyline progress.
//
// Reads the bot's IPC sock if available (live snapshot), else falls
// back to "what would the picker say given an empty inventory". The
// useful form is the live one.
//
// Usage:
// node scripts/show-story.js # live snapshot via IPC
// node scripts/show-story.js --plain # show static catalogue
import { config as loadDotenv } from "dotenv";
loadDotenv();
import net from "node:net";
import { STORYLINE } from "../runtime/goal/storyline.js";
import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js";
import { socketPath } from "../runtime/config.js";
import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js";
function plainCatalogue() {
console.log("=== Storyline (canonical Minecraft survival arc) ===");
for (let i = 0; i < STORYLINE.length; i++) {
const s = STORYLINE[i];
console.log(` ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}`);
console.log(`${s.narration_ru}`);
}
}
async function fetchSnapshotViaIpc() {
return new Promise((resolve) => {
const sock = net.connect(socketPath);
const buf = [];
const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500);
sock.on("connect", () => {
sock.write(JSON.stringify({ type: COMMAND_TYPES.SNAPSHOT }) + "\n");
});
const parse = () => {
try {
const raw = Buffer.concat(buf).toString("utf8").trim();
const lines = raw.split("\n").filter(Boolean);
for (const ln of lines) {
const obj = JSON.parse(ln);
if (obj?.type === EVENT_TYPES.STATUS && obj?.payload) {
clearTimeout(timer);
sock.destroy();
resolve(obj.payload);
return true;
}
}
} catch {}
return false;
};
sock.on("data", (chunk) => {
buf.push(chunk);
parse();
});
sock.on("end", () => {
clearTimeout(timer);
if (!parse()) resolve(null);
});
sock.on("error", () => { clearTimeout(timer); resolve(null); });
});
}
async function main() {
if (process.argv.includes("--plain")) {
plainCatalogue();
return;
}
const snap = await fetchSnapshotViaIpc();
if (!snap) {
console.log("(bot IPC not reachable — showing static catalogue)");
console.log("");
plainCatalogue();
return;
}
_resetForTest();
const cur = pickCurrentStep(snap);
console.log(`=== Storyline progress (live snapshot) ===`);
console.log(progressSummary(snap));
console.log("");
if (!cur) {
console.log("(disconnected)");
return;
}
for (let i = 0; i < STORYLINE.length; i++) {
const s = STORYLINE[i];
const mark = i < cur.index ? "✓" : (i === cur.index ? "→" : " ");
const tag = i === cur.index ? ` (${cur.suggestion?.skillId ?? "-"})${cur.emergency ? " [PAUSED]" : ""}` : "";
console.log(` ${mark} ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}${tag}`);
}
console.log("");
console.log(`Inventory keys: ${snap.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"}`);
console.log(`HP ${snap.health ?? "?"} / food ${snap.food ?? "?"} / day=${snap.isDay ? "yes" : "no"}`);
}
main().catch((e) => {
console.error("ERROR:", e?.message ?? e);
process.exit(1);
});
+487
View File
@@ -0,0 +1,487 @@
/**
* pepa monitor — fullscreen, read-only TUI.
*
* Replaces the old action-heavy tui/tui.tsx with a pure observability
* dashboard inspired by opencode's full-screen layout. No hotkeys to
* dispatch skills / send chat / approve proposals — operator does that
* via scripts/* or by writing to the IPC sock directly. This screen
* just shows what the bot is doing, in colour.
*
* Panels (top → bottom):
* 1. Header — connection / position / hp / food / time
* 2. Storyline — current step + the 11-step quest map
* 3. Activity — last N dispatches (colour-coded by outcome)
* + MC chat — last N lines
* 4. Advisor — last 6 LLM recommendations (trigger / outcome / tokens)
* + Improvements — open queue from improvement_requests
* 5. Footer — token usage today, advisor stats, q to quit
*
* Live data sources:
* - IPC sock (snapshot frames, log frames, chat frames)
* - SQLite (knowledge.db) polled every 5s for advisor + improvements
*/
import React, { useEffect, useReducer, useState } from "react";
import { render, Box, Text, useApp, useInput, useStdout } from "ink";
import { createIpcClient } from "./ipc-client.js";
import { EVENT_TYPES } from "../runtime/ipc-protocol.js";
import { stateDir } from "../runtime/config.js";
import { initKnowledge, isAvailable as knowledgeReady, recentRecommendations, listImprovements, recommendationStats } from "../runtime/knowledge/index.js";
// --- types ------------------------------------------------------------------
type LogEntry = { ts: string; level: string; source: string; text: string };
type ChatEntry = { uid: number; ts: string; from: string; text: string; kind: string };
type Snapshot = Record<string, any>;
type Dispatch = { uid: number; ts: number; kind: "start" | "end"; label: string; ok?: boolean; code?: string; detail?: string };
let _uidSeq = 0;
function nextUid() { _uidSeq += 1; return _uidSeq; }
type State = {
connectedIpc: boolean;
snapshot: Snapshot;
logs: LogEntry[];
chat: ChatEntry[];
dispatches: Dispatch[];
startedAt: number;
};
type Action =
| { type: "ipc-connected" }
| { type: "ipc-disconnected" }
| { type: "snapshot"; payload: Snapshot }
| { type: "log"; payload: LogEntry }
| { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string };
const MAX_LOGS = 200;
const MAX_CHAT = 50;
const MAX_DISPATCHES = 30;
// --- reducer ----------------------------------------------------------------
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ipc-connected":
return { ...state, connectedIpc: true };
case "ipc-disconnected":
return { ...state, connectedIpc: false };
case "snapshot":
return { ...state, snapshot: action.payload };
case "log": {
const logs = [...state.logs, action.payload].slice(-MAX_LOGS);
// also derive a dispatches view: lines like "→ label" or "← label ok/fail (...)"
const dispatches = extractDispatch(state.dispatches, action.payload);
return { ...state, logs, dispatches };
}
case "chat": {
const chat = [...state.chat, { uid: nextUid(), ts: action.ts, ...action.payload }].slice(-MAX_CHAT);
return { ...state, chat };
}
default:
return state;
}
}
function extractDispatch(prev: Dispatch[], log: LogEntry): Dispatch[] {
if (log.source !== "dispatch") return prev;
const ts = Date.parse(log.ts) || Date.now();
// "→ label" — skill starting
const startMatch = /^→\s+(\S+)/.exec(log.text);
if (startMatch) {
return [...prev, { uid: nextUid(), ts, kind: "start", label: startMatch[1] }].slice(-MAX_DISPATCHES);
}
// "← label ok/fail (...)" — skill ending
const endMatch = /^←\s+(\S+)\s+(ok|fail)(?:\s+\((.*)\))?/.exec(log.text);
if (endMatch) {
const [, label, outcome, detail] = endMatch;
return [...prev, { uid: nextUid(), ts, kind: "end", label, ok: outcome === "ok", detail }].slice(-MAX_DISPATCHES);
}
return prev;
}
// --- helpers ----------------------------------------------------------------
function formatAge(ts: number) {
const dt = Math.max(0, Date.now() - ts);
if (dt < 60_000) return `${Math.floor(dt / 1000)}s`;
if (dt < 3600_000) return `${Math.floor(dt / 60_000)}m`;
const h = Math.floor(dt / 3600_000);
const m = Math.floor((dt % 3600_000) / 60_000);
return `${h}h${m}m`;
}
function formatDuration(ms: number) {
const s = Math.floor(ms / 1000) % 60;
const m = Math.floor(ms / 60_000) % 60;
const h = Math.floor(ms / 3600_000);
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`;
if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`;
return `${s}s`;
}
function shortTime(ts: number) {
const d = new Date(ts);
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
}
function hpColor(hp: number | undefined) {
if (hp == null) return "gray";
if (hp <= 5) return "red";
if (hp <= 12) return "yellow";
return "green";
}
function foodColor(food: number | undefined) {
if (food == null) return "gray";
if (food <= 4) return "red";
if (food <= 10) return "yellow";
return "green";
}
// --- components -------------------------------------------------------------
function StatusHeader({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) {
const pos = snapshot.position
? `(${Math.round(snapshot.position.x)},${Math.round(snapshot.position.y)},${Math.round(snapshot.position.z)})`
: "?";
const hp = snapshot.health;
const food = snapshot.food;
const day = snapshot.isDay ? "☀" : "🌙";
const session = formatDuration(Date.now() - startedAt);
const mcOnline = snapshot.connected;
const story = snapshot.storyStep;
const idx = story?.index ?? 0;
const cur = story?.step;
const want = story?.suggestion?.skillId;
// 11-step progress bar in 11 cells
const bar = Array.from({ length: 11 }, (_, i) => {
if (i < idx) return "▓";
if (i === idx) return "▒";
return "░";
}).join("");
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={width}>
<Box>
<Text bold color="cyan">pepa</Text>
<Text dimColor> · </Text>
<Text color={mcOnline ? "green" : "red"}>{mcOnline ? "●MC" : "○MC"}</Text>
<Text dimColor> · </Text>
<Text color={connectedIpc ? "green" : "red"}>{connectedIpc ? "●IPC" : "○IPC"}</Text>
<Text dimColor> · </Text>
<Text>{session}</Text>
<Text dimColor> · </Text>
<Text bold>{snapshot.username ?? "?"}</Text>
<Text dimColor> · </Text>
<Text>{pos}</Text>
<Text dimColor> · </Text>
<Text>HP </Text><Text bold color={hpColor(hp)}>{hp ?? "?"}</Text>
<Text dimColor> · </Text>
<Text>food </Text><Text bold color={foodColor(food)}>{food ?? "?"}</Text>
<Text dimColor> · </Text>
<Text>{day}</Text>
{(snapshot.hostileCount ?? 0) > 0 ? (
<>
<Text dimColor> · </Text>
<Text color="red">{snapshot.hostileCount}</Text>
{snapshot.closestHostile ? <Text color="red">({snapshot.closestHostile.name}@{Math.round(snapshot.closestHostile.distance)}b)</Text> : null}
</>
) : null}
</Box>
<Box>
<Text color="magenta">story </Text>
<Text bold>{bar}</Text>
<Text dimColor> </Text>
<Text bold>{idx + 1}/11</Text>
<Text dimColor> </Text>
{cur ? (
<>
<Text color="white" bold>{cur.id}</Text>
<Text dimColor> · </Text>
<Text>{cur.title}</Text>
</>
) : <Text dimColor>(no story)</Text>}
{want ? (
<>
<Text dimColor> </Text>
<Text color="cyan">{want}</Text>
</>
) : null}
{story?.emergency ? <Text color="red" bold> [EMERGENCY]</Text> : null}
</Box>
</Box>
);
}
function ActivityPanel({ dispatches, width, height }: { dispatches: Dispatch[]; width: number; height: number }) {
const visible = dispatches.slice(-height);
return (
<Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1} width={width}>
<Text bold color="blue">Activity (last {visible.length})</Text>
{visible.map((d) => {
if (d.kind === "start") {
return (
<Text key={d.uid} dimColor>
{shortTime(d.ts)} <Text color="white">{d.label}</Text>
</Text>
);
}
const color = d.ok ? "green" : "red";
const tail = d.detail ? ` (${String(d.detail).slice(0, 30)})` : "";
return (
<Text key={d.uid}>
<Text dimColor>{shortTime(d.ts)} </Text>
<Text color={color}> {d.label} {d.ok ? "ok" : "fail"}</Text>
<Text dimColor>{tail}</Text>
</Text>
);
})}
{visible.length === 0 ? <Text dimColor>(waiting for activity)</Text> : null}
</Box>
);
}
function ChatPanel({ chat, width, height }: { chat: ChatEntry[]; width: number; height: number }) {
const visible = chat.slice(-height);
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1} width={width}>
<Text bold color="green">MC Chat (last {visible.length})</Text>
{visible.map((c) => (
<Text key={c.uid}>
<Text dimColor>{c.ts?.slice(11, 16) ?? ""} </Text>
<Text color={c.kind === "system" ? "gray" : c.from === "pepa_bot" ? "cyan" : "yellow"} bold={c.from !== "pepa_bot" && c.kind !== "system"}>
{c.from}:
</Text>
<Text> {c.text.slice(0, width - 12)}</Text>
</Text>
))}
{visible.length === 0 ? <Text dimColor>(no chat yet)</Text> : null}
</Box>
);
}
function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; height: number }) {
const visible = recs.slice(0, height);
return (
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} width={width}>
<Text bold color="yellow">Advisor (last {visible.length})</Text>
{visible.map((r) => {
const ok = r.outcome_ok;
const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗";
const outcomeColor = ok == null ? "gray" : ok ? "green" : "red";
const target = r.recommended_skill ?? r.action;
const trigger = String(r.trigger_reason ?? "?");
// One line per recommendation: " ✓ wedged_60s → survive.flee 802t 1900ms"
const lhs = `${outcomeMark} ${trigger}${target}`;
const rhs = `${r.tokens_in ?? "?"}t ${r.latency_ms ?? "?"}ms`;
const free = Math.max(20, width - rhs.length - 6);
return (
<Text key={`adv-${r.id}`}>
<Text color={outcomeColor} bold>{outcomeMark} </Text>
<Text color="white">{trigger}</Text>
<Text dimColor> </Text>
<Text color="cyan">{String(target).slice(0, Math.max(8, free - trigger.length - 6))}</Text>
<Text dimColor> {rhs}</Text>
</Text>
);
})}
{visible.length === 0 ? <Text dimColor>(no advisor calls yet)</Text> : null}
</Box>
);
}
function ImprovementsPanel({ items, width, height }: { items: any[]; width: number; height: number }) {
const visible = items.slice(0, height);
return (
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={width}>
<Text bold color="magenta">Improvements (open {items.length})</Text>
{visible.map((r) => (
<Text key={`imp-${r.id}`}>
<Text color="gray">#{r.id} </Text>
<Text color="yellow">P{r.priority}</Text>
<Text dimColor> ×{r.votes} </Text>
<Text color="white">{String(r.title).slice(0, Math.max(20, width - 14))}</Text>
</Text>
))}
{visible.length === 0 ? <Text dimColor>(no improvement requests yet)</Text> : null}
</Box>
);
}
function Footer({ stats, width }: { stats: any[]; width: number }) {
const total = stats.reduce(
(acc, s) => ({
calls: acc.calls + (s.total ?? 0),
succ: acc.succ + (s.succeeded ?? 0),
fail: acc.fail + (s.failed ?? 0),
in: acc.in + (s.avg_in ?? 0) * (s.total ?? 0),
out: acc.out + (s.avg_out ?? 0) * (s.total ?? 0),
}),
{ calls: 0, succ: 0, fail: 0, in: 0, out: 0 },
);
const priceInRub = Number(process.env.TIMEWEB_PRICE_IN_RUB_PER_M) || 101;
const priceOutRub = Number(process.env.TIMEWEB_PRICE_OUT_RUB_PER_M) || 608;
const costRub = (total.in * priceInRub + total.out * priceOutRub) / 1_000_000;
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} width={width}>
<Box>
<Text dimColor>Last 24h: </Text>
<Text bold>{total.calls}</Text>
<Text dimColor> advisor calls (</Text>
<Text color="green">{total.succ} ok</Text>
<Text dimColor> / </Text>
<Text color="red">{total.fail} fail</Text>
<Text dimColor>) tokens </Text>
<Text bold>{Math.round(total.in / 1000)}K</Text>
<Text dimColor> in / </Text>
<Text bold>{Math.round(total.out / 1000)}K</Text>
<Text dimColor> out </Text>
<Text bold color="cyan">{costRub.toFixed(2)} </Text>
</Box>
<Box>
<Text dimColor>q quit (bot keeps running)</Text>
</Box>
</Box>
);
}
// --- main app ---------------------------------------------------------------
function App() {
const { exit } = useApp();
const { stdout } = useStdout();
// Sample stdout dimensions periodically. Subscribing directly to
// stdout.on('resize') from a React hook conflicts with ink's own
// listener and produces "MaxListenersExceededWarning" / reconciler
// errors. A 1s poll is cheap and good enough — terminals don't
// resize often.
const [cols, setCols] = useState<number>((stdout as any)?.columns ?? 120);
const [rows, setRows] = useState<number>((stdout as any)?.rows ?? 30);
useEffect(() => {
const t = setInterval(() => {
setCols((stdout as any)?.columns ?? 120);
setRows((stdout as any)?.rows ?? 30);
}, 1000);
return () => clearInterval(t);
}, [stdout]);
const [state, dispatch] = useReducer(reducer, {
connectedIpc: false,
snapshot: {},
logs: [],
chat: [],
dispatches: [],
startedAt: Date.now(),
});
const [client] = useState(() => createIpcClient());
const [knowledgeOk, setKnowledgeOk] = useState(false);
const [recs, setRecs] = useState<any[]>([]);
const [improvements, setImprovements] = useState<any[]>([]);
const [stats, setStats] = useState<any[]>([]);
// IPC
useEffect(() => {
const onConnected = () => dispatch({ type: "ipc-connected" });
const onDisconnected = () => dispatch({ type: "ipc-disconnected" });
const onFrame = (frame: any) => {
switch (frame.type) {
case EVENT_TYPES.STATUS: dispatch({ type: "snapshot", payload: frame.payload }); break;
case EVENT_TYPES.LOG: dispatch({ type: "log", payload: frame.payload }); break;
case EVENT_TYPES.CHAT: dispatch({ type: "chat", payload: frame.payload, ts: frame.ts }); break;
case EVENT_TYPES.HELLO:
if (frame.payload?.snapshot) dispatch({ type: "snapshot", payload: frame.payload.snapshot });
if (frame.payload?.recentLogs) {
for (const lg of frame.payload.recentLogs) dispatch({ type: "log", payload: lg });
}
break;
}
};
(client as any).on("connected", onConnected);
(client as any).on("disconnected", onDisconnected);
(client as any).on("frame", onFrame);
return () => {
(client as any).off("connected", onConnected);
(client as any).off("disconnected", onDisconnected);
(client as any).off("frame", onFrame);
client.close();
};
}, [client]);
// Knowledge DB init + polling
useEffect(() => {
let mounted = true;
(async () => {
try {
await initKnowledge({ stateDir });
if (!mounted) return;
setKnowledgeOk(knowledgeReady());
} catch {}
})();
const poll = () => {
if (!knowledgeReady()) return;
try {
setRecs(recentRecommendations({ limit: 12 }));
setImprovements(listImprovements({ status: "open", limit: 12 }));
setStats(recommendationStats({ sinceHours: 24 }));
} catch {}
};
poll();
const t = setInterval(poll, 5000);
return () => { mounted = false; clearInterval(t); };
}, [knowledgeOk]);
// useInput requires TTY raw mode; skip it when stdin isn't a TTY
// (e.g. when piped during a smoke test). Real `npm run tui` is always TTY.
const isTty = !!process.stdin.isTTY;
if (isTty) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useInput((input, key) => {
if (input === "q" || (key.ctrl && input === "c") || key.escape) {
client.close();
exit();
}
});
}
// Layout math: compact 4-section vertical stack.
// row budget:
// header (story + status) ≈ 4 rows
// middle activity/chat ≈ floor((rows - 4 - 4 - 4) / 2)
// bottom advisor/improvements ≈ same
// footer ≈ 4 rows
const totalWidth = Math.max(80, cols);
const halfWidth = Math.floor(totalWidth / 2);
const totalRows = Math.max(20, rows);
const middleRows = Math.max(5, Math.floor((totalRows - 4 - 4 - 4) / 2));
return (
<Box flexDirection="column" width={totalWidth}>
<StatusHeader snapshot={state.snapshot} connectedIpc={state.connectedIpc} width={totalWidth} startedAt={state.startedAt} />
<Box flexDirection="row" width={totalWidth}>
<ActivityPanel dispatches={state.dispatches} width={halfWidth} height={middleRows} />
<ChatPanel chat={state.chat} width={totalWidth - halfWidth} height={middleRows} />
</Box>
<Box flexDirection="row" width={totalWidth}>
<AdvisorPanel recs={recs} width={halfWidth} height={middleRows} />
<ImprovementsPanel items={improvements} width={totalWidth - halfWidth} height={middleRows} />
</Box>
<Footer stats={stats} width={totalWidth} />
</Box>
);
}
// --- entrypoint -------------------------------------------------------------
// Use the alternate screen buffer so the dashboard owns the entire viewport
// and the operator's prior terminal contents come back after quit.
// Skip when stdout isn't a TTY (e.g. piped output during smoke tests).
const useAltScreen = !!process.stdout.isTTY && process.env.PEPA_TUI_NO_ALT !== "1";
if (useAltScreen) {
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
const onExit = () => process.stdout.write("\x1b[?1049l");
process.on("exit", onExit);
process.on("SIGINT", () => { onExit(); process.exit(0); });
process.on("SIGTERM", () => { onExit(); process.exit(0); });
}
render(<App />);