v0.3.0: Maslow + Awareness — self-learning bot with needs ladder, event-driven reflex, and TimeWeb fast advisor #27
@@ -29,6 +29,21 @@ GOOGLE_API_KEY=
|
||||
PI_DEFAULT_PROVIDER=openai
|
||||
PI_DEFAULT_MODEL=gpt-5-mini
|
||||
|
||||
# --- Fast LLM tier (TimeWeb / OpenAI-compatible, v0.3.0+) --------------------
|
||||
# Pi (CLI) handles slow deep analytics — post-mortem and 30-min reflection.
|
||||
# A parallel "fast advisor" tier handles tactical "what to do RIGHT NOW"
|
||||
# decisions when the reflex is stuck / preempted. The default expectation
|
||||
# is TimeWeb, but any OpenAI-compatible endpoint works (OpenAI direct,
|
||||
# Groq, OpenRouter, local Ollama with the OpenAI shim).
|
||||
#
|
||||
# Leave blank to disable — the bot then runs without the fast tier
|
||||
# (rc.1 scaffold is a safe no-op when API_KEY is unset).
|
||||
TIMEWEB_BASE_URL=
|
||||
TIMEWEB_API_KEY=
|
||||
TIMEWEB_MODEL=
|
||||
# Optional: request timeout in ms (default 8000)
|
||||
TIMEWEB_TIMEOUT_MS=
|
||||
|
||||
# --- Bot behaviour ------------------------------------------------------------
|
||||
# How often (seconds) the autonomous tick prompt fires. Set to 0 to disable.
|
||||
TICK_INTERVAL_SECONDS=60
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# pepa v0.3.0 — Maslow + Awareness
|
||||
|
||||
Concept: needs-based hierarchical agent with real-time event awareness and a
|
||||
fast LLM tactical advisor. Pi (CLI) stays for slow deep analytics
|
||||
(postmortem, reflection). A second, fast LLM tier (TimeWeb / OpenAI-
|
||||
compatible) handles "what to do right now" decisions when the reflex
|
||||
detects wedged/stuck/changed environment.
|
||||
|
||||
This is a major behavioural rewrite over v0.2.x:
|
||||
|
||||
- v0.2.x had a flat reflex chain (modes → defend → eat → sleep →
|
||||
curriculum → idle) — curriculum was just "next mode". The bot had no
|
||||
internal concept of "do I have a pickaxe?" let alone "do I have a
|
||||
shelter?" Lessons from Pi were hallucinated skill names (47/47
|
||||
Pi-extracted lessons applied_count=0 as of v0.2.0-rc.3).
|
||||
- v0.3.0 introduces a **needs ladder** (Maslow-like) that drives the
|
||||
bot's intent end-to-end, an **awareness layer** that reacts to env
|
||||
changes within ~100ms instead of waiting for the next tick boundary,
|
||||
and a **fast LLM** tier that closes the loop when the bot is stuck.
|
||||
|
||||
## Concept terms
|
||||
|
||||
This pattern is called variously in the AI literature:
|
||||
- **Hierarchical Task Network (HTN)** planning — Voyager uses this
|
||||
- **Needs-based / utility AI** — game-AI mainstream
|
||||
- **BDI agent** (Beliefs-Desires-Intentions) — academic AI
|
||||
- **Subsumption architecture** (Brooks) — reactive layers preempt
|
||||
deliberative layers when conditions trigger
|
||||
|
||||
Pepa v0.3.0 is essentially **Maslow-stack curriculum + Brooks-style
|
||||
preemption + dual-tier LLM (fast tactical + slow analytical)**.
|
||||
|
||||
## Manifesto / Needs ladder
|
||||
|
||||
```
|
||||
L0 alive HP>5, не тонет, не горит, не падает с фатальной высоты
|
||||
L1 food ≥6 насыщения (готов кушать на месте)
|
||||
L2 tools_wood wooden_pickaxe + wooden_axe + wooden_sword
|
||||
L3 shelter_basic 4 стены + крыша + кровать в радиусе 8 от спавн-base
|
||||
L4 tools_stone stone_pickaxe + stone_axe + stone_sword
|
||||
L5 armor_basic хотя бы один кусок (predпочтительно нагрудник)
|
||||
L6 food_security ≥16 еды + источник (ферма / стая коров рядом)
|
||||
L7 tools_iron iron_pickaxe + iron_axe + iron_sword
|
||||
L8 armor_iron полный iron set
|
||||
L9 village_seed 2+ постройки, забор/оградка, базовая ферма
|
||||
L10 village_full глобальная цель (ферма + дом + сосед-NPC мечта)
|
||||
```
|
||||
|
||||
На каждом тике reflex выбирает **самую нижнюю неудовлетворённую** нужду.
|
||||
Эта нужда становится **активной**. Curriculum.next() и Pi-coach подсказки
|
||||
дальше выбираются **внутри** активной нужды. Если нужда сменилась
|
||||
(например, HP упало → L0 проснулся), текущий skill прерывается.
|
||||
|
||||
## v0.3.0 release plan (3 rc)
|
||||
|
||||
### rc.1 — Skill registry hardening + Fast advisor scaffold
|
||||
**Цель**: убрать главную проблему v0.2.x — Pi инвентит skill names.
|
||||
Не вводим манифест ещё, но строим инфраструктуру для него.
|
||||
|
||||
- `runtime/skill-registry.js` — exported `listSkillIds()`, `isRegistered(id)`,
|
||||
`skillRegistryPrompt()` (готовый блок текста для LLM-промпта со списком
|
||||
валидных id, по группам)
|
||||
- Все Pi-промпты (`coach/postmortem.js`, `coach/reflect.js`) передают
|
||||
реестр в system prompt
|
||||
- `runtime/coach/advice.js`: `normalisePreferSkill` строго отбрасывает
|
||||
всё, что не в реестре (raise log, не дрейфит на fuzzy)
|
||||
- `runtime/llm/provider.js` — OpenAI-совместимый клиент, конфигурируется
|
||||
через env (`TIMEWEB_BASE_URL`, `TIMEWEB_API_KEY`, `TIMEWEB_MODEL`); graceful
|
||||
fallback "no-op" если env не задан (бот не падает)
|
||||
- `runtime/coach/fast-advisor.js` — функция `advise({snapshot, reason})`
|
||||
с rate-limit (макс. 6 вызовов/час), таймаут 8с, JSON-парсинг ответа
|
||||
через тот же `extractJson()` что у Pi. **Пока не подключаем к reflex** —
|
||||
scaffold + тесты
|
||||
- Тесты для каждого нового модуля + регрессионный тест:
|
||||
`advice.test.js` проверяет что hallucinated `relocate.surface` falls
|
||||
through (никакой override)
|
||||
- Минимум 270+ зелёных тестов
|
||||
|
||||
### rc.2 — Manifesto / Needs ladder + curriculum integration
|
||||
**Цель**: bot acts toward concrete needs, not toward "explore further".
|
||||
|
||||
- `runtime/manifesto/needs.js` — каталог 11 нужд, каждая со схемой:
|
||||
```
|
||||
{ id, level, detect(snapshot) → boolean satisfied, prefer_skill_for_pursuit, ... }
|
||||
```
|
||||
- `runtime/manifesto/state.js` — `pickActiveNeed(snapshot)` возвращает
|
||||
самую нижнюю неудовлетворённую. Кеширует на 5с.
|
||||
- `runtime/reflex.js`:
|
||||
- В `curriculumReflex` сначала `activeNeed = pickActiveNeed(...)`
|
||||
- Skill подбирается в первую очередь по `activeNeed.prefer_skill_for_pursuit`
|
||||
- Fallback на curriculum.next() только если нужда не дала однозначного skill
|
||||
- `runtime/coach/advice.js`: `consult()` теперь принимает `activeNeed` и
|
||||
отбрасывает lessons чьи trigger_situation противоречит текущей нужде
|
||||
(например, "избегай ночью гулять" не применяется когда L0=alive в опасности)
|
||||
- Pi-промпты (postmortem, reflect) получают `currentNeed: "L2 tools_wood"`
|
||||
и просят Pi дать совет именно для этого уровня
|
||||
- Новый персонаж reflex hook: при смене activeNeed бот произносит в чате
|
||||
"пора заняться X" (Russian narration tying into chatter.js)
|
||||
- Тесты: каждая нужда имеет 2-3 теста (detect satisfied/unsatisfied,
|
||||
правильный prefer_skill)
|
||||
|
||||
### rc.3 — Event-driven awareness + skill pre-emption
|
||||
**Цель**: bot reacts within ~100ms to env changes (fall, teleport,
|
||||
damage, hostile spawn near).
|
||||
|
||||
- `runtime/awareness/events.js` — установка listeners:
|
||||
- `bot.on('move')` — детект position-jump >5 блоков за тик → событие
|
||||
`forced-move` → инвалидация current dispatch context
|
||||
- `bot.on('health')` — снижение HP > 2 за тик → reflex.preempt()
|
||||
- `bot.on('entitySpawn')` — враждебный <12 блоков → reflex.preempt()
|
||||
- `bot.on('blockUpdate')` около бота (manhattan <4) → пометка
|
||||
`environment_changed=true`
|
||||
- `runtime/awareness/state.js` — храним flags `(forcedMove, lastDamage,
|
||||
hostileAdded, envChanged)`, expose `consumeFlags()` для reflex
|
||||
- Skill protocol extended: `execute(ctx, args)` теперь получает
|
||||
`ctx.abortSignal` (AbortSignal). Длинные операции (pathfinder.goto,
|
||||
collectBlock loops) проверяют `signal.aborted` между шагами и сразу
|
||||
возвращают `{ok: false, code: 'preempted'}`
|
||||
- `runtime/reflex.js`: при срабатывании preempt-флагов вызывается
|
||||
`currentDispatch?.abort()`, и reflex запускает следующий тик
|
||||
немедленно (не ждёт `DISPATCH_INTERVAL_MS`)
|
||||
- `recovery.tunnel-out`, `survive.pillar-up`, `gather.logs`, `explore.far`
|
||||
адаптируются под AbortSignal (минимальное — `if (signal.aborted)
|
||||
return { ok:false, code:'preempted' }` после каждого `await`)
|
||||
- **Связка с fast advisor**: когда preempt сработал из-за `forcedMove`
|
||||
или environment_changed, и reflex не находит очевидный skill, вызывает
|
||||
`fastAdvisor.advise(...)` чтобы получить тактический совет (rc.1
|
||||
scaffolding активируется здесь)
|
||||
|
||||
### Acceptance signals (после rc.3)
|
||||
|
||||
- В живой БД: `lessons WHERE applied_count > 0` растёт (сейчас 3,
|
||||
должно стать 20+ за сутки)
|
||||
- Bot движется к конкретным целям: видимый прогресс инвентаря (wood →
|
||||
pickaxe → stone → axe), а не "блуждание в одном квадранте"
|
||||
- При forcedMove бот меняет план в течение секунды, не продолжает
|
||||
старый skill
|
||||
- Fast advisor пакетно срабатывает <10 раз/час, каждый раз приводит к
|
||||
смене skill (логируется)
|
||||
|
||||
## Что отложено в v0.3.1
|
||||
|
||||
- **Vision** (multimodal LLM на скриншотах) — требует prismarine-viewer
|
||||
pipeline + multimodal model в провайдере; не в первом релизе
|
||||
- **Vector memory of scenarios** — embeddings от похожих ситуаций
|
||||
- **Auto-curriculum from wiki** — фоновый паук minecraft.wiki
|
||||
|
||||
## Workflow notes
|
||||
|
||||
- Каждый rc — отдельный PR, мержим после approve
|
||||
- main защищён, auto-patch открывает PR с тегом `auto-patch`
|
||||
- Если что-то ломается в проде (живой бот в петле >30 мин), откатываем
|
||||
на v0.2.0-rc.3 commit `865aae1` через `git checkout <commit>` на
|
||||
ветке `revert/v0.3.0-stability`
|
||||
- Тестовые данные строго в `/tmp/pepa-test-state-*` (исправлено в v0.2.0-rc.2)
|
||||
@@ -0,0 +1,248 @@
|
||||
# pepa v0.3.0 — status
|
||||
|
||||
Live tracking document for the v0.3.0 iteration ("Maslow + Awareness").
|
||||
See [`PLAN.md`](./PLAN.md) for the full design.
|
||||
|
||||
## Shipped
|
||||
|
||||
### rc.1 — Live skill registry + Fast advisor scaffold
|
||||
**Root problem solved**: 47/47 Pi-extracted lessons in v0.2.x had
|
||||
`applied_count = 0` because Pi was hallucinating skill ids
|
||||
(`relocate.surface`, `choose.safe.surface`, `survive.shelter`,
|
||||
`gather.visible_log`, …) that don't exist in the registry. Both halves
|
||||
fixed: (a) Pi now sees the real registry in its system prompt,
|
||||
(b) anything that still slips through gets rejected at consult time.
|
||||
|
||||
- [`runtime/skill-registry.js`](../../runtime/skill-registry.js) —
|
||||
single source of truth wrapping `skills/index.js`. Exports:
|
||||
- `listSkillIds()` — live id list
|
||||
- `isRegistered(id)` — bool check
|
||||
- `describeSkill(id)` — id/title/timeoutMs
|
||||
- `skillRegistryPrompt({ limit })` — prompt-ready block grouped by
|
||||
namespace, with "USE ONLY THESE, never invent" instruction
|
||||
- [`runtime/llm/provider.js`](../../runtime/llm/provider.js) —
|
||||
OpenAI-compatible chat client, env-driven:
|
||||
- `TIMEWEB_BASE_URL` (default `https://api.openai.com/v1`)
|
||||
- `TIMEWEB_API_KEY` (required to enable; safe no-op otherwise)
|
||||
- `TIMEWEB_MODEL` (required)
|
||||
- `TIMEWEB_TIMEOUT_MS` (default 8000)
|
||||
- Supports JSON-mode via `response_format: { type: "json_object" }`
|
||||
- Surfaces `not_configured`, `no_model`, `http_<status>`,
|
||||
`network_error`, `timeout`, `bad_json` codes
|
||||
- [`runtime/coach/fast-advisor.js`](../../runtime/coach/fast-advisor.js)
|
||||
— tactical "what now?" tier. Scaffold only in rc.1; auto-trigger
|
||||
comes in rc.3.
|
||||
- `advise({snapshot, reason, recentSkillIds, lessonsTail})` →
|
||||
`{action: 'switch_skill'|'continue'|'wait', skillId?, rationale}`
|
||||
- Rejects any returned `skill_id` not in the live registry
|
||||
- Rate-limit: 6 calls/hour, 30s cooldown between calls
|
||||
- System prompt embeds registry; user prompt carries snapshot + trigger
|
||||
- [`runtime/coach/advice.js`](../../runtime/coach/advice.js):
|
||||
- `normalisePreferSkill()` now returns `null` for anything not in
|
||||
registry/mode-map (was: passed through unchanged → dispatcher
|
||||
crashed at `runSkill()`)
|
||||
- Logs `warn` line when a hallucinated prefer_skill is dropped
|
||||
- [`runtime/coach/postmortem.js`](../../runtime/coach/postmortem.js):
|
||||
- Pi prompt includes the live registry block (`skillRegistryPrompt`)
|
||||
with a "CRITICAL: USE ONLY THESE" instruction
|
||||
- On insert, drops `prefer_skill`/`avoid_skill` that's neither a
|
||||
registered id nor a known mode name; warn-logs the count
|
||||
- [`runtime/coach/reflect.js`](../../runtime/coach/reflect.js) — same
|
||||
treatment as postmortem (registry in prompt + write-time filter)
|
||||
|
||||
Tests: 279 green (was 257 on rc.3). Added:
|
||||
- `runtime/skill-registry.test.js` — 5 tests
|
||||
- `runtime/llm/provider.test.js` — 9 tests
|
||||
- `runtime/coach/fast-advisor.test.js` — 10 tests
|
||||
|
||||
### rc.2 — Manifesto / Needs ladder L0-L10
|
||||
**Root problem solved**: pre-v0.3.0 the bot had no notion of intermediate
|
||||
goals. The curriculum produced a single "next milestone" but no
|
||||
hierarchy. So when the bot was wedged with no pickaxe, it kept trying
|
||||
`explore.far` instead of recognising "I need wood → planks → pickaxe
|
||||
first". Lessons from Pi couldn't help because there was no
|
||||
internal-state language to express "L2 not satisfied".
|
||||
|
||||
The needs ladder gives the bot an explicit, ordered list of survival
|
||||
concerns. Each reflex tick picks the LOWEST unsatisfied need and
|
||||
dispatches a concrete skill toward it.
|
||||
|
||||
```
|
||||
L0 alive HP>5, food>0, no lava, no creeper@close
|
||||
L1 food ≥6 food items in inventory (or hungry+have any)
|
||||
L2 tools_wood wooden_pickaxe + wooden_axe + wooden_sword
|
||||
L3 shelter_basic bed placed nearby or in inventory
|
||||
L4 tools_stone stone tier (pickaxe + axe + sword)
|
||||
L5 armor_basic any chestplate equipped (pursue=null for now)
|
||||
L6 food_security ≥16 food items
|
||||
L7 tools_iron iron tier (pursue=gather.stone until craft.iron-* lands)
|
||||
L8 armor_iron iron chestplate (pursue=null for now)
|
||||
L9 village_seed bed + chest nearby
|
||||
L10 village_full global goal (never detected, falls through to curriculum)
|
||||
```
|
||||
|
||||
- [`runtime/manifesto/needs.js`](../../runtime/manifesto/needs.js) —
|
||||
catalogue of 11 needs. Each has `detect(snapshot)` and
|
||||
`pursue(snapshot)`. Pursue can return `null` (e.g. armor levels) and
|
||||
the ladder gracefully skips, recording the level as "blocked".
|
||||
- [`runtime/manifesto/state.js`](../../runtime/manifesto/state.js) —
|
||||
`pickActiveNeed(snapshot)` walks the ladder, picks the first
|
||||
unsatisfied + pursuable need. Returns `{need, skillId, args, blockedNeeds}`.
|
||||
3-second cache to avoid re-walking the ladder on every micro-tick.
|
||||
Validates `skillId` against the live registry (rc.1 piece) before
|
||||
returning — manifesto can't ship a hallucinated id.
|
||||
- [`runtime/reflex.js`](../../runtime/reflex.js):
|
||||
- `curriculumReflex` now consults manifesto FIRST. If a need dictates
|
||||
a skill, that's what gets dispatched. The curriculum plan is the
|
||||
fallback when manifesto has no concrete pursue.
|
||||
- Tests can pass `ctx.disableManifesto = true` to exercise the
|
||||
curriculum branch in isolation.
|
||||
- [`runtime/coach/reflect.js`](../../runtime/coach/reflect.js) — Pi
|
||||
self-reflection prompt now includes the active need
|
||||
(`L2 tools_wood → gather.logs (Деревянные орудия)`) so Pi can give
|
||||
level-appropriate advice instead of generic suggestions.
|
||||
|
||||
Tests: 315 green (was 279 on rc.1, +36 new):
|
||||
- `runtime/manifesto/needs.test.js` — 24 tests (one per need detect/pursue)
|
||||
- `runtime/manifesto/state.test.js` — 10 tests (ladder walk, caching, skipping)
|
||||
- `runtime/reflex.test.js` — 2 new integration tests (manifesto-on
|
||||
overrides curriculum; well-fed bot pursues tools_stone)
|
||||
|
||||
### rc.4 (this commit batch) — Paradigm shift: TimeWeb-only LLM + improvement queue
|
||||
**What changed**: Pi (CLI subscription) was removed from every
|
||||
background loop. The bot's analytical LLM path (`coach/postmortem`,
|
||||
`coach/reflect`) now goes through the same TimeWeb endpoint the fast
|
||||
advisor already uses. The trigger system was extended with
|
||||
emergency conditions (low HP + close hostile, lava under foot)
|
||||
that bypass the long cooldown. Every recommendation is persisted to
|
||||
SQLite with its outcome, and a deterministic tuner watches the
|
||||
stats to flag underperforming triggers. The LLM also writes a
|
||||
queue of "structural gaps" — missing skills or features —
|
||||
that the operator reviews and implements by hand.
|
||||
|
||||
- [`runtime/coach/llm-call.js`](../../runtime/coach/llm-call.js) —
|
||||
shared `askAnalytical()` helper that wraps `runtime/llm/provider.js#complete()`
|
||||
with a longer (30s) timeout suitable for postmortem and reflect.
|
||||
- [`runtime/coach/postmortem.js`](../../runtime/coach/postmortem.js):
|
||||
- Drain loop runs through TimeWeb, not Pi CLI
|
||||
- `buildPrompt()` returns `{system, user}` (was a single concatenated string)
|
||||
- Reply schema includes `improvements[]` for missing-skill callouts
|
||||
- `lessons` source is now `timeweb-coach` (was `pi-coach`)
|
||||
- [`runtime/coach/reflect.js`](../../runtime/coach/reflect.js) — same
|
||||
treatment. `lessons` source is now `timeweb-reflect`.
|
||||
- [`runtime/coach/advisor-trigger.js`](../../runtime/coach/advisor-trigger.js):
|
||||
- **Emergency triggers** added: HP≤6 + hostile≤8b, or lava under foot.
|
||||
Use a much shorter 20s cooldown — wait-on-cooldown would be lethal.
|
||||
- Active need now passed to the LLM so suggestions track the manifesto.
|
||||
- Every recommendation is `insertRecommendation()`-ed; reflex marks
|
||||
`applied=1` when it dispatches, and `outcome_ok` when the skill returns.
|
||||
- [`runtime/knowledge/schema.sql`](../../runtime/knowledge/schema.sql):
|
||||
two new tables.
|
||||
- `advisor_recommendations` — ground truth for the LLM trail with
|
||||
full token usage + outcome attribution
|
||||
- `improvement_requests` — operator-facing queue. Dedup by title
|
||||
bumps `votes` instead of inserting duplicates.
|
||||
- [`runtime/coach/trigger-tuner.js`](../../runtime/coach/trigger-tuner.js)
|
||||
(new) — hourly: reads 24h of recommendation stats, flags low-success
|
||||
triggers and expensive-prompt-mediocre-payoff cases as
|
||||
`improvement_requests` with `source="tuner"`. No LLM call needed
|
||||
— pure SQL.
|
||||
- [`runtime/llm/provider.js`](../../runtime/llm/provider.js):
|
||||
`complete()` now returns `usage: {in, out, total}` and logs
|
||||
`in=Nt/out=Mt` on every call.
|
||||
- [`runtime/coach/fast-advisor.js`](../../runtime/coach/fast-advisor.js):
|
||||
`getUsageSnapshot()` aggregates total tokens across the session;
|
||||
surfaces in `scripts/list-improvements.js --stats`.
|
||||
- [`scripts/list-improvements.js`](../../scripts/list-improvements.js)
|
||||
(new) — operator CLI. `--status open` (default), `--stats`,
|
||||
`--done <id> [note]`, `--inprogress <id>`, `--reject <id>`,
|
||||
`--source <postmortem|reflect|advisor|tuner|manual>`,
|
||||
`--category <skill|tuning|...>`.
|
||||
|
||||
Cost measurement (smoke-test against TimeWeb gpt-5.4-mini):
|
||||
per advise(): ~705 input + 45 output = ~750 tokens
|
||||
rate cap: 6 calls/hour
|
||||
worst case @ full hourly cap: ~108K tokens/day
|
||||
estimated cost (OpenAI gpt-5-mini reference pricing): ~$0.60/month
|
||||
|
||||
Tests: 360 green (was 332 on v0.3.0-rc.3, +28 new):
|
||||
+3 abortSignal tests in skills/contract.test.js
|
||||
+13 advisor-trigger tests
|
||||
+4 emergency-trigger tests
|
||||
+4 knowledge-recommendation tests
|
||||
+3 knowledge-improvement tests
|
||||
+2 postmortem/reflect rewrites for TimeWeb path
|
||||
+7 trigger-tuner tests (low success / expensive / healthy / dedup)
|
||||
|
||||
### rc.3 — Event-driven awareness + skill pre-emption
|
||||
**Root problem solved**: in v0.2.x the reflex was purely polling. The
|
||||
loop took a snapshot every DISPATCH_INTERVAL_MS (~2s) and decided what
|
||||
to do, but anything that happened **between** ticks was invisible.
|
||||
Concretely: when the operator dug a path that let the bot fall to a
|
||||
new area, the bot continued executing its prior `explore.far` against
|
||||
stale assumptions until the next tick. By then it had wandered further
|
||||
off course, and the cycle never broke. Same problem for hostile spawns
|
||||
and HP plunges — the reflex saw them only after the current skill ran
|
||||
its 30-90s timeout.
|
||||
|
||||
This rc gives the reflex an event-driven layer that **preempts** the
|
||||
in-flight skill within ~100ms of an environmental shock.
|
||||
|
||||
- [`runtime/awareness/events.js`](../../runtime/awareness/events.js) —
|
||||
wires direct `bot.on(...)` listeners and surfaces them as flags + an
|
||||
optional preempt callback:
|
||||
- `bot.on("move")` — single-tick position jump ≥ 5 blocks (teleport,
|
||||
fall, pathfinder snap, operator pushed us) → `forced_move`
|
||||
- `bot.on("health")` — HP drop ≥ 2 in one tick → `health_plunge`
|
||||
- `bot.on("entitySpawn")` — hostile mob spawns within 12 blocks →
|
||||
`hostile_added`
|
||||
- `bot.on("blockUpdate")` — block change within manhattan 4 →
|
||||
`env_changed` (informational only, NOT preempting; throttled 800ms)
|
||||
- [`runtime/skills/index.js`](../../runtime/skills/index.js):
|
||||
- `RUNNER_CODES.PREEMPTED` — new stable failure code
|
||||
- `runSkill()` now races `execute()` with `ctx.abortSignal`. If the
|
||||
signal fires mid-await, the skill returns `{ ok: false, code:
|
||||
"preempted" }` within one microtask — no skill code change needed.
|
||||
Long-running skills (`gather.logs`, `explore.far`,
|
||||
`recovery.tunnel-out`, `survive.pillar-up`) get this for free.
|
||||
- [`runtime/bot.js`](../../runtime/bot.js):
|
||||
- `dispatchAction` creates a fresh `AbortController` per dispatch
|
||||
and stores it on `reflexCtx.currentAbort` + `reflexCtx.abortSignal`
|
||||
- `bot.once("spawn")` calls `attachAwareness(bot, {onPreempt})`
|
||||
where `onPreempt` aborts the current dispatch
|
||||
- `reflexCtx.lastPreempt` records the most recent shock for
|
||||
snapshot/telemetry consumers
|
||||
|
||||
Tests: 332 green (was 315 on rc.2, +17 new):
|
||||
- `runtime/awareness/events.test.js` — 12 tests (each event type,
|
||||
thresholds, throttling, hostile filter)
|
||||
- `runtime/skills/contract.test.js` — 3 new preempt tests (mid-flight
|
||||
abort, pre-armed signal, clean signal doesn't interfere)
|
||||
- 2 extra contract sanity checks shaken out by signal plumbing
|
||||
|
||||
## Next session quick start
|
||||
|
||||
1. **Read PLAN.md** for the full design and per-rc breakdown.
|
||||
2. **Check live DB** to see if Pi-lesson application is improving:
|
||||
```bash
|
||||
sqlite3 state/play.xmatic.team_25565/knowledge.db \
|
||||
"SELECT source, COUNT(*) AS n, SUM(applied_count > 0) AS applied
|
||||
FROM lessons GROUP BY source ORDER BY n DESC;"
|
||||
```
|
||||
After rc.1 deploys, expect Pi-coach/Pi-reflect `applied` count to
|
||||
start growing as the registry feedback closes the loop.
|
||||
3. **Set fast-advisor env when ready to test**:
|
||||
```bash
|
||||
export TIMEWEB_BASE_URL="https://<timeweb-endpoint>/v1"
|
||||
export TIMEWEB_API_KEY="<key>"
|
||||
export TIMEWEB_MODEL="gpt-5-mini"
|
||||
```
|
||||
The advisor still isn't auto-triggered in rc.1 — it's wired in rc.3.
|
||||
4. **Pick the next rc** from PLAN.md.
|
||||
|
||||
## Workflow notes
|
||||
|
||||
- main is protected — only operator merges PRs
|
||||
- Tests: `npm test` (279 green at last check), isolated under `/tmp/`
|
||||
- The bot supervisor hot-restarts on file changes in `runtime/**/*.js`
|
||||
- If something regresses badly, revert to v0.2.0-rc.3 commit `865aae1`
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pepa-pi-bot",
|
||||
"version": "0.2.0-rc.3",
|
||||
"version": "0.3.0-rc.3",
|
||||
"private": true,
|
||||
"description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.",
|
||||
"license": "MIT",
|
||||
@@ -16,7 +16,7 @@
|
||||
"tui": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js",
|
||||
"stop": "bash scripts/stop.sh",
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/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/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.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/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"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Event-driven awareness. The reflex used to be polling-only: every
|
||||
// DISPATCH_INTERVAL_MS the loop took a snapshot and decided what to do.
|
||||
// That means anything happening *between* ticks — a creeper spawning,
|
||||
// the bot taking damage, the bot being teleported by a falling block —
|
||||
// was invisible until the next tick, and any active skill kept running
|
||||
// against stale assumptions.
|
||||
//
|
||||
// This module wires direct mineflayer listeners that update a small
|
||||
// flags object the reflex can consume each tick AND that triggers
|
||||
// "preempt" callbacks (registered by the dispatcher) when something
|
||||
// significant happens. The skill currently in flight can react by
|
||||
// observing ctx.abortSignal.aborted between awaits.
|
||||
|
||||
import { info } from "../log.js";
|
||||
|
||||
const HOSTILE_NAMES = new Set([
|
||||
"zombie", "skeleton", "creeper", "spider", "cave_spider", "witch",
|
||||
"husk", "stray", "drowned", "phantom", "blaze", "ghast", "magma_cube",
|
||||
"pillager", "vindicator", "vex", "wither_skeleton", "wither", "ravager",
|
||||
"enderman", "endermite", "guardian", "elder_guardian", "evoker", "silverfish",
|
||||
"hoglin", "zoglin", "piglin", "piglin_brute", "shulker", "warden",
|
||||
]);
|
||||
|
||||
// Heuristic thresholds — tunable later.
|
||||
const FORCED_MOVE_BLOCKS = 5; // single tick movement > this = forced (teleport/fall/push)
|
||||
const HEALTH_PLUNGE_DELTA = 2; // HP dropped by ≥ this in one tick = take note
|
||||
const HOSTILE_CLOSE_BLOCKS = 12; // entity spawning within = preempt
|
||||
const BLOCK_UPDATE_RADIUS = 4; // blockUpdate within manhattan = env-changed
|
||||
const ENV_CHANGE_THROTTLE_MS = 800;
|
||||
|
||||
export function attachAwareness(bot, { onPreempt = null } = {}) {
|
||||
if (!bot || typeof bot.on !== "function") {
|
||||
throw new Error("attachAwareness: bot.on missing");
|
||||
}
|
||||
const state = createAwarenessState();
|
||||
let lastPos = bot.entity?.position ? cloneVec(bot.entity.position) : null;
|
||||
let lastHealth = typeof bot.health === "number" ? bot.health : null;
|
||||
let lastEnvChangeAt = 0;
|
||||
|
||||
function preempt(reason, payload) {
|
||||
try { onPreempt?.({ reason, payload, at: Date.now() }); } catch (e) {
|
||||
info("awareness", `preempt callback threw: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
bot.on("move", () => {
|
||||
const pos = bot.entity?.position;
|
||||
if (!pos) return;
|
||||
const cur = cloneVec(pos);
|
||||
if (lastPos) {
|
||||
const dist = Math.hypot(cur.x - lastPos.x, cur.y - lastPos.y, cur.z - lastPos.z);
|
||||
if (dist >= FORCED_MOVE_BLOCKS) {
|
||||
state.flags.forcedMove = { at: Date.now(), from: lastPos, to: cur, distance: Math.round(dist * 10) / 10 };
|
||||
info("awareness", `forced move: ${state.flags.forcedMove.distance}b from (${Math.round(lastPos.x)}, ${Math.round(lastPos.y)}, ${Math.round(lastPos.z)}) to (${Math.round(cur.x)}, ${Math.round(cur.y)}, ${Math.round(cur.z)})`);
|
||||
preempt("forced_move", state.flags.forcedMove);
|
||||
}
|
||||
}
|
||||
lastPos = cur;
|
||||
});
|
||||
|
||||
bot.on("health", () => {
|
||||
const hp = bot.health;
|
||||
if (typeof hp !== "number") return;
|
||||
if (lastHealth !== null && hp + HEALTH_PLUNGE_DELTA <= lastHealth) {
|
||||
state.flags.healthPlunge = { at: Date.now(), from: lastHealth, to: hp, delta: lastHealth - hp };
|
||||
info("awareness", `hp plunge: ${lastHealth} → ${hp}`);
|
||||
preempt("health_plunge", state.flags.healthPlunge);
|
||||
}
|
||||
lastHealth = hp;
|
||||
});
|
||||
|
||||
bot.on("entitySpawn", (entity) => {
|
||||
if (!entity) return;
|
||||
const name = (entity.name ?? "").toLowerCase();
|
||||
if (!HOSTILE_NAMES.has(name)) return;
|
||||
const me = bot.entity?.position;
|
||||
if (!me || !entity.position) return;
|
||||
const dist = me.distanceTo(entity.position);
|
||||
if (dist > HOSTILE_CLOSE_BLOCKS) return;
|
||||
state.flags.hostileAdded = { at: Date.now(), name, distance: Math.round(dist * 10) / 10 };
|
||||
info("awareness", `hostile near: ${name}@${state.flags.hostileAdded.distance}m`);
|
||||
preempt("hostile_added", state.flags.hostileAdded);
|
||||
});
|
||||
|
||||
bot.on("blockUpdate", (oldBlock, newBlock) => {
|
||||
const me = bot.entity?.position;
|
||||
if (!me) return;
|
||||
const block = newBlock ?? oldBlock;
|
||||
const at = block?.position;
|
||||
if (!at) return;
|
||||
const manhattan = Math.abs(at.x - me.x) + Math.abs(at.y - me.y) + Math.abs(at.z - me.z);
|
||||
if (manhattan > BLOCK_UPDATE_RADIUS) return;
|
||||
const now = Date.now();
|
||||
if (now - lastEnvChangeAt < ENV_CHANGE_THROTTLE_MS) return;
|
||||
lastEnvChangeAt = now;
|
||||
state.flags.envChanged = { at: now, blockName: block?.name ?? "?", distance: manhattan };
|
||||
// envChanged is informational only — does NOT trigger preempt by
|
||||
// default (block updates are too frequent during gather skills).
|
||||
});
|
||||
|
||||
state._teardown = () => {
|
||||
// node:events doesn't expose direct unbind without storing refs.
|
||||
// In tests we just drop the bot. Real reflex never detaches.
|
||||
};
|
||||
|
||||
info("awareness", "attached (forced_move + health_plunge + hostile_added + env_changed)");
|
||||
return state;
|
||||
}
|
||||
|
||||
export function createAwarenessState() {
|
||||
return {
|
||||
flags: {
|
||||
forcedMove: null,
|
||||
healthPlunge: null,
|
||||
hostileAdded: null,
|
||||
envChanged: null,
|
||||
},
|
||||
consume() {
|
||||
const out = { ...this.flags };
|
||||
this.flags = {
|
||||
forcedMove: null,
|
||||
healthPlunge: null,
|
||||
hostileAdded: null,
|
||||
envChanged: null,
|
||||
};
|
||||
return out;
|
||||
},
|
||||
hasPreempting() {
|
||||
const f = this.flags;
|
||||
return !!(f.forcedMove || f.healthPlunge || f.hostileAdded);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cloneVec(v) {
|
||||
return { x: v.x, y: v.y, z: v.z };
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = {
|
||||
HOSTILE_NAMES, FORCED_MOVE_BLOCKS, HEALTH_PLUNGE_DELTA,
|
||||
HOSTILE_CLOSE_BLOCKS, BLOCK_UPDATE_RADIUS, ENV_CHANGE_THROTTLE_MS,
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { attachAwareness, createAwarenessState, __testing } from "./events.js";
|
||||
|
||||
function vec(x, y, z) {
|
||||
return {
|
||||
x, y, z,
|
||||
distanceTo(other) {
|
||||
return Math.hypot(this.x - other.x, this.y - other.y, this.z - other.z);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeBot(pos = vec(0, 64, 0), hp = 20) {
|
||||
const bot = new EventEmitter();
|
||||
bot.entity = { position: pos };
|
||||
bot.health = hp;
|
||||
return bot;
|
||||
}
|
||||
|
||||
test("attachAwareness: throws when bot has no on()", () => {
|
||||
assert.throws(() => attachAwareness({}), /bot\.on missing/);
|
||||
});
|
||||
|
||||
test("createAwarenessState: starts with null flags, consume resets", () => {
|
||||
const s = createAwarenessState();
|
||||
assert.equal(s.flags.forcedMove, null);
|
||||
s.flags.forcedMove = { at: 1, from: {}, to: {}, distance: 7 };
|
||||
assert.equal(s.hasPreempting(), true);
|
||||
const out = s.consume();
|
||||
assert.equal(out.forcedMove.distance, 7);
|
||||
assert.equal(s.flags.forcedMove, null);
|
||||
});
|
||||
|
||||
test("forcedMove: jump > threshold flags + preempts", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot(vec(0, 64, 0));
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
// move within threshold — no flag
|
||||
bot.entity.position = vec(1, 64, 0);
|
||||
bot.emit("move");
|
||||
assert.equal(state.flags.forcedMove, null);
|
||||
assert.equal(calls.length, 0);
|
||||
// teleport / fall — far jump
|
||||
bot.entity.position = vec(20, 64, 0);
|
||||
bot.emit("move");
|
||||
assert.ok(state.flags.forcedMove, "forcedMove flag set");
|
||||
assert.ok(state.flags.forcedMove.distance >= 18);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "forced_move");
|
||||
});
|
||||
|
||||
test("healthPlunge: HP drop ≥ delta flags + preempts", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot(vec(0, 64, 0), 20);
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
// trivial HP change does NOT flag
|
||||
bot.health = 19;
|
||||
bot.emit("health");
|
||||
assert.equal(state.flags.healthPlunge, null);
|
||||
// big drop
|
||||
bot.health = 12;
|
||||
bot.emit("health");
|
||||
assert.ok(state.flags.healthPlunge);
|
||||
assert.equal(state.flags.healthPlunge.from, 19);
|
||||
assert.equal(state.flags.healthPlunge.to, 12);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "health_plunge");
|
||||
});
|
||||
|
||||
test("hostileAdded: zombie nearby triggers preempt", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
const zombie = { name: "zombie", position: vec(2, 64, 0) };
|
||||
bot.emit("entitySpawn", zombie);
|
||||
assert.ok(state.flags.hostileAdded);
|
||||
assert.equal(state.flags.hostileAdded.name, "zombie");
|
||||
assert.equal(state.flags.hostileAdded.distance, 2);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, "hostile_added");
|
||||
});
|
||||
|
||||
test("hostileAdded: far hostile ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const far = { name: "creeper", position: vec(50, 64, 0) };
|
||||
bot.emit("entitySpawn", far);
|
||||
assert.equal(state.flags.hostileAdded, null);
|
||||
});
|
||||
|
||||
test("hostileAdded: passive mob ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const cow = { name: "cow", position: vec(2, 64, 0) };
|
||||
bot.emit("entitySpawn", cow);
|
||||
assert.equal(state.flags.hostileAdded, null);
|
||||
});
|
||||
|
||||
test("envChanged: nearby blockUpdate flags but does NOT preempt", () => {
|
||||
const calls = [];
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot, { onPreempt: (e) => calls.push(e) });
|
||||
const newBlock = { name: "cobblestone", position: vec(1, 64, 0) };
|
||||
bot.emit("blockUpdate", null, newBlock);
|
||||
assert.ok(state.flags.envChanged);
|
||||
assert.equal(state.flags.envChanged.blockName, "cobblestone");
|
||||
assert.equal(calls.length, 0, "env changes are observational, not preempting");
|
||||
});
|
||||
|
||||
test("envChanged: throttled", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const near = { name: "stone", position: vec(2, 64, 0) };
|
||||
bot.emit("blockUpdate", null, near);
|
||||
const firstAt = state.flags.envChanged.at;
|
||||
bot.emit("blockUpdate", null, near);
|
||||
// second one within throttle window keeps the first timestamp
|
||||
assert.equal(state.flags.envChanged.at, firstAt);
|
||||
});
|
||||
|
||||
test("envChanged: far blockUpdate ignored", () => {
|
||||
const bot = makeBot();
|
||||
const state = attachAwareness(bot);
|
||||
const far = { name: "stone", position: vec(20, 64, 0) };
|
||||
bot.emit("blockUpdate", null, far);
|
||||
assert.equal(state.flags.envChanged, null);
|
||||
});
|
||||
|
||||
test("hasPreempting: true only for forcedMove/healthPlunge/hostileAdded", () => {
|
||||
const s = createAwarenessState();
|
||||
assert.equal(s.hasPreempting(), false);
|
||||
s.flags.envChanged = { at: 1, blockName: "stone", distance: 2 };
|
||||
assert.equal(s.hasPreempting(), false, "envChanged alone does not preempt");
|
||||
s.flags.hostileAdded = { at: 1, name: "creeper", distance: 5 };
|
||||
assert.equal(s.hasPreempting(), true);
|
||||
});
|
||||
|
||||
test("thresholds: constants are sane", () => {
|
||||
assert.ok(__testing.FORCED_MOVE_BLOCKS >= 3 && __testing.FORCED_MOVE_BLOCKS <= 10);
|
||||
assert.ok(__testing.HEALTH_PLUNGE_DELTA >= 1 && __testing.HEALTH_PLUNGE_DELTA <= 5);
|
||||
assert.ok(__testing.HOSTILE_CLOSE_BLOCKS >= 8);
|
||||
});
|
||||
+42
-2
@@ -60,7 +60,9 @@ import { createOwnedBlocksLedger } from "./owned-blocks.js";
|
||||
import { initKnowledge } from "./knowledge/index.js";
|
||||
import { attach as attachCoach } from "./coach/postmortem.js";
|
||||
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";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
@@ -78,6 +80,7 @@ const ESCALATION_COOLDOWN_MS = 10 * 60 * 1000;
|
||||
|
||||
let bot = null;
|
||||
let pathWatchdog = null;
|
||||
let awarenessState = null;
|
||||
let reflexPaused = false;
|
||||
let tickTimer = null;
|
||||
let reconnectTimer = null;
|
||||
@@ -237,6 +240,19 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
}
|
||||
reflexCtx.busy = true;
|
||||
reflexCtx.currentActionLabel = label;
|
||||
// Rolling window of last 8 dispatched skill ids — read by
|
||||
// runtime/coach/advisor-trigger.js to detect loops (4+ same in a row)
|
||||
reflexCtx.recentSkillIds = reflexCtx.recentSkillIds ?? [];
|
||||
reflexCtx.recentSkillIds.push(label);
|
||||
if (reflexCtx.recentSkillIds.length > 8) reflexCtx.recentSkillIds.shift();
|
||||
// v0.3.0-rc.3 — pre-emption: each dispatch gets a fresh AbortController.
|
||||
// awareness/events.js#onPreempt fires controller.abort() when the env
|
||||
// shocks (forced move, HP plunge, hostile spawn) the current skill
|
||||
// shouldn't run against. runSkill races execute() with the signal and
|
||||
// returns code: "preempted" within one microtask.
|
||||
const dispatchAbort = new AbortController();
|
||||
reflexCtx.currentAbort = dispatchAbort;
|
||||
reflexCtx.abortSignal = dispatchAbort.signal;
|
||||
const startedAt = Date.now();
|
||||
// Capture the situation hash BEFORE the action runs so a failure is
|
||||
// attributable to the state at dispatch time, not the state after the
|
||||
@@ -312,6 +328,10 @@ function dispatchAction(fn, label, opts = {}) {
|
||||
.finally(() => {
|
||||
reflexCtx.busy = false;
|
||||
reflexCtx.currentActionLabel = null;
|
||||
if (reflexCtx.currentAbort === dispatchAbort) {
|
||||
reflexCtx.currentAbort = null;
|
||||
reflexCtx.abortSignal = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -670,9 +690,29 @@ function connect() {
|
||||
// v0.2.0 — self-learning coach + persona narration. Both are
|
||||
// import-safe; they just attach listeners and (for coach) a periodic
|
||||
// Pi-drain timer. See docs/v0.2.0-self-learning.md.
|
||||
try { attachCoach(bot, { stateDir, askPi }); } catch (e) { warn("coach", `attach: ${e?.message ?? e}`); }
|
||||
try { attachReflect({ bot, stateDir, askPi, getSnapshot: () => lastSnapshot }); } catch (e) { warn("reflect", `attach: ${e?.message ?? e}`); }
|
||||
// v0.3.0 — coach/reflect run on TimeWeb (fast LLM). Pi CLI is no
|
||||
// longer wired into background loops; it remains available for
|
||||
// manual operator commands only.
|
||||
try { attachCoach(bot, { stateDir }); } catch (e) { warn("coach", `attach: ${e?.message ?? e}`); }
|
||||
try { attachReflect({ bot, stateDir, getSnapshot: () => lastSnapshot }); } catch (e) { warn("reflect", `attach: ${e?.message ?? e}`); }
|
||||
try { attachTuner(); } catch (e) { warn("tuner", `attach: ${e?.message ?? e}`); }
|
||||
try { attachChatter(bot, { getSnapshot: () => lastSnapshot }); } catch (e) { warn("persona", `attach: ${e?.message ?? e}`); }
|
||||
// v0.3.0-rc.3 — awareness layer: listens to bot.on('move'/'health'/
|
||||
// 'entitySpawn'/'blockUpdate') and aborts the current dispatch via
|
||||
// reflexCtx.currentAbort when something disrupts the in-flight skill.
|
||||
try {
|
||||
awarenessState = attachAwareness(bot, {
|
||||
onPreempt: ({ reason, payload }) => {
|
||||
const abort = reflexCtx.currentAbort;
|
||||
if (abort && !abort.signal.aborted) {
|
||||
info("preempt", `aborting ${reflexCtx.currentActionLabel ?? "?"} due to ${reason}`);
|
||||
abort.abort();
|
||||
}
|
||||
reflexCtx.lastPreempt = { reason, payload, at: Date.now() };
|
||||
},
|
||||
});
|
||||
reflexCtx.awareness = awarenessState;
|
||||
} catch (e) { warn("awareness", `attach: ${e?.message ?? e}`); }
|
||||
});
|
||||
|
||||
bot.on("messagestr", (text) => {
|
||||
|
||||
+14
-6
@@ -10,7 +10,8 @@
|
||||
// recall → behavioural change. Without this, the DB is just a log.
|
||||
|
||||
import { isAvailable as knowledgeAvailable, topAdvice, markApplied } from "../knowledge/index.js";
|
||||
import { info } from "../log.js";
|
||||
import { isRegistered } from "../skill-registry.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
// Skills we will not blindly swap into — they require their own
|
||||
// preconditions (e.g. survive.flee needs a known threat direction).
|
||||
@@ -47,15 +48,19 @@ const MODE_TO_SKILL = Object.freeze({
|
||||
});
|
||||
|
||||
function normalisePreferSkill(raw) {
|
||||
if (!raw || typeof raw !== "string") return raw;
|
||||
if (SAFE_OVERRIDES.has(raw)) return raw;
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
if (SAFE_OVERRIDES.has(raw) && isRegistered(raw)) return raw;
|
||||
const lower = raw.toLowerCase().trim();
|
||||
if (MODE_TO_SKILL[lower]) return MODE_TO_SKILL[lower];
|
||||
// Pi sometimes writes "survive_flee" or "survive flee"; normalise.
|
||||
const dot = lower.replace(/[_\s]+/g, ".");
|
||||
if (SAFE_OVERRIDES.has(dot)) return dot;
|
||||
if (SAFE_OVERRIDES.has(dot) && isRegistered(dot)) return dot;
|
||||
if (MODE_TO_SKILL[dot]) return MODE_TO_SKILL[dot];
|
||||
return raw;
|
||||
// Anything else (Pi hallucinated names like "relocate.surface",
|
||||
// "choose.safe.surface", "survive.shelter", "gather.visible_log") —
|
||||
// hard reject. We'd rather fall through to 'avoid' / 'proceed' than
|
||||
// dispatch a nonexistent skill.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +86,7 @@ export function consult({ plannedSkillId, snapshot } = {}) {
|
||||
// avoid_skill matches?
|
||||
if (advice.avoid && advice.avoid === plannedSkillId) {
|
||||
const normalisedPrefer = normalisePreferSkill(advice.prefer);
|
||||
if (normalisedPrefer && SAFE_OVERRIDES.has(normalisedPrefer)) {
|
||||
if (normalisedPrefer && SAFE_OVERRIDES.has(normalisedPrefer) && isRegistered(normalisedPrefer)) {
|
||||
if (normalisedPrefer !== advice.prefer) {
|
||||
info("coach", `advice: normalised prefer "${advice.prefer}" → "${normalisedPrefer}"`);
|
||||
}
|
||||
@@ -93,6 +98,9 @@ export function consult({ plannedSkillId, snapshot } = {}) {
|
||||
lesson: advice.lesson,
|
||||
};
|
||||
}
|
||||
if (advice.prefer && !normalisedPrefer) {
|
||||
warn("coach", `advice: rejected hallucinated prefer_skill "${advice.prefer}" (lesson #${advice.lessonId})`);
|
||||
}
|
||||
info("coach", `advice: avoid ${plannedSkillId} (lesson #${advice.lessonId})`);
|
||||
return { action: "avoid", lessonId: advice.lessonId, lesson: advice.lesson };
|
||||
}
|
||||
|
||||
@@ -117,10 +117,16 @@ test("normalisePreferSkill: passes through known dot-form skills unchanged", ()
|
||||
assert.equal(normalisePreferSkill("explore.far"), "explore.far");
|
||||
});
|
||||
|
||||
test("normalisePreferSkill: unknown values returned as-is", () => {
|
||||
assert.equal(normalisePreferSkill("some.unknown.skill"), "some.unknown.skill");
|
||||
test("normalisePreferSkill: unknown values rejected (returns null)", () => {
|
||||
// v0.3.0-rc.1: anything not in the live registry and not a known mode
|
||||
// name is rejected outright. We'd rather fall through to 'avoid' than
|
||||
// dispatch a hallucinated skill id.
|
||||
assert.equal(normalisePreferSkill("some.unknown.skill"), null);
|
||||
assert.equal(normalisePreferSkill("relocate.surface"), null);
|
||||
assert.equal(normalisePreferSkill("choose.safe.surface"), null);
|
||||
assert.equal(normalisePreferSkill("survive.shelter"), null);
|
||||
assert.equal(normalisePreferSkill(null), null);
|
||||
assert.equal(normalisePreferSkill(""), "");
|
||||
assert.equal(normalisePreferSkill(""), null);
|
||||
});
|
||||
|
||||
test("consult: Pi-style mode-name prefer is normalised to override target", async () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Auto-trigger policy for the fast tactical advisor.
|
||||
//
|
||||
// The advisor is too slow for synchronous use inside a reflex tick
|
||||
// (5-15s via TimeWeb's hosted agent endpoint). The strategy here is
|
||||
// asynchronous: when conditions warrant tactical advice, fire-and-forget
|
||||
// an advise() call; when the result eventually arrives, cache it on
|
||||
// ctx.advisorRecommendation. The next reflex tick reads that cache and
|
||||
// can substitute the recommended skill before dispatching.
|
||||
//
|
||||
// Triggers (any one, AND-ed with the not-recently-asked cooldown):
|
||||
//
|
||||
// 1. Wedged > 60s — bot's position hasn't shifted ≥16 blocks in over
|
||||
// a minute (already tracked by reflex.js as lastSignificantMoveAt)
|
||||
// 2. Last 4+ dispatches were the same skill — clear loop signal
|
||||
// 3. Last awareness preempt was very recent AND followed by same
|
||||
// skill being dispatched again — env-shock-blind retry
|
||||
//
|
||||
// The recommendation has a TTL (60s). After that it's stale and the
|
||||
// reflex falls back to manifesto / curriculum. This keeps the system
|
||||
// reactive — advice ages out, fresh data drives fresh advice.
|
||||
|
||||
import { advise, isAvailable as advisorAvailable } from "./fast-advisor.js";
|
||||
import { isRegistered } from "../skill-registry.js";
|
||||
import { insertRecommendation } from "../knowledge/index.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const TRIGGER_COOLDOWN_MS = 90_000;
|
||||
const RECOMMENDATION_TTL_MS = 60_000;
|
||||
const WEDGED_THRESHOLD_MS = 60_000;
|
||||
const REPEAT_THRESHOLD = 4;
|
||||
const PREEMPT_WINDOW_MS = 30_000;
|
||||
// Emergency triggers — bypass cooldown because waiting another 90s
|
||||
// when the bot is about to die is not useful.
|
||||
const EMERGENCY_HP = 6;
|
||||
const EMERGENCY_HOSTILE_DIST = 8;
|
||||
const EMERGENCY_COOLDOWN_MS = 20_000;
|
||||
|
||||
let _lastTriggerAt = 0;
|
||||
let _inFlight = false;
|
||||
|
||||
export function _resetForTest() {
|
||||
_lastTriggerAt = 0;
|
||||
_inFlight = false;
|
||||
}
|
||||
|
||||
export function getTriggerState() {
|
||||
return {
|
||||
lastTriggerAt: _lastTriggerAt,
|
||||
inFlight: _inFlight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* tickAdvisor(ctx) → maybe-fires advise() in background.
|
||||
*
|
||||
* Called from reflex AFTER it has chosen a plannedSkillId but BEFORE
|
||||
* dispatching. Does NOT block — the in-flight call resolves later and
|
||||
* writes ctx.advisorRecommendation. The caller decides whether to
|
||||
* consume a fresh recommendation on this tick or wait for the next.
|
||||
*/
|
||||
export function tickAdvisor(ctx, { plannedSkillId } = {}) {
|
||||
if (!advisorAvailable()) return { fired: false, reason: "disabled" };
|
||||
if (_inFlight) return { fired: false, reason: "in_flight" };
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Drop a recommendation that's already aged out.
|
||||
if (ctx.advisorRecommendation && now - ctx.advisorRecommendation.at > RECOMMENDATION_TTL_MS) {
|
||||
ctx.advisorRecommendation = null;
|
||||
}
|
||||
|
||||
const reason = detectTrigger(ctx, now, plannedSkillId);
|
||||
if (!reason) return { fired: false, reason: "no_trigger" };
|
||||
|
||||
// Emergency triggers use a much shorter cooldown — waiting 90s with
|
||||
// HP=4 and a creeper at 3 blocks is exactly when we MUST hit the LLM.
|
||||
const isEmergency = reason.startsWith("emergency_");
|
||||
const cooldownMs = isEmergency ? EMERGENCY_COOLDOWN_MS : TRIGGER_COOLDOWN_MS;
|
||||
if (now - _lastTriggerAt < cooldownMs) {
|
||||
return { fired: false, reason: "cooldown" };
|
||||
}
|
||||
|
||||
_lastTriggerAt = now;
|
||||
_inFlight = true;
|
||||
const snapshot = ctx.snapshot ?? null;
|
||||
const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8);
|
||||
const activeNeed = ctx.activeNeed ?? null;
|
||||
|
||||
info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"})`);
|
||||
// Fire-and-forget. The promise's resolution writes ctx.advisorRecommendation.
|
||||
advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, force: true })
|
||||
.then((result) => {
|
||||
_inFlight = false;
|
||||
const needLabel = activeNeed
|
||||
? `L${activeNeed.need.level} ${activeNeed.need.id}`
|
||||
: null;
|
||||
if (result.ok && result.action === "switch_skill" && isRegistered(result.skillId)) {
|
||||
const recId = insertRecommendation({
|
||||
triggerReason: reason,
|
||||
plannedSkill: plannedSkillId ?? null,
|
||||
recommendedSkill: result.skillId,
|
||||
action: "switch_skill",
|
||||
rationale: result.rationale,
|
||||
activeNeed: needLabel,
|
||||
tokensIn: result.usage?.in,
|
||||
tokensOut: result.usage?.out,
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
ctx.advisorRecommendation = {
|
||||
id: recId,
|
||||
at: Date.now(),
|
||||
skillId: result.skillId,
|
||||
action: "switch_skill",
|
||||
rationale: result.rationale,
|
||||
triggerReason: reason,
|
||||
latencyMs: result.latencyMs,
|
||||
usage: result.usage ?? null,
|
||||
};
|
||||
info("advisor-trigger", `recommendation cached: ${result.skillId} (${result.latencyMs}ms, in=${result.usage?.in ?? "?"}t/out=${result.usage?.out ?? "?"}t, db=${recId ?? "-"})`);
|
||||
} else if (result.ok && (result.action === "wait" || result.action === "continue")) {
|
||||
const recId = insertRecommendation({
|
||||
triggerReason: reason,
|
||||
plannedSkill: plannedSkillId ?? null,
|
||||
recommendedSkill: null,
|
||||
action: result.action,
|
||||
rationale: result.rationale,
|
||||
activeNeed: needLabel,
|
||||
tokensIn: result.usage?.in,
|
||||
tokensOut: result.usage?.out,
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
ctx.advisorRecommendation = {
|
||||
id: recId,
|
||||
at: Date.now(),
|
||||
action: result.action,
|
||||
rationale: result.rationale,
|
||||
triggerReason: reason,
|
||||
latencyMs: result.latencyMs,
|
||||
usage: result.usage ?? null,
|
||||
};
|
||||
info("advisor-trigger", `recommendation: ${result.action} (${result.latencyMs}ms)`);
|
||||
} else if (!result.ok) {
|
||||
warn("advisor-trigger", `advise failed: ${result.code} (${result.detail})`);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
_inFlight = false;
|
||||
warn("advisor-trigger", `advise threw: ${e?.message ?? e}`);
|
||||
});
|
||||
|
||||
return { fired: true, reason };
|
||||
}
|
||||
|
||||
function detectTrigger(ctx, now, plannedSkillId) {
|
||||
const snap = ctx.snapshot ?? {};
|
||||
|
||||
// 0. EMERGENCY: low HP + hostile near — call BEFORE the bot dies.
|
||||
// Checked first so reason string starts with "emergency_" → bypasses
|
||||
// the long trigger cooldown via the caller's isEmergency check.
|
||||
const hp = snap.health ?? 20;
|
||||
const hostile = snap.closestHostile;
|
||||
if (hp <= EMERGENCY_HP && hostile && (hostile.distance ?? Infinity) <= EMERGENCY_HOSTILE_DIST) {
|
||||
return `emergency_hp${Math.round(hp)}_${hostile.name ?? "hostile"}@${Math.round(hostile.distance)}`;
|
||||
}
|
||||
// 0b. EMERGENCY: drowning / lava / lethal fluid
|
||||
if (snap.hazards?.footBlock === "lava") {
|
||||
return "emergency_lava";
|
||||
}
|
||||
|
||||
// 1. Wedged > threshold
|
||||
if (ctx.lastSignificantMoveAt && (now - ctx.lastSignificantMoveAt) > WEDGED_THRESHOLD_MS) {
|
||||
return `wedged_${Math.round((now - ctx.lastSignificantMoveAt) / 1000)}s`;
|
||||
}
|
||||
// 2. Repeat-skill loop
|
||||
const recent = ctx.recentSkillIds ?? [];
|
||||
if (recent.length >= REPEAT_THRESHOLD) {
|
||||
const tail = recent.slice(-REPEAT_THRESHOLD);
|
||||
const allSame = tail.every((id) => id === tail[0]);
|
||||
if (allSame && plannedSkillId === tail[0]) {
|
||||
return `repeat_${REPEAT_THRESHOLD}_${tail[0]}`;
|
||||
}
|
||||
}
|
||||
// 3. Recent preempt followed by same skill again
|
||||
if (ctx.lastPreempt && now - ctx.lastPreempt.at < PREEMPT_WINDOW_MS) {
|
||||
const lastDispatched = recent[recent.length - 1];
|
||||
if (lastDispatched && lastDispatched === plannedSkillId) {
|
||||
return `preempt_retry_${ctx.lastPreempt.reason}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* consumeFreshRecommendation(ctx) → { skillId, action, rationale } | null
|
||||
*
|
||||
* Returns a recommendation if one is currently cached and fresh, and
|
||||
* clears it so subsequent ticks don't re-apply the same advice.
|
||||
*/
|
||||
export function consumeFreshRecommendation(ctx) {
|
||||
const rec = ctx.advisorRecommendation;
|
||||
if (!rec) return null;
|
||||
if (Date.now() - rec.at > RECOMMENDATION_TTL_MS) {
|
||||
ctx.advisorRecommendation = null;
|
||||
return null;
|
||||
}
|
||||
if (rec.action !== "switch_skill" || !rec.skillId) {
|
||||
// 'continue' / 'wait' don't replace dispatch; surface for telemetry only
|
||||
return null;
|
||||
}
|
||||
ctx.advisorRecommendation = null;
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = {
|
||||
TRIGGER_COOLDOWN_MS, RECOMMENDATION_TTL_MS, WEDGED_THRESHOLD_MS,
|
||||
REPEAT_THRESHOLD, PREEMPT_WINDOW_MS, detectTrigger,
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
tickAdvisor,
|
||||
consumeFreshRecommendation,
|
||||
getTriggerState,
|
||||
_resetForTest,
|
||||
__testing,
|
||||
} from "./advisor-trigger.js";
|
||||
import { _resetForTest as resetAdvisor } from "./fast-advisor.js";
|
||||
|
||||
const { detectTrigger, WEDGED_THRESHOLD_MS, REPEAT_THRESHOLD, PREEMPT_WINDOW_MS, RECOMMENDATION_TTL_MS } = __testing;
|
||||
|
||||
const API_KEY = "TIMEWEB_API_KEY";
|
||||
const MODEL = "TIMEWEB_MODEL";
|
||||
|
||||
function withEnv(env, fn) {
|
||||
const prev = {};
|
||||
for (const k of Object.keys(env)) {
|
||||
prev[k] = process.env[k];
|
||||
if (env[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = env[k];
|
||||
}
|
||||
return Promise.resolve(fn()).finally(() => {
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(reply, latency = 0) {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
if (latency) await new Promise((r) => setTimeout(r, latency));
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: typeof reply === "string" ? reply : JSON.stringify(reply) } }],
|
||||
usage: { prompt_tokens: 1500, completion_tokens: 40, total_tokens: 1540 },
|
||||
}),
|
||||
};
|
||||
};
|
||||
return () => { globalThis.fetch = orig; };
|
||||
}
|
||||
|
||||
test("detectTrigger: returns null when nothing matches", () => {
|
||||
const r = detectTrigger({ recentSkillIds: [] }, Date.now(), "gather.logs");
|
||||
assert.equal(r, null);
|
||||
});
|
||||
|
||||
test("detectTrigger: low HP + hostile near → emergency_hp", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger({
|
||||
recentSkillIds: [],
|
||||
snapshot: { health: 4, closestHostile: { name: "creeper", distance: 3 } },
|
||||
}, now, "gather.logs");
|
||||
assert.match(r, /^emergency_hp4_creeper@3/);
|
||||
});
|
||||
|
||||
test("detectTrigger: foot in lava → emergency_lava", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger({
|
||||
recentSkillIds: [],
|
||||
snapshot: { health: 18, hazards: { footBlock: "lava" } },
|
||||
}, now, "explore.far");
|
||||
assert.equal(r, "emergency_lava");
|
||||
});
|
||||
|
||||
test("detectTrigger: emergency wins over wedged when both present", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger({
|
||||
recentSkillIds: [],
|
||||
snapshot: { health: 4, closestHostile: { name: "skeleton", distance: 5 } },
|
||||
lastSignificantMoveAt: now - 120_000,
|
||||
}, now, "x");
|
||||
assert.match(r, /^emergency_/);
|
||||
});
|
||||
|
||||
test("detectTrigger: wedged > 60s fires", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger(
|
||||
{ recentSkillIds: ["x"], lastSignificantMoveAt: now - WEDGED_THRESHOLD_MS - 5000 },
|
||||
now,
|
||||
"explore.far",
|
||||
);
|
||||
assert.match(r, /^wedged_\d+s/);
|
||||
});
|
||||
|
||||
test("detectTrigger: 4 same dispatches in row + same planned → repeat", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger(
|
||||
{ recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"] },
|
||||
now,
|
||||
"explore.far",
|
||||
);
|
||||
assert.match(r, /^repeat_4_explore\.far/);
|
||||
});
|
||||
|
||||
test("detectTrigger: same skill repeated but planned is different → no repeat trigger", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger(
|
||||
{ recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"] },
|
||||
now,
|
||||
"gather.logs",
|
||||
);
|
||||
assert.equal(r, null);
|
||||
});
|
||||
|
||||
test("detectTrigger: recent preempt + same skill re-planned → preempt_retry", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger(
|
||||
{
|
||||
recentSkillIds: ["gather.logs"],
|
||||
lastPreempt: { at: now - 5000, reason: "forced_move" },
|
||||
},
|
||||
now,
|
||||
"gather.logs",
|
||||
);
|
||||
assert.equal(r, "preempt_retry_forced_move");
|
||||
});
|
||||
|
||||
test("detectTrigger: old preempt (> window) does not trigger", () => {
|
||||
const now = Date.now();
|
||||
const r = detectTrigger(
|
||||
{
|
||||
recentSkillIds: ["gather.logs"],
|
||||
lastPreempt: { at: now - PREEMPT_WINDOW_MS - 5000, reason: "forced_move" },
|
||||
},
|
||||
now,
|
||||
"gather.logs",
|
||||
);
|
||||
assert.equal(r, null);
|
||||
});
|
||||
|
||||
test("tickAdvisor: disabled when TIMEWEB_API_KEY missing", async () => {
|
||||
await withEnv({ [API_KEY]: undefined }, () => {
|
||||
_resetForTest();
|
||||
const r = tickAdvisor({ recentSkillIds: [] }, { plannedSkillId: "x" });
|
||||
assert.equal(r.fired, false);
|
||||
assert.equal(r.reason, "disabled");
|
||||
});
|
||||
});
|
||||
|
||||
test("tickAdvisor: no_trigger when ctx has nothing interesting", async () => {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, () => {
|
||||
_resetForTest();
|
||||
resetAdvisor();
|
||||
const r = tickAdvisor({ recentSkillIds: [] }, { plannedSkillId: "gather.logs" });
|
||||
assert.equal(r.fired, false);
|
||||
assert.equal(r.reason, "no_trigger");
|
||||
});
|
||||
});
|
||||
|
||||
test("tickAdvisor: fires on wedged trigger and caches recommendation", async () => {
|
||||
const restore = stubFetch({
|
||||
action: "switch_skill",
|
||||
skill_id: "survive.flee",
|
||||
rationale: "Wedged here, retreat instead.",
|
||||
});
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
resetAdvisor();
|
||||
const ctx = {
|
||||
recentSkillIds: ["explore.far"],
|
||||
lastSignificantMoveAt: Date.now() - 120_000,
|
||||
};
|
||||
const r = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
|
||||
assert.equal(r.fired, true);
|
||||
assert.match(r.reason, /^wedged_/);
|
||||
assert.equal(getTriggerState().inFlight, true);
|
||||
|
||||
// Wait for the in-flight promise to settle.
|
||||
await new Promise((res) => setTimeout(res, 20));
|
||||
assert.equal(getTriggerState().inFlight, false);
|
||||
assert.ok(ctx.advisorRecommendation, "recommendation cached");
|
||||
assert.equal(ctx.advisorRecommendation.skillId, "survive.flee");
|
||||
assert.equal(ctx.advisorRecommendation.usage.total, 1540);
|
||||
});
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("tickAdvisor: cooldown blocks second trigger right after", async () => {
|
||||
const restore = stubFetch({ action: "continue", rationale: "ok" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
resetAdvisor();
|
||||
const ctx = {
|
||||
recentSkillIds: ["explore.far"],
|
||||
lastSignificantMoveAt: Date.now() - 120_000,
|
||||
};
|
||||
const r1 = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
|
||||
assert.equal(r1.fired, true);
|
||||
await new Promise((res) => setTimeout(res, 20));
|
||||
const r2 = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
|
||||
assert.equal(r2.fired, false);
|
||||
assert.equal(r2.reason, "cooldown");
|
||||
});
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test("consumeFreshRecommendation: returns + clears switch_skill recommendation", () => {
|
||||
_resetForTest();
|
||||
const ctx = {
|
||||
advisorRecommendation: {
|
||||
at: Date.now(),
|
||||
action: "switch_skill",
|
||||
skillId: "survive.flee",
|
||||
rationale: "x",
|
||||
},
|
||||
};
|
||||
const r = consumeFreshRecommendation(ctx);
|
||||
assert.ok(r);
|
||||
assert.equal(r.skillId, "survive.flee");
|
||||
assert.equal(ctx.advisorRecommendation, null);
|
||||
});
|
||||
|
||||
test("consumeFreshRecommendation: stale (> TTL) recommendation dropped", () => {
|
||||
_resetForTest();
|
||||
const ctx = {
|
||||
advisorRecommendation: {
|
||||
at: Date.now() - RECOMMENDATION_TTL_MS - 1000,
|
||||
action: "switch_skill",
|
||||
skillId: "survive.flee",
|
||||
},
|
||||
};
|
||||
const r = consumeFreshRecommendation(ctx);
|
||||
assert.equal(r, null);
|
||||
assert.equal(ctx.advisorRecommendation, null);
|
||||
});
|
||||
|
||||
test("consumeFreshRecommendation: continue/wait recommendations are not consumed for skill swap", () => {
|
||||
_resetForTest();
|
||||
const ctx = {
|
||||
advisorRecommendation: { at: Date.now(), action: "continue", rationale: "ok" },
|
||||
};
|
||||
const r = consumeFreshRecommendation(ctx);
|
||||
assert.equal(r, null);
|
||||
// stays cached for telemetry
|
||||
assert.ok(ctx.advisorRecommendation);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// Fast tactical advisor — second LLM tier, parallel to Pi.
|
||||
//
|
||||
// Pi (the CLI coach) is great for deep post-mortems and 30-min reflection,
|
||||
// but it's slow (5-15s) and rate-limited. When the reflex detects the bot
|
||||
// is wedged, stuck, or just took an environment shock (forced teleport,
|
||||
// HP plunge, hostile spawn), we want a sub-2-second "what do I do?"
|
||||
// answer from a cheap, hosted model. That's this module.
|
||||
//
|
||||
// In rc.1 this is a scaffold: complete() + advise() + rate-limiting +
|
||||
// integration tests, but no auto-trigger from the reflex yet. rc.3 wires
|
||||
// the trigger paths (awareness layer) into here.
|
||||
//
|
||||
// The advisor MUST return a JSON shape whose `prefer_skill` field is a
|
||||
// real, registered skill id — anything else is rejected. The system
|
||||
// prompt embeds the live registry so the model has the source of truth.
|
||||
|
||||
import { complete, isAvailable as llmAvailable } from "../llm/provider.js";
|
||||
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const HOURLY_BUDGET = 6;
|
||||
const COOLDOWN_MS = 30_000;
|
||||
|
||||
let _callTimes = [];
|
||||
let _lastCallAt = 0;
|
||||
let _tokensIn = 0;
|
||||
let _tokensOut = 0;
|
||||
let _calls = 0;
|
||||
|
||||
export function isAvailable() {
|
||||
return llmAvailable();
|
||||
}
|
||||
|
||||
export function getUsageSnapshot() {
|
||||
const now = Date.now();
|
||||
const hourAgo = now - 3600_000;
|
||||
const recentCalls = _callTimes.filter((t) => t > hourAgo).length;
|
||||
return {
|
||||
callsLastHour: recentCalls,
|
||||
callsTotal: _calls,
|
||||
tokensInTotal: _tokensIn,
|
||||
tokensOutTotal: _tokensOut,
|
||||
hourlyBudget: HOURLY_BUDGET,
|
||||
};
|
||||
}
|
||||
|
||||
export function _resetForTest() {
|
||||
_callTimes = [];
|
||||
_lastCallAt = 0;
|
||||
_tokensIn = 0;
|
||||
_tokensOut = 0;
|
||||
_calls = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* advise({ snapshot, reason, recentSkillIds, lessonsTail }) →
|
||||
* { ok: true, action: 'switch_skill'|'continue'|'wait', skillId?, rationale, raw, latencyMs }
|
||||
* | { ok: false, code, detail, latencyMs }
|
||||
*
|
||||
* `reason` is a free-text trigger ("wedged_60s", "forced_move",
|
||||
* "hp_plunge", "stuck_3_dispatches"). It goes verbatim into the prompt
|
||||
* so the model can tailor its advice.
|
||||
*/
|
||||
export async function advise({
|
||||
snapshot,
|
||||
reason = "unknown",
|
||||
recentSkillIds = [],
|
||||
lessonsTail = [],
|
||||
activeNeed = null,
|
||||
force = false,
|
||||
} = {}) {
|
||||
if (!isAvailable()) {
|
||||
return { ok: false, code: "not_configured", detail: "set TIMEWEB_API_KEY", latencyMs: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
_callTimes = _callTimes.filter((t) => t > now - 3600_000);
|
||||
if (!force && _callTimes.length >= HOURLY_BUDGET) {
|
||||
return { ok: false, code: "budget_exhausted", detail: `${_callTimes.length}/${HOURLY_BUDGET} per hour`, latencyMs: 0 };
|
||||
}
|
||||
if (!force && now - _lastCallAt < COOLDOWN_MS) {
|
||||
return { ok: false, code: "cooldown", detail: `${Math.round((COOLDOWN_MS - (now - _lastCallAt)) / 1000)}s`, latencyMs: 0 };
|
||||
}
|
||||
|
||||
const system = buildSystemPrompt();
|
||||
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed });
|
||||
|
||||
_callTimes.push(now);
|
||||
_lastCallAt = now;
|
||||
|
||||
const res = await complete({ system, user, json: true });
|
||||
_calls += 1;
|
||||
if (res.usage) {
|
||||
_tokensIn += res.usage.in;
|
||||
_tokensOut += res.usage.out;
|
||||
}
|
||||
if (!res.ok) {
|
||||
warn("advisor", `complete failed: ${res.code} (${res.detail})`);
|
||||
return { ok: false, code: res.code, detail: res.detail, latencyMs: res.latencyMs };
|
||||
}
|
||||
|
||||
const parsed = res.text;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, code: "bad_shape", detail: "no object in reply", latencyMs: res.latencyMs };
|
||||
}
|
||||
|
||||
const action = String(parsed.action ?? "").toLowerCase();
|
||||
const skillId = parsed.skill_id ?? parsed.prefer_skill ?? null;
|
||||
const rationale = parsed.rationale ?? parsed.reason ?? "";
|
||||
|
||||
if (action === "switch_skill") {
|
||||
if (!skillId || !isRegistered(skillId)) {
|
||||
warn("advisor", `rejected hallucinated skill "${skillId}"`);
|
||||
return {
|
||||
ok: false,
|
||||
code: "hallucinated_skill",
|
||||
detail: skillId ?? "(null)",
|
||||
rationale,
|
||||
raw: parsed,
|
||||
latencyMs: res.latencyMs,
|
||||
usage: res.usage,
|
||||
};
|
||||
}
|
||||
info("advisor", `switch_skill → ${skillId} (${rationale.slice(0, 80)})`);
|
||||
return {
|
||||
ok: true,
|
||||
action: "switch_skill",
|
||||
skillId,
|
||||
rationale,
|
||||
raw: parsed,
|
||||
latencyMs: res.latencyMs,
|
||||
usage: res.usage,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "continue" || action === "wait") {
|
||||
info("advisor", `${action} (${rationale.slice(0, 80)})`);
|
||||
return { ok: true, action, rationale, raw: parsed, latencyMs: res.latencyMs, usage: res.usage };
|
||||
}
|
||||
|
||||
return { ok: false, code: "bad_action", detail: action || "missing", raw: parsed, latencyMs: res.latencyMs, usage: res.usage };
|
||||
}
|
||||
|
||||
function buildSystemPrompt() {
|
||||
return [
|
||||
"You are the tactical advisor for pepa, an autonomous Minecraft survival bot.",
|
||||
"You are called when the bot's reflex layer detects something wrong (wedged, stuck,",
|
||||
"forced move, HP plunge). Your job: produce a single fast decision.",
|
||||
"",
|
||||
"Reply STRICTLY with a JSON object:",
|
||||
'{',
|
||||
' "action": "switch_skill" | "continue" | "wait",',
|
||||
' "skill_id": "<registered skill id or null>",',
|
||||
' "rationale": "<≤25 words explaining why>"',
|
||||
'}',
|
||||
"",
|
||||
"Rules:",
|
||||
'- "switch_skill" REQUIRES skill_id to be one of the registered ids below.',
|
||||
'- "continue" means current skill is fine, just give it more time.',
|
||||
'- "wait" means stop dispatching for ~10s (e.g. waiting for night to pass).',
|
||||
'- If unsure, return "continue".',
|
||||
"",
|
||||
skillRegistryPrompt({ limit: 1800 }),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }) {
|
||||
const pos = snapshot?.position;
|
||||
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
|
||||
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
|
||||
const lessons = (lessonsTail ?? []).slice(0, 4).map((l) => ` - ${l.text ?? l}`).join("\n");
|
||||
const needLine = activeNeed
|
||||
? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}`
|
||||
: "(no active need)";
|
||||
const hostile = snapshot?.closestHostile
|
||||
? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b`
|
||||
: "(none)";
|
||||
|
||||
return [
|
||||
`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"}`,
|
||||
`Active need (Maslow ladder): ${needLine}`,
|
||||
`Closest hostile: ${hostile}`,
|
||||
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
|
||||
`Recent dispatches: ${recent}`,
|
||||
`Inventory keys: ${inv}`,
|
||||
`Nearby threats: ${formatThreats(snapshot?.threats)}`,
|
||||
`No-progress reason: ${snapshot?.noProgressReason ?? "(none)"}`,
|
||||
"",
|
||||
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.",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function formatThreats(threats) {
|
||||
if (!Array.isArray(threats) || threats.length === 0) return "(none)";
|
||||
return threats.slice(0, 3).map((t) => `${t.name ?? "?"}@${Math.round(t.distance ?? 0)}m`).join(", ");
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = { buildSystemPrompt, buildUserPrompt, formatThreats, HOURLY_BUDGET, COOLDOWN_MS };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { advise, isAvailable, _resetForTest, __testing } from "./fast-advisor.js";
|
||||
|
||||
const API_KEY = "TIMEWEB_API_KEY";
|
||||
const MODEL = "TIMEWEB_MODEL";
|
||||
const BASE = "TIMEWEB_BASE_URL";
|
||||
|
||||
function withEnv(env, fn) {
|
||||
const prev = {};
|
||||
for (const k of Object.keys(env)) {
|
||||
prev[k] = process.env[k];
|
||||
if (env[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = env[k];
|
||||
}
|
||||
return Promise.resolve(fn()).finally(() => {
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stubFetch(reply) {
|
||||
const calls = [];
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: typeof reply === "string" ? reply : JSON.stringify(reply) } }],
|
||||
}),
|
||||
};
|
||||
};
|
||||
return { calls, restore() { globalThis.fetch = orig; } };
|
||||
}
|
||||
|
||||
test("advise: not_configured without API key", async () => {
|
||||
await withEnv({ [API_KEY]: undefined }, async () => {
|
||||
_resetForTest();
|
||||
const res = await advise({ reason: "stuck" });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "not_configured");
|
||||
assert.match(res.detail, /TIMEWEB_API_KEY/);
|
||||
});
|
||||
});
|
||||
|
||||
test("advise: accepts a registered skill", async () => {
|
||||
const f = stubFetch({ action: "switch_skill", skill_id: "survive.flee", rationale: "creeper close" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m", [BASE]: "https://x/v1" }, async () => {
|
||||
_resetForTest();
|
||||
const res = await advise({
|
||||
snapshot: { health: 10, food: 18, isDay: true, position: { x: 1, y: 64, z: 1 } },
|
||||
reason: "wedged_60s",
|
||||
recentSkillIds: ["explore.far", "explore.far", "explore.far"],
|
||||
force: true,
|
||||
});
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.action, "switch_skill");
|
||||
assert.equal(res.skillId, "survive.flee");
|
||||
assert.match(res.rationale, /creeper/);
|
||||
// system prompt should mention the live registry
|
||||
const sent = JSON.parse(f.calls[0].opts.body);
|
||||
assert.match(sent.messages[0].content, /Valid skill ids/);
|
||||
assert.match(sent.messages[0].content, /survive\.flee/);
|
||||
assert.match(sent.messages[1].content, /wedged_60s/);
|
||||
});
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test("advise: rejects hallucinated skill id with code=hallucinated_skill", async () => {
|
||||
const f = stubFetch({ action: "switch_skill", skill_id: "relocate.surface", rationale: "fresh spot" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
const res = await advise({ reason: "loop", force: true });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "hallucinated_skill");
|
||||
assert.equal(res.detail, "relocate.surface");
|
||||
});
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test("advise: accepts 'continue' and 'wait' without skill_id", async () => {
|
||||
const f = stubFetch({ action: "continue", rationale: "skill is making slow progress" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
const res = await advise({ reason: "tick", force: true });
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.action, "continue");
|
||||
});
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test("advise: rate-limit cooldown blocks rapid calls", async () => {
|
||||
const f = stubFetch({ action: "continue", rationale: "ok" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
const r1 = await advise({ reason: "x" });
|
||||
assert.equal(r1.ok, true);
|
||||
const r2 = await advise({ reason: "y" });
|
||||
assert.equal(r2.ok, false);
|
||||
assert.equal(r2.code, "cooldown");
|
||||
});
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test("advise: hourly budget enforced with force=true override", async () => {
|
||||
const f = stubFetch({ action: "continue", rationale: "ok" });
|
||||
try {
|
||||
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
|
||||
_resetForTest();
|
||||
for (let i = 0; i < __testing.HOURLY_BUDGET; i++) {
|
||||
await advise({ reason: `t${i}`, force: true });
|
||||
}
|
||||
const over = await advise({ reason: "over" });
|
||||
assert.equal(over.ok, false);
|
||||
assert.equal(over.code, "budget_exhausted");
|
||||
});
|
||||
} finally { f.restore(); }
|
||||
});
|
||||
|
||||
test("buildSystemPrompt: contains registry block and JSON schema", () => {
|
||||
const sys = __testing.buildSystemPrompt();
|
||||
assert.match(sys, /switch_skill/);
|
||||
assert.match(sys, /Valid skill ids/);
|
||||
assert.match(sys, /survive\.flee/);
|
||||
});
|
||||
|
||||
test("buildUserPrompt: includes trigger and recent skills", () => {
|
||||
const u = __testing.buildUserPrompt({
|
||||
snapshot: { health: 4, food: 3, isDay: false, position: { x: 10, y: 65, z: 10 }, activeSkill: "explore.far" },
|
||||
reason: "hp_plunge",
|
||||
recentSkillIds: ["explore.far", "explore.far"],
|
||||
lessonsTail: [{ text: "do not fight at night" }],
|
||||
});
|
||||
assert.match(u, /hp_plunge/);
|
||||
assert.match(u, /HP: 4/);
|
||||
assert.match(u, /Recent dispatches: explore\.far → explore\.far/);
|
||||
assert.match(u, /do not fight at night/);
|
||||
});
|
||||
|
||||
test("formatThreats: empty / formatted", () => {
|
||||
assert.equal(__testing.formatThreats(undefined), "(none)");
|
||||
assert.equal(__testing.formatThreats([]), "(none)");
|
||||
assert.equal(
|
||||
__testing.formatThreats([{ name: "zombie", distance: 4.3 }, { name: "creeper", distance: 7 }]),
|
||||
"zombie@4m, creeper@7m",
|
||||
);
|
||||
});
|
||||
|
||||
test("isAvailable mirrors provider availability", async () => {
|
||||
await withEnv({ [API_KEY]: undefined }, async () => {
|
||||
assert.equal(isAvailable(), false);
|
||||
});
|
||||
await withEnv({ [API_KEY]: "k" }, async () => {
|
||||
assert.equal(isAvailable(), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Shared helper for the slow-analytical coach loops (postmortem.js,
|
||||
// reflect.js). Replaces the old askPi-based subprocess path with a
|
||||
// direct TimeWeb / OpenAI-compatible HTTP call.
|
||||
//
|
||||
// Why this split: postmortem and reflect each took 5-15s via Pi CLI
|
||||
// (with its own subprocess + auth + sometimes a fresh MC connection)
|
||||
// and were rate-limited by the subscription. Now they take 5-15s via
|
||||
// the same TimeWeb endpoint the fast-advisor uses — but they're
|
||||
// analytical, NOT tactical, so they ask for a different prompt shape
|
||||
// and a longer reply.
|
||||
//
|
||||
// The Pi CLI is no longer driven from background timers. It remains
|
||||
// available for manual operator commands.
|
||||
|
||||
import { complete } from "../llm/provider.js";
|
||||
import { warn } from "../log.js";
|
||||
|
||||
const ANALYTICAL_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* askAnalytical({ system, user, json }) → text|object|null
|
||||
*
|
||||
* Higher-timeout, lower-temperature companion to fast-advisor's
|
||||
* complete(). Returns just the parsed text/object on success or null
|
||||
* on failure (so callers can keep their old "no reply" branch).
|
||||
*
|
||||
* Token usage is logged via the underlying provider — no extra
|
||||
* accounting here.
|
||||
*/
|
||||
export async function askAnalytical({ system, user, json = true, timeoutMs } = {}) {
|
||||
const res = await complete({
|
||||
system,
|
||||
user,
|
||||
json,
|
||||
timeoutMs: timeoutMs ?? ANALYTICAL_TIMEOUT_MS,
|
||||
});
|
||||
if (!res.ok) {
|
||||
warn("coach-llm", `analytical call failed: ${res.code} (${res.detail})`);
|
||||
return null;
|
||||
}
|
||||
return res.text;
|
||||
}
|
||||
|
||||
export { ANALYTICAL_TIMEOUT_MS };
|
||||
+94
-59
@@ -25,18 +25,22 @@ import {
|
||||
record as recordLesson,
|
||||
poiNearby,
|
||||
recordPOI,
|
||||
createImprovementRequest,
|
||||
} from "../knowledge/index.js";
|
||||
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
|
||||
import { isAvailable as llmAvailable } from "../llm/provider.js";
|
||||
import { askAnalytical } from "./llm-call.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const COACH_INTERVAL_MS = 5 * 60 * 1000; // 5 min between coach passes
|
||||
const COACH_BATCH_MAX = 8; // up to 8 deaths per Pi call
|
||||
const COACH_PI_BUDGET_PER_HOUR = 3; // ≤ 3 Pi calls/hour
|
||||
const COACH_BATCH_MAX = 8; // up to 8 deaths per LLM call
|
||||
const COACH_BUDGET_PER_HOUR = 3; // ≤ 3 analytical LLM calls/hour
|
||||
const COACH_COOLDOWN_MS = 12 * 60 * 1000; // 12 min between calls
|
||||
const RECENT_CHAT_TAIL = 6;
|
||||
const SCENARIO_TAIL = 12;
|
||||
|
||||
let _attached = null;
|
||||
let _piCallTimes = [];
|
||||
let _llmCallTimes = [];
|
||||
let _coachTimer = null;
|
||||
let _lastInventory = null;
|
||||
|
||||
@@ -75,17 +79,18 @@ export function attach(bot, ctx = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Start the periodic Pi-coach drain loop.
|
||||
if (ctx.askPi && !_coachTimer) {
|
||||
// v0.3.0 — postmortem analysis runs through TimeWeb (the fast LLM
|
||||
// provider). Pi CLI no longer drives this loop. The drain timer
|
||||
// fires regardless of whether TimeWeb is configured; drainOnce()
|
||||
// short-circuits when the LLM is unavailable.
|
||||
if (!_coachTimer) {
|
||||
_coachTimer = setInterval(() => {
|
||||
drainOnce({ askPi: ctx.askPi, stateDir: ctx.stateDir }).catch((e) =>
|
||||
drainOnce({ stateDir: ctx.stateDir }).catch((e) =>
|
||||
warn("coach", `drain error: ${e?.message ?? e}`),
|
||||
);
|
||||
}, COACH_INTERVAL_MS);
|
||||
_coachTimer.unref?.();
|
||||
info("coach", `attached; drain every ${COACH_INTERVAL_MS / 1000}s`);
|
||||
} else {
|
||||
info("coach", "attached; Pi not provided, deaths captured without postmortem analysis");
|
||||
info("coach", `attached; drain every ${COACH_INTERVAL_MS / 1000}s${llmAvailable() ? " (TimeWeb)" : " (LLM disabled — deaths captured only)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,54 +253,65 @@ function readJournalNearby(stateDir, pos, radius) {
|
||||
* Rate-limited: at most COACH_PI_BUDGET_PER_HOUR calls/hour, with
|
||||
* COACH_COOLDOWN_MS gap between calls.
|
||||
*/
|
||||
export async function drainOnce({ askPi, stateDir, force = false } = {}) {
|
||||
export async function drainOnce({ stateDir, force = false, askAnalyticalFn = askAnalytical } = {}) {
|
||||
if (!knowledgeAvailable()) return { ok: false, reason: "knowledge unavailable" };
|
||||
if (!askPi) return { ok: false, reason: "no askPi" };
|
||||
if (!llmAvailable()) return { ok: false, reason: "llm not configured" };
|
||||
|
||||
const now = Date.now();
|
||||
const hourAgo = now - 60 * 60 * 1000;
|
||||
_piCallTimes = _piCallTimes.filter((t) => t > hourAgo);
|
||||
if (!force && _piCallTimes.length >= COACH_PI_BUDGET_PER_HOUR) {
|
||||
return { ok: false, reason: "hourly budget exhausted", calls: _piCallTimes.length };
|
||||
_llmCallTimes = _llmCallTimes.filter((t) => t > hourAgo);
|
||||
if (!force && _llmCallTimes.length >= COACH_BUDGET_PER_HOUR) {
|
||||
return { ok: false, reason: "hourly budget exhausted", calls: _llmCallTimes.length };
|
||||
}
|
||||
if (!force && _piCallTimes.length > 0 && now - _piCallTimes[_piCallTimes.length - 1] < COACH_COOLDOWN_MS) {
|
||||
if (!force && _llmCallTimes.length > 0 && now - _llmCallTimes[_llmCallTimes.length - 1] < COACH_COOLDOWN_MS) {
|
||||
return { ok: false, reason: "cooldown" };
|
||||
}
|
||||
|
||||
const pending = unanalysedDeaths({ limit: COACH_BATCH_MAX });
|
||||
if (pending.length === 0) return { ok: true, analysed: 0 };
|
||||
|
||||
const prompt = buildPrompt(pending);
|
||||
_piCallTimes.push(now);
|
||||
const { system, user } = buildPrompt(pending);
|
||||
_llmCallTimes.push(now);
|
||||
|
||||
const reply = await askPiOnce({ askPi, prompt });
|
||||
if (!reply) return { ok: false, reason: "no reply" };
|
||||
|
||||
const parsed = extractJson(reply);
|
||||
if (!parsed) {
|
||||
warn("coach", "Pi reply was not parseable JSON");
|
||||
return { ok: false, reason: "bad reply" };
|
||||
}
|
||||
const parsed = await askAnalyticalFn({ system, user, json: true });
|
||||
if (!parsed) return { ok: false, reason: "no reply" };
|
||||
const reply = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
|
||||
|
||||
let lessonsCount = 0;
|
||||
let rejectedPreferCount = 0;
|
||||
for (const item of asArray(parsed.lessons ?? parsed)) {
|
||||
if (!item || !item.lesson) continue;
|
||||
// Skill ids referenced by Pi must be in the live registry.
|
||||
// Mode names (e.g. "night_shelter") are tolerated at write time and
|
||||
// translated at consult time by advice.js#normalisePreferSkill.
|
||||
let preferSkill = item.prefer_skill ?? null;
|
||||
if (preferSkill && !isRegistered(preferSkill) && !isLikelyModeName(preferSkill)) {
|
||||
rejectedPreferCount += 1;
|
||||
preferSkill = null;
|
||||
}
|
||||
let avoidSkill = item.avoid_skill ?? null;
|
||||
if (avoidSkill && !isRegistered(avoidSkill) && !isLikelyModeName(avoidSkill)) {
|
||||
avoidSkill = null;
|
||||
}
|
||||
recordLesson({
|
||||
text: item.lesson,
|
||||
category: item.category ?? "survival",
|
||||
triggerSkill: item.trigger_skill ?? null,
|
||||
triggerHostile: item.trigger_hostile ?? null,
|
||||
triggerSituation: item.trigger_situation ?? null,
|
||||
avoidSkill: item.avoid_skill ?? null,
|
||||
preferSkill: item.prefer_skill ?? null,
|
||||
avoidSkill,
|
||||
preferSkill,
|
||||
confidence: clamp(Number(item.confidence) || 0.6, 0.1, 0.95),
|
||||
source: "pi-coach",
|
||||
sourceRef: item.source_ref ?? null,
|
||||
});
|
||||
lessonsCount += 1;
|
||||
}
|
||||
if (rejectedPreferCount > 0) {
|
||||
warn("coach", `dropped prefer_skill from ${rejectedPreferCount} lessons (not in registry)`);
|
||||
}
|
||||
|
||||
// Write one postmortem per death; if Pi grouped them, share the same lesson.
|
||||
// Write one postmortem per death; if grouped, share the same lesson.
|
||||
const groupLesson = parsed.lessons?.[0]?.lesson ?? parsed.lesson ?? null;
|
||||
for (const d of pending) {
|
||||
insertPostmortem({
|
||||
@@ -304,13 +320,42 @@ export async function drainOnce({ askPi, stateDir, force = false } = {}) {
|
||||
lesson: groupLesson,
|
||||
nextAction: parsed.next_action ?? null,
|
||||
rawResponse: reply.slice(0, 4000),
|
||||
source: "pi",
|
||||
source: "timeweb",
|
||||
});
|
||||
markDeathAnalysed(d.id);
|
||||
}
|
||||
|
||||
info("coach", `drain: analysed ${pending.length} deaths → ${lessonsCount} lessons`);
|
||||
return { ok: true, analysed: pending.length, lessons: lessonsCount };
|
||||
// v0.3.0 — record any improvement requests the LLM flagged. The
|
||||
// LLM is encouraged to do this when the deaths point to a missing
|
||||
// skill or feature; the operator reads scripts/list-improvements.js
|
||||
// and decides what to implement.
|
||||
let improvementsCount = 0;
|
||||
for (const imp of asArray(parsed.improvements ?? [])) {
|
||||
if (!imp?.title) continue;
|
||||
createImprovementRequest({
|
||||
source: "postmortem",
|
||||
category: imp.category ?? "skill",
|
||||
title: String(imp.title).slice(0, 120),
|
||||
description: imp.description ?? null,
|
||||
context: { death_ids: pending.map((d) => d.id), cause: parsed.cause },
|
||||
priority: imp.priority ?? 3,
|
||||
});
|
||||
improvementsCount += 1;
|
||||
}
|
||||
|
||||
info("coach", `drain: analysed ${pending.length} deaths → ${lessonsCount} lessons, ${improvementsCount} improvement requests`);
|
||||
return { ok: true, analysed: pending.length, lessons: lessonsCount, improvements: improvementsCount };
|
||||
}
|
||||
|
||||
// Mode names from runtime/modes.js (advice.js#MODE_TO_SKILL) — we accept
|
||||
// these at write time because advice.js maps them to real skills at consult.
|
||||
const KNOWN_MODE_NAMES = new Set([
|
||||
"self_preservation", "night_shelter", "hunger", "shelter",
|
||||
"flee", "sleep", "eat", "tunnel_out", "tunnel-out", "explore", "wander",
|
||||
]);
|
||||
function isLikelyModeName(s) {
|
||||
if (!s || typeof s !== "string") return false;
|
||||
return KNOWN_MODE_NAMES.has(s.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function buildPrompt(deaths) {
|
||||
@@ -329,45 +374,35 @@ function buildPrompt(deaths) {
|
||||
].filter(Boolean).join("\n");
|
||||
}).join("\n\n");
|
||||
|
||||
return [
|
||||
const system = [
|
||||
"You are reviewing recent deaths of an autonomous Minecraft survival bot (pepa).",
|
||||
"The bot is trying to gather wood, craft tools, build a small village, and survive nights.",
|
||||
"It's currently dying repeatedly. Your job: extract 1-3 short, generalised lessons it can apply on respawn.",
|
||||
"Your job: extract 1-3 short, generalised lessons + flag any missing-skill gaps.",
|
||||
"",
|
||||
"DEATHS:",
|
||||
summary,
|
||||
skillRegistryPrompt({ limit: 1800 }),
|
||||
"",
|
||||
"Reply with ONE JSON object (no prose, no markdown fences):",
|
||||
"Reply with ONE JSON object (no markdown fences):",
|
||||
'{ "cause": "<short>", "next_action": "<one-sentence directive>",',
|
||||
' "lessons": [',
|
||||
' { "lesson": "...", "category": "combat|pathing|crafting|survival|social",',
|
||||
' "trigger_skill": "<skill id or null>",',
|
||||
' "trigger_hostile": "<mob name or null>",',
|
||||
' "avoid_skill": "<skill to NOT dispatch or null>",',
|
||||
' "prefer_skill": "<alternative skill or null>",',
|
||||
' "confidence": 0.7 }',
|
||||
' ] }',
|
||||
' "avoid_skill": "<registered skill id to NOT dispatch, or null>",',
|
||||
' "prefer_skill": "<registered skill id to use instead, or null>",',
|
||||
' "confidence": 0.7 } ],',
|
||||
' "improvements": [',
|
||||
' { "title": "<≤80 chars: what skill/feature is missing>",',
|
||||
' "description": "<why current registry doesn\'t cover this; concrete example>",',
|
||||
' "category": "skill|tuning|perception|planning|social|other",',
|
||||
' "priority": 1 } ] }',
|
||||
"",
|
||||
"Keep each lesson under 30 words. Be specific (e.g., \"attack creeper with fists\" rather than \"don't fight\").",
|
||||
"Keep each lesson under 30 words. Be specific.",
|
||||
"CRITICAL: avoid_skill and prefer_skill MUST be one of the registered ids above, or null.",
|
||||
"Use 'improvements' ONLY when a death is plausibly caused by the bot lacking a skill that doesn't exist in the registry (e.g. 'no skill to craft iron armor'). Skip it otherwise.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function askPiOnce({ askPi, prompt }) {
|
||||
return new Promise((resolve) => {
|
||||
let buf = "";
|
||||
try {
|
||||
askPi({
|
||||
prompt,
|
||||
onChunk: ({ stream, text }) => {
|
||||
if (stream === "stdout") buf += text;
|
||||
},
|
||||
onDone: () => resolve(buf),
|
||||
});
|
||||
} catch (e) {
|
||||
warn("coach", `askPi failed: ${e?.message ?? e}`);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
const user = `DEATHS:\n${summary}`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
function extractJson(text) {
|
||||
@@ -390,4 +425,4 @@ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function safeParse(s) { try { return JSON.parse(s); } catch { return null; } }
|
||||
|
||||
// Test-only exports
|
||||
export const __testing = { captureDeath, buildPrompt, extractJson, inferCause };
|
||||
export const __testing = { captureDeath, buildPrompt, extractJson, inferCause, isLikelyModeName, KNOWN_MODE_NAMES };
|
||||
|
||||
@@ -39,16 +39,18 @@ test("extractJson: tolerates fences and surrounding text", () => {
|
||||
assert.equal(extractJson(""), null);
|
||||
});
|
||||
|
||||
test("buildPrompt: includes all death rows and JSON schema hint", () => {
|
||||
test("buildPrompt: returns {system, user}, includes all death rows + improvements schema", () => {
|
||||
const rows = [
|
||||
{ id: 1, ts: Date.now(), x: 100, y: 64, z: 200, cause: "hostile", hostile: "creeper", last_skill: "gather.logs", last_skill_code: "timeout", food_at_death: 14, context_blob: JSON.stringify({ recentScenarios: [{ skillId: "gather.logs", code: "timeout" }] }) },
|
||||
{ id: 2, ts: Date.now(), x: 102, y: 64, z: 201, cause: "hostile", hostile: "creeper", last_skill: "explore.far", last_skill_code: "done", food_at_death: 12, context_blob: null },
|
||||
];
|
||||
const prompt = buildPrompt(rows);
|
||||
assert.match(prompt, /death id=1/);
|
||||
assert.match(prompt, /death id=2/);
|
||||
assert.match(prompt, /creeper/);
|
||||
assert.match(prompt, /Reply with ONE JSON object/);
|
||||
const { system, user } = buildPrompt(rows);
|
||||
assert.match(user, /death id=1/);
|
||||
assert.match(user, /death id=2/);
|
||||
assert.match(user, /creeper/);
|
||||
assert.match(system, /Reply with ONE JSON object/);
|
||||
assert.match(system, /improvements/);
|
||||
assert.match(system, /Valid skill ids/);
|
||||
});
|
||||
|
||||
test("captureDeath: builds a row with context blob and inferred cause", () => {
|
||||
@@ -88,7 +90,7 @@ test("attach + emit('death'): inserts row in knowledge DB", async () => {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("drainOnce: respects budget and parses Pi reply", async () => {
|
||||
test("drainOnce: respects budget and parses analytical LLM reply (incl. improvements)", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-"));
|
||||
__resetForTests();
|
||||
await initKnowledge({ stateDir });
|
||||
@@ -105,7 +107,13 @@ test("drainOnce: respects budget and parses Pi reply", async () => {
|
||||
|
||||
const lessonsBefore = recall({ category: "combat" }).length;
|
||||
|
||||
const fakeReply = JSON.stringify({
|
||||
// TimeWeb path needs env vars to satisfy the llmAvailable check.
|
||||
const prevKey = process.env.TIMEWEB_API_KEY;
|
||||
const prevModel = process.env.TIMEWEB_MODEL;
|
||||
process.env.TIMEWEB_API_KEY = "test-key";
|
||||
process.env.TIMEWEB_MODEL = "test-model";
|
||||
|
||||
const fakeReply = {
|
||||
cause: "creeper_explosion_unarmed",
|
||||
next_action: "shelter at dusk",
|
||||
lessons: [{
|
||||
@@ -116,16 +124,23 @@ test("drainOnce: respects budget and parses Pi reply", async () => {
|
||||
prefer_skill: "survive.flee",
|
||||
confidence: 0.85,
|
||||
}],
|
||||
});
|
||||
const askPi = ({ onChunk, onDone }) => {
|
||||
onChunk({ stream: "stdout", text: fakeReply });
|
||||
onDone({ code: 0 });
|
||||
improvements: [
|
||||
{ title: "Add craft.shield skill", description: "No skill to craft a shield when creepers are around.", category: "skill", priority: 2 },
|
||||
],
|
||||
};
|
||||
const askAnalyticalFn = async () => fakeReply;
|
||||
|
||||
const result = await drainOnce({ stateDir, force: true, askAnalyticalFn });
|
||||
|
||||
if (prevKey === undefined) delete process.env.TIMEWEB_API_KEY;
|
||||
else process.env.TIMEWEB_API_KEY = prevKey;
|
||||
if (prevModel === undefined) delete process.env.TIMEWEB_MODEL;
|
||||
else process.env.TIMEWEB_MODEL = prevModel;
|
||||
|
||||
const result = await drainOnce({ askPi, stateDir, force: true });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.analysed, 1);
|
||||
assert.equal(result.lessons, 1);
|
||||
assert.equal(result.improvements, 1);
|
||||
|
||||
const after = recall({ hostile: "creeper", category: "combat" });
|
||||
assert.ok(after.length > lessonsBefore, "new lesson recorded");
|
||||
@@ -137,18 +152,20 @@ test("drainOnce: respects budget and parses Pi reply", async () => {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("drainOnce: empty queue → ok with 0 analysed", async () => {
|
||||
test("drainOnce: skipped when LLM not configured", async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), "pepa-coach-test-"));
|
||||
__resetForTests();
|
||||
await initKnowledge({ stateDir });
|
||||
if (!isAvailable()) {
|
||||
assert.ok(true);
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
const result = await drainOnce({ askPi: () => {}, stateDir, force: true });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.analysed, 0);
|
||||
const prevKey = process.env.TIMEWEB_API_KEY;
|
||||
delete process.env.TIMEWEB_API_KEY;
|
||||
const result = await drainOnce({ stateDir, force: true });
|
||||
if (prevKey !== undefined) process.env.TIMEWEB_API_KEY = prevKey;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, "llm not configured");
|
||||
closeStore();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
+108
-66
@@ -15,34 +15,49 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { isAvailable as knowledgeAvailable, record as recordLesson } from "../knowledge/index.js";
|
||||
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 { isAvailable as llmAvailable } from "../llm/provider.js";
|
||||
import { askAnalytical } from "./llm-call.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
// Mode-name allow-list, mirrors postmortem.js (advice.js maps them to
|
||||
// real skills at consult-time). Anything else is hallucination → dropped.
|
||||
const KNOWN_MODE_NAMES = new Set([
|
||||
"self_preservation", "night_shelter", "hunger", "shelter",
|
||||
"flee", "sleep", "eat", "tunnel_out", "tunnel-out", "explore", "wander",
|
||||
]);
|
||||
function isLikelyModeName(s) {
|
||||
if (!s || typeof s !== "string") return false;
|
||||
return KNOWN_MODE_NAMES.has(s.toLowerCase().trim());
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const HOURLY_BUDGET = 2;
|
||||
const HISTORY_TAIL_LINES = 80;
|
||||
|
||||
let _attached = null;
|
||||
let _timer = null;
|
||||
let _piCallTimes = [];
|
||||
let _llmCallTimes = [];
|
||||
|
||||
export function attach({ bot, stateDir, askPi, getSnapshot, intervalMs = DEFAULT_INTERVAL_MS } = {}) {
|
||||
export function attach({ bot, stateDir, getSnapshot, intervalMs = DEFAULT_INTERVAL_MS } = {}) {
|
||||
if (_attached) {
|
||||
warn("reflect", "attach called twice; ignoring");
|
||||
return;
|
||||
}
|
||||
if (!stateDir || !askPi || !getSnapshot) {
|
||||
info("reflect", "attach: missing stateDir/askPi/getSnapshot — disabled");
|
||||
if (!stateDir || !getSnapshot) {
|
||||
info("reflect", "attach: missing stateDir/getSnapshot — disabled");
|
||||
return;
|
||||
}
|
||||
_attached = { bot, stateDir, askPi, getSnapshot };
|
||||
_attached = { bot, stateDir, getSnapshot };
|
||||
_timer = setInterval(() => {
|
||||
runOnce({ stateDir, askPi, getSnapshot }).catch((e) =>
|
||||
runOnce({ stateDir, getSnapshot }).catch((e) =>
|
||||
warn("reflect", `tick err: ${e?.message ?? e}`),
|
||||
);
|
||||
}, intervalMs);
|
||||
_timer.unref?.();
|
||||
info("reflect", `attached; self-assess every ${Math.round(intervalMs / 60000)} min`);
|
||||
info("reflect", `attached; self-assess every ${Math.round(intervalMs / 60000)} min${llmAvailable() ? " (TimeWeb)" : " (LLM disabled — will skip)"}`);
|
||||
}
|
||||
|
||||
export function detach() {
|
||||
@@ -51,12 +66,13 @@ export function detach() {
|
||||
_attached = null;
|
||||
}
|
||||
|
||||
export async function runOnce({ stateDir, askPi, getSnapshot, force = false } = {}) {
|
||||
export async function runOnce({ stateDir, getSnapshot, force = false, askAnalyticalFn = askAnalytical } = {}) {
|
||||
const now = Date.now();
|
||||
const hourAgo = now - 3600_000;
|
||||
_piCallTimes = _piCallTimes.filter((t) => t > hourAgo);
|
||||
if (!force && _piCallTimes.length >= HOURLY_BUDGET) {
|
||||
return { ok: false, reason: "budget exhausted", calls: _piCallTimes.length };
|
||||
_llmCallTimes = _llmCallTimes.filter((t) => t > hourAgo);
|
||||
if (!llmAvailable()) return { ok: false, reason: "llm not configured" };
|
||||
if (!force && _llmCallTimes.length >= HOURLY_BUDGET) {
|
||||
return { ok: false, reason: "budget exhausted", calls: _llmCallTimes.length };
|
||||
}
|
||||
|
||||
const snap = getSnapshot();
|
||||
@@ -64,36 +80,64 @@ export async function runOnce({ stateDir, askPi, getSnapshot, force = false } =
|
||||
const scenarios = readScenarioTail(stateDir);
|
||||
const diary = readDiaryTail(stateDir);
|
||||
const plan = readPlan(stateDir);
|
||||
const activeNeed = pickActiveNeed(snap);
|
||||
|
||||
const prompt = buildPrompt({ snap, journal, scenarios, diary, plan });
|
||||
_piCallTimes.push(now);
|
||||
const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed });
|
||||
_llmCallTimes.push(now);
|
||||
|
||||
const reply = await askPiOnce({ askPi, prompt });
|
||||
if (!reply) return { ok: false, reason: "no reply" };
|
||||
|
||||
const parsed = parseReply(reply);
|
||||
if (!parsed) {
|
||||
warn("reflect", "Pi reply not parseable as JSON");
|
||||
return { ok: false, reason: "bad reply", raw: reply.slice(0, 200) };
|
||||
}
|
||||
const parsed = await askAnalyticalFn({ system, user, json: true });
|
||||
if (!parsed || typeof parsed !== "object") return { ok: false, reason: "no reply" };
|
||||
const reply = JSON.stringify(parsed);
|
||||
|
||||
const path = writeReflection(stateDir, parsed, reply);
|
||||
let rejectedPrefer = 0;
|
||||
for (const l of asArray(parsed.lessons)) {
|
||||
if (!l?.lesson) continue;
|
||||
let preferSkill = l.prefer_skill ?? null;
|
||||
if (preferSkill && !isRegistered(preferSkill) && !isLikelyModeName(preferSkill)) {
|
||||
rejectedPrefer += 1;
|
||||
preferSkill = null;
|
||||
}
|
||||
let avoidSkill = l.avoid_skill ?? null;
|
||||
if (avoidSkill && !isRegistered(avoidSkill) && !isLikelyModeName(avoidSkill)) {
|
||||
avoidSkill = null;
|
||||
}
|
||||
recordLesson({
|
||||
text: l.lesson,
|
||||
category: l.category ?? "self-improve",
|
||||
triggerSkill: l.trigger_skill ?? null,
|
||||
triggerHostile: l.trigger_hostile ?? null,
|
||||
avoidSkill: l.avoid_skill ?? null,
|
||||
preferSkill: l.prefer_skill ?? null,
|
||||
avoidSkill,
|
||||
preferSkill,
|
||||
confidence: clamp(Number(l.confidence) || 0.5, 0.1, 0.9),
|
||||
source: "pi-reflect",
|
||||
source: "timeweb-reflect",
|
||||
sourceRef: path,
|
||||
});
|
||||
}
|
||||
info("reflect", `verdict=${parsed.verdict ?? "?"} ${parsed.summary?.slice(0, 80) ?? ""} (${path ?? "no file"})`);
|
||||
return { ok: true, verdict: parsed.verdict, summary: parsed.summary, lessons: parsed.lessons ?? [] };
|
||||
if (rejectedPrefer > 0) {
|
||||
warn("reflect", `dropped prefer_skill from ${rejectedPrefer} reflection lessons (not in registry)`);
|
||||
}
|
||||
|
||||
// v0.3.0 — improvement requests from reflection. The LLM is asked
|
||||
// to flag missing skills/features when self-reflection reveals a
|
||||
// systemic gap (e.g. "I keep failing iron tools because there's no
|
||||
// craft.iron-pickaxe skill").
|
||||
let improvementsCount = 0;
|
||||
for (const imp of asArray(parsed.improvements ?? [])) {
|
||||
if (!imp?.title) continue;
|
||||
createImprovementRequest({
|
||||
source: "reflect",
|
||||
category: imp.category ?? "skill",
|
||||
title: String(imp.title).slice(0, 120),
|
||||
description: imp.description ?? null,
|
||||
context: { verdict: parsed.verdict, reflection_path: path },
|
||||
priority: imp.priority ?? 3,
|
||||
});
|
||||
improvementsCount += 1;
|
||||
}
|
||||
|
||||
info("reflect", `verdict=${parsed.verdict ?? "?"} ${parsed.summary?.slice(0, 80) ?? ""} lessons=${(parsed.lessons ?? []).length} improvements=${improvementsCount} (${path ?? "no file"})`);
|
||||
return { ok: true, verdict: parsed.verdict, summary: parsed.summary, lessons: parsed.lessons ?? [], improvements: improvementsCount };
|
||||
}
|
||||
|
||||
function readJournalTail(stateDir) {
|
||||
@@ -128,19 +172,51 @@ function readPlan(stateDir) {
|
||||
try { return readFileSync(f, "utf8"); } catch { return ""; }
|
||||
}
|
||||
|
||||
function buildPrompt({ snap, journal, scenarios, diary, plan }) {
|
||||
function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) {
|
||||
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)";
|
||||
return [
|
||||
const needLine = activeNeed
|
||||
? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})`
|
||||
: "(satisfied through L10 / no active need)";
|
||||
|
||||
const system = [
|
||||
"You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.",
|
||||
"Look at the last ~30 minutes of activity below. Answer honestly: are you actually making progress, or stuck in a loop?",
|
||||
"Answer honestly: are you making progress, stuck in a loop, or facing a structural gap?",
|
||||
"",
|
||||
skillRegistryPrompt({ limit: 1800 }),
|
||||
"",
|
||||
"Reply with ONE JSON object (no markdown fences, no prose):",
|
||||
'{',
|
||||
' "verdict": "progress" | "loop" | "recovering" | "idle" | "emergency",',
|
||||
' "summary": "<2-3 sentence honest assessment in Russian>",',
|
||||
' "next_action": "<one-sentence directive>",',
|
||||
' "lessons": [',
|
||||
' { "lesson": "<≤30 words, generalised rule>",',
|
||||
' "category": "combat|pathing|crafting|survival|self-improve",',
|
||||
' "trigger_skill": "<skill id or null>",',
|
||||
' "trigger_hostile": "<mob name or null>",',
|
||||
' "avoid_skill": "<registered skill id or null>",',
|
||||
' "prefer_skill": "<registered skill id or null>",',
|
||||
' "confidence": 0.6 } ],',
|
||||
' "improvements": [',
|
||||
' { "title": "<≤80 chars: structural gap (e.g. \'No craft.iron-pickaxe skill\')>",',
|
||||
' "description": "<concrete example showing why no registered skill helps>",',
|
||||
' "category": "skill|tuning|perception|planning|social|other",',
|
||||
' "priority": 1 } ]',
|
||||
'}',
|
||||
"",
|
||||
"CRITICAL: avoid_skill and prefer_skill MUST be one of the registered ids above, or null.",
|
||||
"Use 'improvements' ONLY when you identify a structural gap — a missing skill or feature that would unblock a class of situations. Skip it otherwise.",
|
||||
].join("\n");
|
||||
|
||||
const user = [
|
||||
"## Current state",
|
||||
`- position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`,
|
||||
`- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`,
|
||||
`- runtimeState: ${snap?.runtimeState ?? "?"}`,
|
||||
`- activeSkill: ${snap?.activeSkill ?? "(idle)"}`,
|
||||
`- activeNeed (Maslow ladder L0-L10): ${needLine}`,
|
||||
`- currentMilestone: ${snap?.currentMilestone ?? "?"}`,
|
||||
`- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`,
|
||||
`- lastResult: ${lastResult}`,
|
||||
@@ -165,27 +241,9 @@ function buildPrompt({ snap, journal, scenarios, diary, plan }) {
|
||||
"```",
|
||||
journal.slice(-20).join("\n"),
|
||||
"```",
|
||||
"",
|
||||
"Reply with ONE JSON object (no markdown fences, no prose):",
|
||||
'{',
|
||||
' "verdict": "progress" | "loop" | "recovering" | "idle" | "emergency",',
|
||||
' "summary": "<2-3 sentence honest assessment in Russian>",',
|
||||
' "next_action": "<one-sentence directive for what to do next>",',
|
||||
' "lessons": [',
|
||||
' { "lesson": "<≤30 words, generalised rule>",',
|
||||
' "category": "combat|pathing|crafting|survival|self-improve",',
|
||||
' "trigger_skill": "<skill id or null>",',
|
||||
' "trigger_hostile": "<mob name or null>",',
|
||||
' "avoid_skill": "<skill to avoid or null>",',
|
||||
' "prefer_skill": "<alternative skill id or null>",',
|
||||
' "confidence": 0.6 }',
|
||||
' ]',
|
||||
'}',
|
||||
"",
|
||||
"If you're clearly in a loop (same activity, no inventory growth, same position), say so honestly.",
|
||||
"If you're stuck in a bad terrain (deep pit, hostile-rich area), recommend choosing a new base.",
|
||||
"Lessons should be SHORT and ACTIONABLE. Don't repeat lessons the dispatcher already learned.",
|
||||
].join("\n");
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
function parseReply(text) {
|
||||
@@ -241,22 +299,6 @@ function writeReflection(stateDir, parsed, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
function askPiOnce({ askPi, prompt }) {
|
||||
return new Promise((res) => {
|
||||
let buf = "";
|
||||
try {
|
||||
askPi({
|
||||
prompt,
|
||||
onChunk: ({ stream, text }) => { if (stream === "stdout") buf += text; },
|
||||
onDone: () => res(buf),
|
||||
});
|
||||
} catch (e) {
|
||||
warn("reflect", `askPi: ${e?.message ?? e}`);
|
||||
res(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function asArray(v) { return Array.isArray(v) ? v : v ? [v] : []; }
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
|
||||
@@ -4,14 +4,27 @@ import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { initKnowledge, recall } from "../knowledge/index.js";
|
||||
import { initKnowledge, recall, listImprovements } from "../knowledge/index.js";
|
||||
import { closeStore, __resetForTests, isAvailable } from "../knowledge/store.js";
|
||||
import { runOnce, __testing } from "./reflect.js";
|
||||
|
||||
const { buildPrompt, parseReply } = __testing;
|
||||
|
||||
test("buildPrompt: includes runtime state + plan + diary", () => {
|
||||
const p = buildPrompt({
|
||||
function withTimeWebEnv(fn) {
|
||||
const prevKey = process.env.TIMEWEB_API_KEY;
|
||||
const prevModel = process.env.TIMEWEB_MODEL;
|
||||
process.env.TIMEWEB_API_KEY = "test-key";
|
||||
process.env.TIMEWEB_MODEL = "test-model";
|
||||
return Promise.resolve(fn()).finally(() => {
|
||||
if (prevKey === undefined) delete process.env.TIMEWEB_API_KEY;
|
||||
else process.env.TIMEWEB_API_KEY = prevKey;
|
||||
if (prevModel === undefined) delete process.env.TIMEWEB_MODEL;
|
||||
else process.env.TIMEWEB_MODEL = prevModel;
|
||||
});
|
||||
}
|
||||
|
||||
test("buildPrompt: returns {system, user} with state, plan, diary, improvement schema", () => {
|
||||
const { system, user } = buildPrompt({
|
||||
snap: {
|
||||
position: { x: 600, y: 64, z: 200 },
|
||||
health: 4, food: 6, isDay: false,
|
||||
@@ -26,15 +39,17 @@ test("buildPrompt: includes runtime state + plan + diary", () => {
|
||||
scenarios: ['{"skillId":"explore.far","code":"wedged"}'],
|
||||
diary: "13:00 spawned\n13:05 died",
|
||||
plan: "1. Gather 16 logs\n2. Craft pickaxe",
|
||||
activeNeed: null,
|
||||
});
|
||||
assert.match(p, /position: \(600, 64, 200\)/);
|
||||
assert.match(p, /hp: 4 food: 6/);
|
||||
assert.match(p, /emergency/);
|
||||
assert.match(p, /Gather 16 logs/);
|
||||
assert.match(p, /Reply with ONE JSON object/);
|
||||
assert.match(user, /position: \(600, 64, 200\)/);
|
||||
assert.match(user, /hp: 4 food: 6/);
|
||||
assert.match(user, /emergency/);
|
||||
assert.match(user, /Gather 16 logs/);
|
||||
assert.match(system, /Reply with ONE JSON object/);
|
||||
assert.match(system, /improvements/);
|
||||
});
|
||||
|
||||
test("parseReply: extracts JSON from various Pi outputs", () => {
|
||||
test("parseReply: extracts JSON from various LLM outputs", () => {
|
||||
assert.deepEqual(parseReply('{"verdict":"loop","summary":"stuck"}'), { verdict: "loop", summary: "stuck" });
|
||||
assert.deepEqual(parseReply('```json\n{"verdict":"progress"}\n```'), { verdict: "progress" });
|
||||
const longReply = 'I see... your situation. Here is my JSON:\n{"verdict":"emergency","summary":"hp critical","lessons":[]}\nDone.';
|
||||
@@ -43,7 +58,7 @@ test("parseReply: extracts JSON from various Pi outputs", () => {
|
||||
assert.equal(parseReply(""), null);
|
||||
});
|
||||
|
||||
test("runOnce: writes reflection file + records lessons", async () => {
|
||||
test("runOnce: writes reflection file + records lessons + improvement requests", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-"));
|
||||
__resetForTests();
|
||||
await initKnowledge({ stateDir: tmp });
|
||||
@@ -52,39 +67,64 @@ test("runOnce: writes reflection file + records lessons", async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const fakeReply = JSON.stringify({
|
||||
verdict: "loop",
|
||||
summary: "Бот ходит по кругу, ничего не добывает.",
|
||||
next_action: "выбрать новое место под базу",
|
||||
lessons: [{
|
||||
lesson: "В этой точке постоянные смерти — искать новое место.",
|
||||
category: "survival",
|
||||
prefer_skill: "village.choose-base",
|
||||
confidence: 0.7,
|
||||
}],
|
||||
});
|
||||
const askPi = ({ onChunk, onDone }) => {
|
||||
onChunk({ stream: "stdout", text: fakeReply });
|
||||
onDone({ code: 0 });
|
||||
};
|
||||
const getSnapshot = () => ({
|
||||
position: { x: 0, y: 64, z: 0 },
|
||||
health: 8, food: 10, isDay: true,
|
||||
runtimeState: "working",
|
||||
inventory: {},
|
||||
await withTimeWebEnv(async () => {
|
||||
const fakeReply = {
|
||||
verdict: "loop",
|
||||
summary: "Бот ходит по кругу, ничего не добывает.",
|
||||
next_action: "выбрать новое место под базу",
|
||||
lessons: [{
|
||||
lesson: "В этой точке постоянные смерти — искать новое место.",
|
||||
category: "survival",
|
||||
prefer_skill: "village.choose-base",
|
||||
confidence: 0.7,
|
||||
}],
|
||||
improvements: [
|
||||
{ title: "Add craft.iron-pickaxe skill", description: "Bot mines iron but cannot craft a tier-3 pickaxe.", category: "skill", priority: 2 },
|
||||
],
|
||||
};
|
||||
const askAnalyticalFn = async () => fakeReply;
|
||||
const getSnapshot = () => ({
|
||||
position: { x: 0, y: 64, z: 0 },
|
||||
health: 8, food: 10, isDay: true,
|
||||
runtimeState: "working",
|
||||
inventory: {},
|
||||
});
|
||||
|
||||
const result = await runOnce({ stateDir: tmp, getSnapshot, force: true, askAnalyticalFn });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.verdict, "loop");
|
||||
assert.equal(result.improvements, 1);
|
||||
|
||||
const reflectionsDir = join(tmp, "reflections");
|
||||
assert.ok(existsSync(reflectionsDir));
|
||||
const files = readdirSync(reflectionsDir);
|
||||
assert.ok(files.length >= 1, `expected ≥1 reflection file, got ${files.length}`);
|
||||
|
||||
const lessons = recall({ category: "survival" });
|
||||
assert.ok(lessons.some((l) => l.source === "timeweb-reflect"), "lesson recorded with source=timeweb-reflect");
|
||||
|
||||
const improvements = listImprovements({ source: "reflect" });
|
||||
assert.ok(improvements.some((r) => r.title === "Add craft.iron-pickaxe skill"));
|
||||
});
|
||||
|
||||
const result = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.verdict, "loop");
|
||||
closeStore();
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
const reflectionsDir = join(tmp, "reflections");
|
||||
assert.ok(existsSync(reflectionsDir));
|
||||
const files = readdirSync(reflectionsDir);
|
||||
assert.ok(files.length >= 1, `expected ≥1 reflection file, got ${files.length}`);
|
||||
|
||||
const lessons = recall({ category: "survival" });
|
||||
assert.ok(lessons.some((l) => l.source === "pi-reflect"), "lesson recorded with source=pi-reflect");
|
||||
test("runOnce: skipped when LLM not configured", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-"));
|
||||
__resetForTests();
|
||||
await initKnowledge({ stateDir: tmp });
|
||||
if (!isAvailable()) {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
return;
|
||||
}
|
||||
const prevKey = process.env.TIMEWEB_API_KEY;
|
||||
delete process.env.TIMEWEB_API_KEY;
|
||||
const res = await runOnce({ stateDir: tmp, getSnapshot: () => ({}), force: true });
|
||||
if (prevKey !== undefined) process.env.TIMEWEB_API_KEY = prevKey;
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.reason, "llm not configured");
|
||||
|
||||
closeStore();
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
@@ -98,15 +138,15 @@ test("runOnce: budget exhausted → ok=false", async () => {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
return;
|
||||
}
|
||||
const askPi = ({ onDone }) => onDone({ code: 0 });
|
||||
const getSnapshot = () => ({});
|
||||
// Fire 2 forced calls to exhaust budget; 3rd without force should fail.
|
||||
await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
|
||||
await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
|
||||
const res = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: false });
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.reason ?? "", /budget|reply/);
|
||||
|
||||
await withTimeWebEnv(async () => {
|
||||
const askAnalyticalFn = async () => ({ verdict: "ok" });
|
||||
const getSnapshot = () => ({});
|
||||
await runOnce({ stateDir: tmp, getSnapshot, force: true, askAnalyticalFn });
|
||||
await runOnce({ stateDir: tmp, getSnapshot, force: true, askAnalyticalFn });
|
||||
const res = await runOnce({ stateDir: tmp, getSnapshot, force: false, askAnalyticalFn });
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.reason ?? "", /budget|reply/);
|
||||
});
|
||||
closeStore();
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Trigger tuner — periodic statistical sanity-check over the
|
||||
// advisor_recommendations table.
|
||||
//
|
||||
// Replaces the old Pi-reflect "analyse your own pattern" loop with a
|
||||
// deterministic local computation: no LLM call, no subscription, just
|
||||
// SQL. Every TUNE_INTERVAL_MS the tuner reads the last 24h of
|
||||
// recommendations, groups by trigger_reason, and flags two failure
|
||||
// modes as improvement_requests for the operator:
|
||||
//
|
||||
// 1. low-success trigger: a trigger that fires often (≥ MIN_SAMPLE)
|
||||
// but lands a successful outcome < SUCCESS_FLOOR of the time.
|
||||
// The threshold probably needs tuning, or the prompt isn't giving
|
||||
// the LLM the right hint.
|
||||
// 2. expensive trigger: trigger averages > EXPENSIVE_TOKENS input
|
||||
// tokens but its success rate is mediocre. Could mean the prompt
|
||||
// includes context the LLM doesn't actually use.
|
||||
//
|
||||
// The tuner deduplicates via createImprovementRequest's votes mechanism
|
||||
// — re-flagging the same gap just bumps the counter, not the row count.
|
||||
|
||||
import {
|
||||
isAvailable as knowledgeAvailable,
|
||||
recommendationStats,
|
||||
createImprovementRequest,
|
||||
} from "../knowledge/index.js";
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const TUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MIN_SAMPLE = 5;
|
||||
const SUCCESS_FLOOR = 0.25;
|
||||
const EXPENSIVE_TOKENS = 1000;
|
||||
const EXPENSIVE_SUCCESS_CEILING = 0.5;
|
||||
|
||||
let _timer = null;
|
||||
|
||||
export function attach({ intervalMs = TUNE_INTERVAL_MS } = {}) {
|
||||
if (_timer) {
|
||||
warn("tuner", "attach called twice; ignoring");
|
||||
return;
|
||||
}
|
||||
_timer = setInterval(() => {
|
||||
runOnce().catch((e) => warn("tuner", `tick err: ${e?.message ?? e}`));
|
||||
}, intervalMs);
|
||||
_timer.unref?.();
|
||||
info("tuner", `attached; tune every ${Math.round(intervalMs / 60000)} min`);
|
||||
}
|
||||
|
||||
export function detach() {
|
||||
if (_timer) clearInterval(_timer);
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
export function runOnce({ stats = null } = {}) {
|
||||
if (!knowledgeAvailable()) return { ok: false, reason: "knowledge unavailable" };
|
||||
|
||||
const rows = stats ?? recommendationStats({ sinceHours: 24 });
|
||||
if (!rows.length) return { ok: true, flagged: 0, reason: "no data" };
|
||||
|
||||
const flagged = [];
|
||||
for (const row of rows) {
|
||||
const sample = (row.applied ?? 0);
|
||||
if (sample < MIN_SAMPLE) continue;
|
||||
const succ = row.succeeded ?? 0;
|
||||
const successRate = sample === 0 ? 0 : succ / sample;
|
||||
|
||||
// 1. Low success → tune the trigger
|
||||
if (successRate < SUCCESS_FLOOR) {
|
||||
const title = `Trigger "${row.trigger_reason}" has low success rate`;
|
||||
createImprovementRequest({
|
||||
source: "tuner",
|
||||
category: "tuning",
|
||||
title,
|
||||
description: `Over the last 24h, ${sample} applied recommendations from trigger ${row.trigger_reason} produced only ${succ} successful outcomes (${(successRate * 100).toFixed(0)}%). Consider tightening the trigger condition, improving the prompt, or adjusting the threshold.`,
|
||||
context: { stats: row },
|
||||
priority: 2,
|
||||
});
|
||||
flagged.push({ kind: "low_success", trigger: row.trigger_reason, sample, succ });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Expensive prompt with mediocre payoff
|
||||
const avgIn = row.avg_in ?? 0;
|
||||
if (avgIn > EXPENSIVE_TOKENS && successRate < EXPENSIVE_SUCCESS_CEILING) {
|
||||
const title = `Trigger "${row.trigger_reason}" prompt is expensive`;
|
||||
createImprovementRequest({
|
||||
source: "tuner",
|
||||
category: "tuning",
|
||||
title,
|
||||
description: `Trigger ${row.trigger_reason} averages ${Math.round(avgIn)} input tokens but lands successful outcomes only ${(successRate * 100).toFixed(0)}% of the time (${succ}/${sample}). The prompt may include context the model doesn't use — consider trimming.`,
|
||||
context: { stats: row },
|
||||
priority: 4,
|
||||
});
|
||||
flagged.push({ kind: "expensive_prompt", trigger: row.trigger_reason, avgIn });
|
||||
}
|
||||
}
|
||||
if (flagged.length > 0) {
|
||||
info("tuner", `flagged ${flagged.length} improvement(s) from ${rows.length} trigger group(s)`);
|
||||
}
|
||||
return { ok: true, flagged: flagged.length, items: flagged, groups: rows.length };
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = { TUNE_INTERVAL_MS, MIN_SAMPLE, SUCCESS_FLOOR, EXPENSIVE_TOKENS };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { initKnowledge, isAvailable, listImprovements } from "../knowledge/index.js";
|
||||
import { closeStore, __resetForTests } from "../knowledge/store.js";
|
||||
import { runOnce, __testing } from "./trigger-tuner.js";
|
||||
|
||||
const { MIN_SAMPLE } = __testing;
|
||||
|
||||
async function bootstrap() {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "pepa-tuner-test-"));
|
||||
__resetForTests();
|
||||
await initKnowledge({ stateDir: tmp });
|
||||
return tmp;
|
||||
}
|
||||
|
||||
function cleanup(tmp) {
|
||||
closeStore();
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
test("runOnce: empty stats → ok with 0 flagged", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const r = runOnce({ stats: [] });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.flagged, 0);
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("runOnce: ignores small samples (below MIN_SAMPLE)", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const stats = [
|
||||
{ trigger_reason: "wedged_60s", total: 2, applied: 2, succeeded: 0, failed: 2, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
||||
];
|
||||
const r = runOnce({ stats });
|
||||
assert.equal(r.flagged, 0, "applied=2 is below MIN_SAMPLE; skipped");
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("runOnce: flags low success-rate trigger as improvement", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const stats = [
|
||||
{ trigger_reason: "wedged_60s", total: 10, applied: 10, succeeded: 1, failed: 9, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
||||
];
|
||||
const r = runOnce({ stats });
|
||||
assert.equal(r.flagged, 1);
|
||||
const requests = listImprovements({ source: "tuner" });
|
||||
assert.ok(requests.some((req) => req.title.includes("wedged_60s") && req.title.includes("low success")));
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("runOnce: flags expensive prompt with mediocre payoff", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const stats = [
|
||||
{ trigger_reason: "repeat_4_explore.far", total: 10, applied: 10, succeeded: 4, failed: 6, avg_in: 1500, avg_out: 50, avg_latency_ms: 7000 },
|
||||
];
|
||||
const r = runOnce({ stats });
|
||||
assert.equal(r.flagged, 1);
|
||||
const requests = listImprovements({ source: "tuner", category: "tuning" });
|
||||
assert.ok(requests.some((req) => req.title.includes("expensive")));
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("runOnce: healthy trigger does NOT get flagged", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const stats = [
|
||||
{ trigger_reason: "emergency_hp4_creeper@3", total: 8, applied: 8, succeeded: 7, failed: 1, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
||||
];
|
||||
const r = runOnce({ stats });
|
||||
assert.equal(r.flagged, 0);
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("runOnce: re-running with same low-success stats bumps votes, not row count", async () => {
|
||||
const tmp = await bootstrap();
|
||||
if (!isAvailable()) { cleanup(tmp); return; }
|
||||
const stats = [
|
||||
{ trigger_reason: "wedged_unique_label", total: 10, applied: 10, succeeded: 1, failed: 9, avg_in: 700, avg_out: 40, avg_latency_ms: 5000 },
|
||||
];
|
||||
runOnce({ stats });
|
||||
runOnce({ stats });
|
||||
const requests = listImprovements({ source: "tuner" }).filter((r) => r.title.includes("wedged_unique_label"));
|
||||
assert.equal(requests.length, 1, "single row for the same title");
|
||||
assert.ok(requests[0].votes >= 2, "votes bumped on re-flagging");
|
||||
cleanup(tmp);
|
||||
});
|
||||
|
||||
test("MIN_SAMPLE constant is reasonable", () => {
|
||||
assert.ok(MIN_SAMPLE >= 3 && MIN_SAMPLE <= 10);
|
||||
});
|
||||
@@ -227,6 +227,191 @@ export function logChat({ direction, speaker, text, intent, repliedWith } = {})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- v0.3.0 advisor recommendations ----------------------------------------
|
||||
//
|
||||
// Every fast-advisor call that produced a usable answer is logged here.
|
||||
// Rows are mutated post-hoc when reflex applies and when the dispatch
|
||||
// finishes — this is the ground truth for "is the LLM advice actually
|
||||
// helping" and the input to trigger-tuner.js.
|
||||
|
||||
export function insertRecommendation({
|
||||
triggerReason, plannedSkill, recommendedSkill, action, rationale,
|
||||
activeNeed, tokensIn, tokensOut, latencyMs,
|
||||
} = {}) {
|
||||
if (!_isAvailable()) return null;
|
||||
try {
|
||||
const res = _getStore().prepare(`
|
||||
INSERT INTO advisor_recommendations
|
||||
(ts, trigger_reason, planned_skill, recommended_skill, action, rationale,
|
||||
active_need, tokens_in, tokens_out, latency_ms, applied)
|
||||
VALUES
|
||||
(@ts, @triggerReason, @plannedSkill, @recommendedSkill, @action, @rationale,
|
||||
@activeNeed, @tokensIn, @tokensOut, @latencyMs, 0)
|
||||
`).run({
|
||||
ts: Date.now(),
|
||||
triggerReason,
|
||||
plannedSkill: plannedSkill ?? null,
|
||||
recommendedSkill: recommendedSkill ?? null,
|
||||
action,
|
||||
rationale: rationale ?? null,
|
||||
activeNeed: activeNeed ?? null,
|
||||
tokensIn: tokensIn ?? null,
|
||||
tokensOut: tokensOut ?? null,
|
||||
latencyMs: latencyMs ?? null,
|
||||
});
|
||||
return res.lastInsertRowid;
|
||||
} catch (e) {
|
||||
warn("knowledge", `insertRecommendation failed: ${e?.message ?? e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function markRecommendationApplied(id) {
|
||||
if (!_isAvailable() || !id) return;
|
||||
try {
|
||||
_getStore().prepare(`UPDATE advisor_recommendations SET applied = 1 WHERE id = ?`).run(id);
|
||||
} catch (e) {
|
||||
warn("knowledge", `markRecommendationApplied failed: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function markRecommendationOutcome(id, { ok, code } = {}) {
|
||||
if (!_isAvailable() || !id) return;
|
||||
try {
|
||||
_getStore().prepare(`
|
||||
UPDATE advisor_recommendations
|
||||
SET outcome_ok = @ok, outcome_code = @code, outcome_at = @at
|
||||
WHERE id = @id
|
||||
`).run({ id, ok: ok ? 1 : 0, code: code ?? null, at: Date.now() });
|
||||
} catch (e) {
|
||||
warn("knowledge", `markRecommendationOutcome failed: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function recommendationStats({ sinceHours = 24 } = {}) {
|
||||
if (!_isAvailable()) return [];
|
||||
try {
|
||||
const since = Date.now() - sinceHours * 3600_000;
|
||||
return _getStore().prepare(`
|
||||
SELECT trigger_reason,
|
||||
COUNT(*) AS total,
|
||||
SUM(applied) AS applied,
|
||||
SUM(CASE WHEN outcome_ok = 1 THEN 1 ELSE 0 END) AS succeeded,
|
||||
SUM(CASE WHEN outcome_ok = 0 THEN 1 ELSE 0 END) AS failed,
|
||||
AVG(tokens_in) AS avg_in,
|
||||
AVG(tokens_out) AS avg_out,
|
||||
AVG(latency_ms) AS avg_latency_ms
|
||||
FROM advisor_recommendations
|
||||
WHERE ts >= @since
|
||||
GROUP BY trigger_reason
|
||||
ORDER BY total DESC
|
||||
`).all({ since });
|
||||
} catch (e) {
|
||||
warn("knowledge", `recommendationStats failed: ${e?.message ?? e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function recentRecommendations({ limit = 20 } = {}) {
|
||||
if (!_isAvailable()) return [];
|
||||
try {
|
||||
return _getStore().prepare(`
|
||||
SELECT * FROM advisor_recommendations ORDER BY ts DESC LIMIT @limit
|
||||
`).all({ limit });
|
||||
} catch (e) {
|
||||
warn("knowledge", `recentRecommendations failed: ${e?.message ?? e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- v0.3.0 improvement requests -------------------------------------------
|
||||
//
|
||||
// The LLM (postmortem / reflect / advisor) writes here when it sees the bot
|
||||
// lack a needed skill or feature. Operator-readable via scripts/list-improvements.js.
|
||||
|
||||
export function createImprovementRequest({
|
||||
source, category, title, description, context, priority = 3,
|
||||
} = {}) {
|
||||
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;
|
||||
}
|
||||
const res = _getStore().prepare(`
|
||||
INSERT INTO improvement_requests
|
||||
(ts, source, category, title, description, context, priority, status, votes)
|
||||
VALUES
|
||||
(@ts, @source, @category, @title, @description, @context, @priority, 'open', 1)
|
||||
`).run({
|
||||
ts: Date.now(),
|
||||
source: source ?? "manual",
|
||||
category: category ?? "other",
|
||||
title,
|
||||
description: description ?? null,
|
||||
context: context ? JSON.stringify(context) : null,
|
||||
priority: clamp(priority, 1, 5),
|
||||
});
|
||||
return res.lastInsertRowid;
|
||||
} catch (e) {
|
||||
warn("knowledge", `createImprovementRequest failed: ${e?.message ?? e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listImprovements({ status, source, category, limit = 50 } = {}) {
|
||||
if (!_isAvailable()) return [];
|
||||
try {
|
||||
const where = [];
|
||||
const params = { limit };
|
||||
if (status) { where.push("status = @status"); params.status = status; }
|
||||
if (source) { where.push("source = @source"); params.source = source; }
|
||||
if (category) { where.push("category = @category"); params.category = category; }
|
||||
const sql = `
|
||||
SELECT * FROM improvement_requests
|
||||
${where.length ? "WHERE " + where.join(" AND ") : ""}
|
||||
ORDER BY (status = 'open') DESC, priority ASC, votes DESC, ts DESC
|
||||
LIMIT @limit
|
||||
`;
|
||||
return _getStore().prepare(sql).all(params).map((r) => ({
|
||||
...r,
|
||||
context: safeParse(r.context),
|
||||
}));
|
||||
} catch (e) {
|
||||
warn("knowledge", `listImprovements failed: ${e?.message ?? e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function markImprovementStatus(id, { status, notes } = {}) {
|
||||
if (!_isAvailable() || !id) return;
|
||||
const validStatuses = ["open", "in_progress", "implemented", "rejected", "duplicate"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
warn("knowledge", `markImprovementStatus: invalid status "${status}"`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fields = ["status = @status", "notes = @notes"];
|
||||
const params = { id, status, notes: notes ?? null };
|
||||
if (status === "implemented") {
|
||||
fields.push("implemented_at = @implementedAt");
|
||||
params.implementedAt = Date.now();
|
||||
}
|
||||
_getStore().prepare(`UPDATE improvement_requests SET ${fields.join(", ")} WHERE id = @id`).run(params);
|
||||
} catch (e) {
|
||||
warn("knowledge", `markImprovementStatus failed: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, Number(n) || lo)); }
|
||||
|
||||
function safeParse(s) {
|
||||
if (!s) return null;
|
||||
try { return JSON.parse(s); } catch { return null; }
|
||||
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
recordPOI,
|
||||
poiNearby,
|
||||
logChat,
|
||||
insertRecommendation,
|
||||
markRecommendationApplied,
|
||||
markRecommendationOutcome,
|
||||
recommendationStats,
|
||||
recentRecommendations,
|
||||
createImprovementRequest,
|
||||
listImprovements,
|
||||
markImprovementStatus,
|
||||
} from "./index.js";
|
||||
import { __resetForTests, closeStore } from "./store.js";
|
||||
|
||||
@@ -217,6 +225,112 @@ test("chat log: append + select", async () => {
|
||||
assert.ok(id1 && id2);
|
||||
});
|
||||
|
||||
// ---- v0.3.0 advisor recommendations ---------------------------------------
|
||||
|
||||
test("advisor recommendations: insert → markApplied → markOutcome → stats", async () => {
|
||||
await bootstrap();
|
||||
if (!isAvailable()) {
|
||||
assert.equal(insertRecommendation({ triggerReason: "x", action: "switch_skill" }), null);
|
||||
return;
|
||||
}
|
||||
const id = insertRecommendation({
|
||||
triggerReason: "wedged_90s",
|
||||
plannedSkill: "explore.far",
|
||||
recommendedSkill: "recovery.tunnel-out",
|
||||
action: "switch_skill",
|
||||
rationale: "Stuck wedged, tunnel out.",
|
||||
activeNeed: "L2 tools_wood",
|
||||
tokensIn: 700, tokensOut: 40, latencyMs: 5000,
|
||||
});
|
||||
assert.ok(id, "got recommendation id");
|
||||
markRecommendationApplied(id);
|
||||
markRecommendationOutcome(id, { ok: true, code: "done" });
|
||||
|
||||
const recent = recentRecommendations({ limit: 5 });
|
||||
const row = recent.find((r) => r.id === id);
|
||||
assert.ok(row);
|
||||
assert.equal(row.applied, 1);
|
||||
assert.equal(row.outcome_ok, 1);
|
||||
|
||||
// second insert with same trigger to test stats grouping
|
||||
const id2 = insertRecommendation({
|
||||
triggerReason: "wedged_90s",
|
||||
plannedSkill: "explore.far",
|
||||
recommendedSkill: "survive.pillar-up",
|
||||
action: "switch_skill",
|
||||
rationale: "Try pillar.",
|
||||
tokensIn: 720, tokensOut: 50, latencyMs: 6000,
|
||||
});
|
||||
markRecommendationApplied(id2);
|
||||
markRecommendationOutcome(id2, { ok: false, code: "no_progress" });
|
||||
|
||||
const stats = recommendationStats({ sinceHours: 24 });
|
||||
const wedged = stats.find((s) => s.trigger_reason === "wedged_90s");
|
||||
assert.ok(wedged);
|
||||
assert.equal(wedged.total, 2);
|
||||
assert.equal(wedged.applied, 2);
|
||||
assert.equal(wedged.succeeded, 1);
|
||||
assert.equal(wedged.failed, 1);
|
||||
});
|
||||
|
||||
test("advisor recommendations: graceful no-op on unknown id", async () => {
|
||||
await bootstrap();
|
||||
if (!isAvailable()) return;
|
||||
markRecommendationApplied(null);
|
||||
markRecommendationOutcome(null, { ok: true });
|
||||
markRecommendationOutcome(999999, { ok: true });
|
||||
// no throw = pass
|
||||
});
|
||||
|
||||
// ---- v0.3.0 improvement requests ------------------------------------------
|
||||
|
||||
test("improvement requests: create, dedup-by-title bumps votes, list filters", async () => {
|
||||
await bootstrap();
|
||||
if (!isAvailable()) {
|
||||
assert.equal(createImprovementRequest({ title: "x" }), null);
|
||||
return;
|
||||
}
|
||||
const id1 = createImprovementRequest({
|
||||
source: "postmortem",
|
||||
category: "skill",
|
||||
title: "Add craft.iron-pickaxe skill",
|
||||
description: "Bot has iron ingots but no skill to craft tier-3 pickaxe.",
|
||||
priority: 2,
|
||||
});
|
||||
assert.ok(id1);
|
||||
|
||||
// duplicate title → bumps votes, returns same id
|
||||
const id2 = createImprovementRequest({
|
||||
source: "reflect",
|
||||
category: "skill",
|
||||
title: "Add craft.iron-pickaxe skill",
|
||||
priority: 2,
|
||||
});
|
||||
assert.equal(id2, id1, "dedup returns original id");
|
||||
|
||||
const list = listImprovements({ status: "open", category: "skill" });
|
||||
const row = list.find((r) => r.id === id1);
|
||||
assert.ok(row);
|
||||
assert.equal(row.votes, 2, "votes bumped by duplicate");
|
||||
|
||||
markImprovementStatus(id1, { status: "implemented", notes: "Shipped in v0.3.1" });
|
||||
const updated = listImprovements({ status: "implemented" });
|
||||
assert.ok(updated.some((r) => r.id === id1));
|
||||
const stillOpen = listImprovements({ status: "open" });
|
||||
assert.ok(!stillOpen.some((r) => r.id === id1));
|
||||
});
|
||||
|
||||
test("improvement requests: priority and status ordering", async () => {
|
||||
await bootstrap();
|
||||
if (!isAvailable()) return;
|
||||
const a = createImprovementRequest({ source: "manual", title: "low-prio thing", priority: 5 });
|
||||
const b = createImprovementRequest({ source: "manual", title: "high-prio thing", priority: 1 });
|
||||
const list = listImprovements({ status: "open" });
|
||||
const ai = list.findIndex((r) => r.id === a);
|
||||
const bi = list.findIndex((r) => r.id === b);
|
||||
assert.ok(bi < ai, "priority 1 listed before priority 5");
|
||||
});
|
||||
|
||||
// Cleanup: close DB and remove tmp dir.
|
||||
test("teardown", () => {
|
||||
closeStore();
|
||||
|
||||
@@ -179,3 +179,63 @@ CREATE TABLE IF NOT EXISTS code_changes (
|
||||
outcome TEXT, -- 'applied'|'rolled_back'|'rejected'
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Advisor recommendations (v0.3.0+ fast LLM trail)
|
||||
-- Every time runtime/coach/advisor-trigger.js asks the fast LLM and
|
||||
-- the answer is cached on ctx, we write a row here. When the reflex
|
||||
-- consumes the recommendation and dispatches, we attach the dispatch
|
||||
-- result later via outcome_ok / outcome_code. The history is the
|
||||
-- ground truth for trigger-tuner.js stats and for the operator's
|
||||
-- "what is the LLM suggesting and is it actually helping" question.
|
||||
----------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS advisor_recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
trigger_reason TEXT NOT NULL, -- 'wedged_*', 'repeat_*', 'preempt_retry_*', 'emergency_*'
|
||||
planned_skill TEXT, -- what manifesto/curriculum was about to dispatch
|
||||
recommended_skill TEXT, -- what the LLM said to do instead
|
||||
action TEXT NOT NULL, -- 'switch_skill' | 'continue' | 'wait'
|
||||
rationale TEXT,
|
||||
active_need TEXT, -- 'L2 tools_wood' etc.
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
latency_ms INTEGER,
|
||||
applied INTEGER NOT NULL DEFAULT 0, -- 1 if reflex actually dispatched recommended_skill
|
||||
outcome_ok INTEGER, -- NULL until dispatch finishes
|
||||
outcome_code TEXT,
|
||||
outcome_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_advisor_ts ON advisor_recommendations(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_advisor_trigger ON advisor_recommendations(trigger_reason);
|
||||
CREATE INDEX IF NOT EXISTS idx_advisor_outcome ON advisor_recommendations(outcome_ok);
|
||||
|
||||
----------------------------------------------------------------------
|
||||
-- Improvement requests (v0.3.0+)
|
||||
-- The LLM (postmortem / reflect / advisor) can flag situations where
|
||||
-- the bot lacked the right skill or feature. Instead of trying to
|
||||
-- self-patch (which we explicitly disabled), it writes an entry here.
|
||||
-- The operator reads `scripts/list-improvements.js` and decides what
|
||||
-- to implement. Implemented entries get marked so the bot stops
|
||||
-- re-flagging the same gap.
|
||||
----------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS improvement_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
source TEXT NOT NULL, -- 'postmortem'|'reflect'|'advisor'|'tuner'|'manual'
|
||||
category TEXT, -- 'skill'|'tuning'|'perception'|'planning'|'social'|'other'
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
context TEXT, -- JSON: position, snapshot tail, related lesson ids
|
||||
priority INTEGER NOT NULL DEFAULT 3, -- 1..5 (1=urgent, 5=nice-to-have)
|
||||
status TEXT NOT NULL DEFAULT 'open', -- 'open'|'in_progress'|'implemented'|'rejected'|'duplicate'
|
||||
duplicate_of INTEGER, -- another row id if dup
|
||||
votes INTEGER NOT NULL DEFAULT 1, -- bumped each time the bot re-flags same gap
|
||||
implemented_at INTEGER,
|
||||
notes TEXT,
|
||||
FOREIGN KEY (duplicate_of) REFERENCES improvement_requests(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_improvements_status ON improvement_requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_improvements_priority ON improvement_requests(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_improvements_source ON improvement_requests(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_improvements_ts ON improvement_requests(ts);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// OpenAI-compatible chat client for the "fast advisor" tier.
|
||||
//
|
||||
// The original "coach" loop uses Pi via the CLI subprocess (5-15s latency,
|
||||
// rate-limited to a few calls per hour). That's appropriate for deep
|
||||
// post-mortem analytics but useless when the bot needs tactical advice
|
||||
// right now ("I'm wedged in a pit, what should I do?").
|
||||
//
|
||||
// This provider opens a parallel path: any OpenAI-compatible HTTP endpoint
|
||||
// (TimeWeb is the default — same env var convention as the user's other
|
||||
// projects — but OpenAI direct, Groq, OpenRouter, and local Ollama with
|
||||
// the OpenAI shim all work with the same plumbing) producing a structured
|
||||
// JSON answer in ≤8 seconds.
|
||||
//
|
||||
// Configuration is strictly env-driven. The provider is a NO-OP unless
|
||||
// TIMEWEB_API_KEY is set, so it's safe to ship the code disabled.
|
||||
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
const ENV = {
|
||||
BASE_URL: "TIMEWEB_BASE_URL",
|
||||
API_KEY: "TIMEWEB_API_KEY",
|
||||
MODEL: "TIMEWEB_MODEL",
|
||||
TIMEOUT_MS: "TIMEWEB_TIMEOUT_MS",
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
||||
// 20s default — TimeWeb's hosted agent endpoint takes 5-15s for the
|
||||
// fast-advisor prompt (registry block + snapshot context). 8s was too
|
||||
// tight and produced spurious timeouts in smoke tests. OpenAI direct
|
||||
// returns much faster (<2s); the env var overrides if needed.
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
|
||||
export function isAvailable() {
|
||||
return !!process.env[ENV.API_KEY];
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return {
|
||||
baseUrl: (process.env[ENV.BASE_URL] || DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
||||
apiKey: process.env[ENV.API_KEY] || null,
|
||||
model: process.env[ENV.MODEL] || null,
|
||||
timeoutMs: Number(process.env[ENV.TIMEOUT_MS]) || DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* complete({ system, user, json, model?, timeoutMs? })
|
||||
* → { ok: true, text, raw, latencyMs } | { ok: false, code, detail, latencyMs }
|
||||
*
|
||||
* `json: true` requests JSON-mode (response_format) and returns the
|
||||
* parsed object as `text`. If the provider doesn't honour JSON-mode the
|
||||
* call still works but caller is responsible for parsing.
|
||||
*/
|
||||
export async function complete({
|
||||
system,
|
||||
user,
|
||||
json = false,
|
||||
model,
|
||||
timeoutMs,
|
||||
} = {}) {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.apiKey) {
|
||||
return { ok: false, code: "not_configured", detail: `set ${ENV.API_KEY}`, latencyMs: 0 };
|
||||
}
|
||||
const useModel = model || cfg.model;
|
||||
if (!useModel) {
|
||||
return { ok: false, code: "no_model", detail: `set ${ENV.MODEL} env or pass model arg`, latencyMs: 0 };
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: useModel,
|
||||
messages: [
|
||||
system ? { role: "system", content: system } : null,
|
||||
{ role: "user", content: user ?? "" },
|
||||
].filter(Boolean),
|
||||
temperature: 0.3,
|
||||
};
|
||||
if (json) {
|
||||
body.response_format = { type: "json_object" };
|
||||
}
|
||||
|
||||
const url = `${cfg.baseUrl}/chat/completions`;
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs ?? cfg.timeoutMs);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${cfg.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
clearTimeout(t);
|
||||
const latency = Date.now() - startedAt;
|
||||
const aborted = e?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
code: aborted ? "timeout" : "network_error",
|
||||
detail: e?.message ?? String(e),
|
||||
latencyMs: latency,
|
||||
};
|
||||
}
|
||||
clearTimeout(t);
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
if (!resp.ok) {
|
||||
let body;
|
||||
try { body = await resp.text(); } catch { body = "<no body>"; }
|
||||
warn("llm", `${useModel} ${resp.status}: ${body.slice(0, 200)}`);
|
||||
return {
|
||||
ok: false,
|
||||
code: `http_${resp.status}`,
|
||||
detail: body.slice(0, 500),
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await resp.json();
|
||||
} catch (e) {
|
||||
return { ok: false, code: "bad_json", detail: e?.message ?? "parse error", latencyMs };
|
||||
}
|
||||
|
||||
const text = payload?.choices?.[0]?.message?.content;
|
||||
if (typeof text !== "string") {
|
||||
return { ok: false, code: "no_content", detail: "no choices[0].message.content", latencyMs };
|
||||
}
|
||||
|
||||
let parsed = text;
|
||||
if (json) {
|
||||
parsed = tryParseJson(text);
|
||||
if (parsed === null) {
|
||||
return { ok: false, code: "bad_json", detail: text.slice(0, 200), latencyMs };
|
||||
}
|
||||
}
|
||||
|
||||
// usage shape per OpenAI / TimeWeb / most compat endpoints:
|
||||
// { prompt_tokens, completion_tokens, total_tokens }
|
||||
const usage = normaliseUsage(payload?.usage);
|
||||
info("llm", `${useModel} ok (${latencyMs}ms, ${text.length}ch, in=${usage.in}/out=${usage.out}t)`);
|
||||
return { ok: true, text: parsed, raw: text, latencyMs, usage };
|
||||
}
|
||||
|
||||
function normaliseUsage(u) {
|
||||
if (!u || typeof u !== "object") return { in: 0, out: 0, total: 0 };
|
||||
const inT = Number(u.prompt_tokens ?? u.input_tokens ?? 0) || 0;
|
||||
const outT = Number(u.completion_tokens ?? u.output_tokens ?? 0) || 0;
|
||||
const total = Number(u.total_tokens ?? inT + outT) || (inT + outT);
|
||||
return { in: inT, out: outT, total };
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
if (!text) return null;
|
||||
const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
|
||||
try { return JSON.parse(trimmed); } catch {}
|
||||
const m = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (!m) return null;
|
||||
try { return JSON.parse(m[0]); } catch { return null; }
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = { ENV, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, tryParseJson, normaliseUsage };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isAvailable, getConfig, complete, __testing } from "./provider.js";
|
||||
|
||||
const { tryParseJson, ENV } = __testing;
|
||||
|
||||
test("isAvailable: false when no API key in env", () => {
|
||||
const prev = process.env[ENV.API_KEY];
|
||||
delete process.env[ENV.API_KEY];
|
||||
try {
|
||||
assert.equal(isAvailable(), false);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env[ENV.API_KEY] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("isAvailable: true when API key set", () => {
|
||||
const prev = process.env[ENV.API_KEY];
|
||||
process.env[ENV.API_KEY] = "test-key";
|
||||
try {
|
||||
assert.equal(isAvailable(), true);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[ENV.API_KEY];
|
||||
else process.env[ENV.API_KEY] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("getConfig: reflects env overrides and strips trailing slash", () => {
|
||||
const prev = {
|
||||
base: process.env[ENV.BASE_URL],
|
||||
key: process.env[ENV.API_KEY],
|
||||
model: process.env[ENV.MODEL],
|
||||
};
|
||||
process.env[ENV.BASE_URL] = "https://api.example.com/v1/";
|
||||
process.env[ENV.API_KEY] = "abc";
|
||||
process.env[ENV.MODEL] = "gpt-fast";
|
||||
try {
|
||||
const cfg = getConfig();
|
||||
assert.equal(cfg.baseUrl, "https://api.example.com/v1");
|
||||
assert.equal(cfg.apiKey, "abc");
|
||||
assert.equal(cfg.model, "gpt-fast");
|
||||
assert.ok(cfg.timeoutMs > 0);
|
||||
} finally {
|
||||
for (const [k, v] of [[ENV.BASE_URL, prev.base], [ENV.API_KEY, prev.key], [ENV.MODEL, prev.model]]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("complete: not_configured when no API key", async () => {
|
||||
const prev = process.env[ENV.API_KEY];
|
||||
delete process.env[ENV.API_KEY];
|
||||
try {
|
||||
const res = await complete({ system: "hi", user: "hi" });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "not_configured");
|
||||
} finally {
|
||||
if (prev !== undefined) process.env[ENV.API_KEY] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("complete: no_model when key is set but model isn't", async () => {
|
||||
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
||||
process.env[ENV.API_KEY] = "x";
|
||||
delete process.env[ENV.MODEL];
|
||||
try {
|
||||
const res = await complete({ system: "s", user: "u" });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "no_model");
|
||||
} finally {
|
||||
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
||||
else process.env[ENV.API_KEY] = prev.key;
|
||||
if (prev.model !== undefined) process.env[ENV.MODEL] = prev.model;
|
||||
}
|
||||
});
|
||||
|
||||
test("tryParseJson: parses naked, fenced, and embedded JSON", () => {
|
||||
assert.deepEqual(tryParseJson('{"a":1}'), { a: 1 });
|
||||
assert.deepEqual(tryParseJson('```json\n{"a":2}\n```'), { a: 2 });
|
||||
assert.deepEqual(tryParseJson('prose before {"a":3} prose after'), { a: 3 });
|
||||
assert.equal(tryParseJson("nope"), null);
|
||||
assert.equal(tryParseJson(""), null);
|
||||
});
|
||||
|
||||
test("complete: real fetch path uses Bearer header and POSTs JSON", async () => {
|
||||
// Stub global fetch to capture the request.
|
||||
const calls = [];
|
||||
const stub = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: JSON.stringify({ verdict: "loop", action: "wander" }) } }],
|
||||
}),
|
||||
};
|
||||
};
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stub;
|
||||
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL], base: process.env[ENV.BASE_URL] };
|
||||
process.env[ENV.API_KEY] = "secret-123";
|
||||
process.env[ENV.MODEL] = "gpt-fast";
|
||||
process.env[ENV.BASE_URL] = "https://example/v1";
|
||||
try {
|
||||
const res = await complete({ system: "be terse", user: "what now?", json: true });
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.text, { verdict: "loop", action: "wander" });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://example/v1/chat/completions");
|
||||
assert.equal(calls[0].opts.method, "POST");
|
||||
assert.equal(calls[0].opts.headers["Authorization"], "Bearer secret-123");
|
||||
const sent = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(sent.model, "gpt-fast");
|
||||
assert.equal(sent.messages[0].role, "system");
|
||||
assert.equal(sent.messages[1].role, "user");
|
||||
assert.equal(sent.response_format.type, "json_object");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
for (const [k, v] of [[ENV.API_KEY, prev.key], [ENV.MODEL, prev.model], [ENV.BASE_URL, prev.base]]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("complete: network error surfaces as code=network_error", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error("boom"); };
|
||||
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
||||
process.env[ENV.API_KEY] = "x";
|
||||
process.env[ENV.MODEL] = "m";
|
||||
try {
|
||||
const res = await complete({ system: "s", user: "u" });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "network_error");
|
||||
assert.match(res.detail, /boom/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
||||
else process.env[ENV.API_KEY] = prev.key;
|
||||
if (prev.model === undefined) delete process.env[ENV.MODEL];
|
||||
else process.env[ENV.MODEL] = prev.model;
|
||||
}
|
||||
});
|
||||
|
||||
test("complete: http error surfaces as http_<status>", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({ ok: false, status: 401, text: async () => "bad key" });
|
||||
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
||||
process.env[ENV.API_KEY] = "x";
|
||||
process.env[ENV.MODEL] = "m";
|
||||
try {
|
||||
const res = await complete({ system: "s", user: "u" });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "http_401");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
||||
else process.env[ENV.API_KEY] = prev.key;
|
||||
if (prev.model === undefined) delete process.env[ENV.MODEL];
|
||||
else process.env[ENV.MODEL] = prev.model;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
// Hierarchical needs ladder (Maslow-like). Each need has:
|
||||
// id stable kebab-case
|
||||
// level 0-10, ascending priority (0 = most urgent)
|
||||
// title Russian short label for chat narration
|
||||
// detect(s) → bool, true means need is already satisfied
|
||||
// pursue(s) → { skillId, args? } | null, what to do RIGHT NOW
|
||||
//
|
||||
// Need ordering matters: state.js picks the LOWEST-level unsatisfied
|
||||
// need. If pursue() returns null we move on to the next level — that's
|
||||
// how "I want armour but can't craft it yet" gracefully degrades to
|
||||
// "go gather more iron".
|
||||
//
|
||||
// Snapshot shape comes from runtime/perceive.js#snapshot().
|
||||
|
||||
const PICKAXE_WOOD = ["wooden_pickaxe"];
|
||||
const PICKAXE_STONE = ["stone_pickaxe"];
|
||||
const PICKAXE_IRON = ["iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"];
|
||||
const AXE_WOOD = ["wooden_axe"];
|
||||
const AXE_STONE = ["stone_axe"];
|
||||
const AXE_IRON = ["iron_axe", "diamond_axe", "netherite_axe"];
|
||||
const SWORD_WOOD = ["wooden_sword"];
|
||||
const SWORD_STONE = ["stone_sword"];
|
||||
const SWORD_IRON = ["iron_sword", "diamond_sword", "netherite_sword"];
|
||||
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 ARMOR_CHEST_ANY = [
|
||||
"leather_chestplate", "iron_chestplate", "golden_chestplate",
|
||||
"diamond_chestplate", "netherite_chestplate", "chainmail_chestplate",
|
||||
];
|
||||
const ARMOR_IRON_CHEST = ["iron_chestplate"];
|
||||
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",
|
||||
];
|
||||
|
||||
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 hostileImminent(s) {
|
||||
const h = s?.closestHostile;
|
||||
if (!h) return false;
|
||||
return (h.distance ?? Infinity) < 8;
|
||||
}
|
||||
|
||||
function aliveDetect(s) {
|
||||
if (!s?.connected) return true; // not connected, nothing to do
|
||||
const hp = s.health ?? 20;
|
||||
const food = s.food ?? 20;
|
||||
if (hp <= 5) return false;
|
||||
if (food <= 0) return false;
|
||||
if (s.hazards?.inFluid && s.hazards?.footBlock === "lava") return false;
|
||||
if (hostileImminent(s) && hp <= 10) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function alivePursue(s) {
|
||||
const hp = s.health ?? 20;
|
||||
const food = s.food ?? 20;
|
||||
if (s.hazards?.footBlock === "lava") {
|
||||
return { skillId: "recovery.tunnel-out", args: { reason: "lava" } };
|
||||
}
|
||||
if (food <= 0 && s.hasFood) {
|
||||
return { skillId: "survive.eat" };
|
||||
}
|
||||
if (food <= 0 && !s.hasFood) {
|
||||
return { skillId: "survive.acquire-food" };
|
||||
}
|
||||
if (hostileImminent(s)) {
|
||||
return { skillId: "survive.flee" };
|
||||
}
|
||||
if (hp <= 5) {
|
||||
return { skillId: "survive.flee" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function foodDetect(s) {
|
||||
if (!s?.connected) return true;
|
||||
if ((s.food ?? 20) >= 18 && countAny(s.inventory, FOOD_ITEMS) >= 1) return true;
|
||||
return countAny(s.inventory, FOOD_ITEMS) >= 6;
|
||||
}
|
||||
|
||||
function foodPursue(s) {
|
||||
if ((s.food ?? 20) < 16 && s.hasFood) {
|
||||
return { skillId: "survive.eat" };
|
||||
}
|
||||
return { skillId: "survive.acquire-food" };
|
||||
}
|
||||
|
||||
function toolsWoodDetect(s) {
|
||||
const inv = s?.inventory;
|
||||
if (!inv) return false;
|
||||
return hasAny(inv, PICKAXE_WOOD) && hasAny(inv, AXE_WOOD) && hasAny(inv, SWORD_WOOD);
|
||||
}
|
||||
|
||||
function toolsWoodPursue(s) {
|
||||
const inv = s.inventory ?? {};
|
||||
const planks = countPlanks(inv);
|
||||
const logs = countLogs(inv);
|
||||
const sticks = inv.stick ?? 0;
|
||||
const hasWb = (inv.crafting_table ?? 0) > 0
|
||||
|| (s.nearbyBlocks?.craftingTable ?? 0) > 0;
|
||||
|
||||
if (logs < 2 && planks < 4 && !hasWb) {
|
||||
return { skillId: "gather.logs" };
|
||||
}
|
||||
if (planks < 4) {
|
||||
return { skillId: "craft.planks" };
|
||||
}
|
||||
if (sticks < 2) {
|
||||
return { skillId: "craft.sticks" };
|
||||
}
|
||||
if (!hasAny(inv, PICKAXE_WOOD)) {
|
||||
return { skillId: "craft.wooden-pickaxe" };
|
||||
}
|
||||
if (!hasAny(inv, AXE_WOOD)) {
|
||||
return { skillId: "craft.wooden-axe" };
|
||||
}
|
||||
if (!hasAny(inv, SWORD_WOOD)) {
|
||||
return { skillId: "craft.wooden-sword" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shelterBasicDetect(s) {
|
||||
const inv = s?.inventory ?? {};
|
||||
const bedPlaced = (s.nearbyBlocks?.beds ?? 0) > 0;
|
||||
return bedPlaced || hasAny(inv, BED_ITEMS);
|
||||
}
|
||||
|
||||
function shelterBasicPursue(s) {
|
||||
const inv = s.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: "gather.logs" };
|
||||
}
|
||||
// Have bed but no shelter — pick a base and build.
|
||||
const blocksForShelter = countPlanks(inv) + (inv.cobblestone ?? 0) + (inv.dirt ?? 0);
|
||||
if (blocksForShelter < 12) {
|
||||
return { skillId: "gather.stone" };
|
||||
}
|
||||
return { skillId: "village.build-shelter" };
|
||||
}
|
||||
|
||||
function toolsStoneDetect(s) {
|
||||
const inv = s?.inventory;
|
||||
if (!inv) return false;
|
||||
return hasAny(inv, PICKAXE_STONE) && hasAny(inv, AXE_STONE) && hasAny(inv, SWORD_STONE);
|
||||
}
|
||||
|
||||
function toolsStonePursue(s) {
|
||||
const inv = s.inventory ?? {};
|
||||
const cobble = inv.cobblestone ?? 0;
|
||||
const sticks = inv.stick ?? 0;
|
||||
if (cobble < 4) {
|
||||
return { skillId: "gather.stone" };
|
||||
}
|
||||
if (sticks < 2) {
|
||||
return { skillId: "craft.sticks" };
|
||||
}
|
||||
if (!hasAny(inv, PICKAXE_STONE)) {
|
||||
return { skillId: "craft.stone-pickaxe" };
|
||||
}
|
||||
if (!hasAny(inv, AXE_STONE)) {
|
||||
return { skillId: "craft.stone-axe" };
|
||||
}
|
||||
if (!hasAny(inv, SWORD_STONE)) {
|
||||
return { skillId: "craft.stone-sword" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function armorBasicDetect(s) {
|
||||
const equip = s?.equipment ?? {};
|
||||
if (equip.torso && ARMOR_CHEST_ANY.includes(equip.torso)) return true;
|
||||
return hasAny(s.inventory, ARMOR_CHEST_ANY);
|
||||
}
|
||||
|
||||
function armorBasicPursue(_s) {
|
||||
// No armor crafting skills registered yet (v0.3.x roadmap). Don't
|
||||
// stall the ladder — let later needs drive activity.
|
||||
return null;
|
||||
}
|
||||
|
||||
function foodSecurityDetect(s) {
|
||||
return countAny(s?.inventory, FOOD_ITEMS) >= 16;
|
||||
}
|
||||
|
||||
function foodSecurityPursue(s) {
|
||||
if ((s.inventory?.wheat_seeds ?? 0) > 0 && (s.nearbyBlocks?.crops ?? 0) > 0) {
|
||||
return { skillId: "farm.wheat" };
|
||||
}
|
||||
return { skillId: "survive.acquire-food" };
|
||||
}
|
||||
|
||||
function toolsIronDetect(s) {
|
||||
const inv = s?.inventory;
|
||||
if (!inv) return false;
|
||||
return hasAny(inv, PICKAXE_IRON) && hasAny(inv, AXE_IRON) && hasAny(inv, SWORD_IRON);
|
||||
}
|
||||
|
||||
function toolsIronPursue(_s) {
|
||||
// No iron-tool craft skills registered yet. Direct the bot to keep
|
||||
// mining — the registry will gain craft.iron-* in a later iteration.
|
||||
return { skillId: "gather.stone" };
|
||||
}
|
||||
|
||||
function armorIronDetect(s) {
|
||||
const equip = s?.equipment ?? {};
|
||||
if (equip.torso === "iron_chestplate") return true;
|
||||
return hasAny(s.inventory, ARMOR_IRON_CHEST);
|
||||
}
|
||||
|
||||
function armorIronPursue(_s) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function villageSeedPursue(s) {
|
||||
const inv = s.inventory ?? {};
|
||||
if ((inv.chest ?? 0) === 0 && countPlanks(inv) >= 8) {
|
||||
return { skillId: "craft.chest" };
|
||||
}
|
||||
if ((inv.chest ?? 0) > 0) {
|
||||
return { skillId: "village.deposit-surplus" };
|
||||
}
|
||||
return { skillId: "village.build-shelter" };
|
||||
}
|
||||
|
||||
function villageFullDetect(_s) {
|
||||
// Always false — it's the global goal.
|
||||
return false;
|
||||
}
|
||||
|
||||
function villageFullPursue(_s) {
|
||||
// Let the curriculum tackle it (fallback chain).
|
||||
return null;
|
||||
}
|
||||
|
||||
export const NEEDS = Object.freeze([
|
||||
{ id: "alive", level: 0, title: "Остаться живым", detect: aliveDetect, pursue: alivePursue },
|
||||
{ id: "food", level: 1, title: "Найти еду", detect: foodDetect, pursue: foodPursue },
|
||||
{ id: "tools_wood", level: 2, title: "Деревянные орудия", detect: toolsWoodDetect, pursue: toolsWoodPursue },
|
||||
{ id: "shelter_basic", level: 3, title: "Простой шелтер", detect: shelterBasicDetect, pursue: shelterBasicPursue },
|
||||
{ id: "tools_stone", level: 4, title: "Каменные орудия", detect: toolsStoneDetect, pursue: toolsStonePursue },
|
||||
{ id: "armor_basic", level: 5, title: "Базовая броня", detect: armorBasicDetect, pursue: armorBasicPursue },
|
||||
{ id: "food_security", level: 6, title: "Запас еды", detect: foodSecurityDetect, pursue: foodSecurityPursue },
|
||||
{ id: "tools_iron", level: 7, title: "Железные орудия", detect: toolsIronDetect, pursue: toolsIronPursue },
|
||||
{ id: "armor_iron", level: 8, title: "Железная броня", detect: armorIronDetect, pursue: armorIronPursue },
|
||||
{ id: "village_seed", level: 9, title: "Зачаток деревни", detect: villageSeedDetect, pursue: villageSeedPursue },
|
||||
{ id: "village_full", level: 10, title: "Полная деревня", detect: villageFullDetect, pursue: villageFullPursue },
|
||||
]);
|
||||
|
||||
export function getNeed(id) {
|
||||
return NEEDS.find((n) => n.id === id) ?? null;
|
||||
}
|
||||
|
||||
// Test exports
|
||||
export const __testing = {
|
||||
hasAny, countAny, countLogs, countPlanks,
|
||||
FOOD_ITEMS, BED_ITEMS, ARMOR_CHEST_ANY,
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { NEEDS, getNeed, __testing } from "./needs.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: { lavaNearby: false, inFluid: false, footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
|
||||
isDay: true,
|
||||
hostileCount: 0,
|
||||
closestHostile: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("ladder: 11 levels in order, ids unique", () => {
|
||||
assert.equal(NEEDS.length, 11);
|
||||
for (let i = 0; i < NEEDS.length; i++) {
|
||||
assert.equal(NEEDS[i].level, i);
|
||||
}
|
||||
const ids = NEEDS.map((n) => n.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
|
||||
test("getNeed: lookup by id", () => {
|
||||
assert.equal(getNeed("tools_wood").level, 2);
|
||||
assert.equal(getNeed("doesnt-exist"), null);
|
||||
});
|
||||
|
||||
test("L0 alive: full HP and food → satisfied", () => {
|
||||
const n = getNeed("alive");
|
||||
assert.equal(n.detect(snap()), true);
|
||||
assert.equal(n.pursue(snap()), null);
|
||||
});
|
||||
|
||||
test("L0 alive: low HP with close hostile → flee", () => {
|
||||
const n = getNeed("alive");
|
||||
const s = snap({ health: 4, closestHostile: { name: "zombie", distance: 3 } });
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "survive.flee");
|
||||
});
|
||||
|
||||
test("L0 alive: zero food and have food → eat", () => {
|
||||
const n = getNeed("alive");
|
||||
const s = snap({ food: 0, hasFood: true, inventory: { bread: 3 } });
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "survive.eat");
|
||||
});
|
||||
|
||||
test("L0 alive: zero food and no food → acquire", () => {
|
||||
const n = getNeed("alive");
|
||||
const s = snap({ food: 0, hasFood: false });
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "survive.acquire-food");
|
||||
});
|
||||
|
||||
test("L1 food: 6+ food items → satisfied", () => {
|
||||
const n = getNeed("food");
|
||||
assert.equal(n.detect(snap({ food: 10, inventory: { bread: 6 } })), true);
|
||||
assert.equal(n.detect(snap({ food: 10, inventory: { bread: 3 } })), false);
|
||||
});
|
||||
|
||||
test("L1 food: full saturation + any food → satisfied (no panic gathering)", () => {
|
||||
const n = getNeed("food");
|
||||
// food=20 means belly is full; 3 bread is enough until we get hungry again
|
||||
assert.equal(n.detect(snap({ food: 20, inventory: { bread: 3 } })), true);
|
||||
});
|
||||
|
||||
test("L2 tools_wood: starts with no logs → gather.logs", () => {
|
||||
const n = getNeed("tools_wood");
|
||||
const s = snap();
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "gather.logs");
|
||||
});
|
||||
|
||||
test("L2 tools_wood: has logs but no planks → craft.planks", () => {
|
||||
const n = getNeed("tools_wood");
|
||||
const s = snap({ inventory: { oak_log: 3 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.planks");
|
||||
});
|
||||
|
||||
test("L2 tools_wood: progression to pickaxe → axe → sword", () => {
|
||||
const n = getNeed("tools_wood");
|
||||
// has planks + sticks but no pickaxe
|
||||
let s = snap({ inventory: { oak_planks: 8, stick: 4 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.wooden-pickaxe");
|
||||
// has pickaxe but no axe
|
||||
s = snap({ inventory: { oak_planks: 8, stick: 4, wooden_pickaxe: 1 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.wooden-axe");
|
||||
// pickaxe + axe but no sword
|
||||
s = snap({ inventory: { oak_planks: 8, stick: 4, wooden_pickaxe: 1, wooden_axe: 1 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.wooden-sword");
|
||||
// all three
|
||||
s = snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 } });
|
||||
assert.equal(n.detect(s), true);
|
||||
});
|
||||
|
||||
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({ inventory: { red_bed: 1 } })), true);
|
||||
assert.equal(n.detect(snap()), false);
|
||||
});
|
||||
|
||||
test("L3 shelter_basic: no wool → gather.wool", () => {
|
||||
const n = getNeed("shelter_basic");
|
||||
const s = snap({ inventory: { oak_planks: 3 } });
|
||||
assert.equal(n.pursue(s).skillId, "gather.wool");
|
||||
});
|
||||
|
||||
test("L3 shelter_basic: enough wool + planks → craft.bed", () => {
|
||||
const n = getNeed("shelter_basic");
|
||||
const s = snap({ inventory: { white_wool: 3, oak_planks: 3 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.bed");
|
||||
});
|
||||
|
||||
test("L4 tools_stone: needs cobblestone first", () => {
|
||||
const n = getNeed("tools_stone");
|
||||
const s = snap({ inventory: { wooden_pickaxe: 1 } });
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "gather.stone");
|
||||
});
|
||||
|
||||
test("L4 tools_stone: cobble + sticks → craft.stone-pickaxe", () => {
|
||||
const n = getNeed("tools_stone");
|
||||
const s = snap({ inventory: { cobblestone: 6, stick: 4 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.stone-pickaxe");
|
||||
});
|
||||
|
||||
test("L5 armor_basic: torso equipped → satisfied", () => {
|
||||
const n = getNeed("armor_basic");
|
||||
const s = snap({ equipment: { torso: "leather_chestplate" } });
|
||||
assert.equal(n.detect(s), true);
|
||||
});
|
||||
|
||||
test("L5 armor_basic: no craft skill yet → pursue returns null", () => {
|
||||
const n = getNeed("armor_basic");
|
||||
const s = snap();
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s), null);
|
||||
});
|
||||
|
||||
test("L6 food_security: ≥16 food → satisfied", () => {
|
||||
const n = getNeed("food_security");
|
||||
assert.equal(n.detect(snap({ inventory: { bread: 16 } })), true);
|
||||
assert.equal(n.detect(snap({ inventory: { bread: 10 } })), false);
|
||||
});
|
||||
|
||||
test("L7 tools_iron: always pursues gather.stone (no craft.iron-* yet)", () => {
|
||||
const n = getNeed("tools_iron");
|
||||
const s = snap();
|
||||
assert.equal(n.detect(s), false);
|
||||
assert.equal(n.pursue(s).skillId, "gather.stone");
|
||||
});
|
||||
|
||||
test("L8 armor_iron: iron_chestplate equipped → satisfied", () => {
|
||||
const n = getNeed("armor_iron");
|
||||
assert.equal(n.detect(snap({ equipment: { torso: "iron_chestplate" } })), true);
|
||||
assert.equal(n.detect(snap({ equipment: { torso: "leather_chestplate" } })), false);
|
||||
});
|
||||
|
||||
test("L9 village_seed: bed + storage nearby → satisfied", () => {
|
||||
const n = getNeed("village_seed");
|
||||
assert.equal(n.detect(snap({ nearbyBlocks: { beds: 1, storage: 1 } })), true);
|
||||
});
|
||||
|
||||
test("L9 village_seed: no chest → craft.chest if enough planks", () => {
|
||||
const n = getNeed("village_seed");
|
||||
const s = snap({ inventory: { oak_planks: 10 } });
|
||||
assert.equal(n.pursue(s).skillId, "craft.chest");
|
||||
});
|
||||
|
||||
test("L10 village_full: never satisfied (global goal)", () => {
|
||||
const n = getNeed("village_full");
|
||||
assert.equal(n.detect(snap()), false);
|
||||
assert.equal(n.pursue(snap()), null);
|
||||
});
|
||||
|
||||
test("helpers: hasAny / countAny work over inventory", () => {
|
||||
const { hasAny, countAny } = __testing;
|
||||
const inv = { bread: 3, cooked_beef: 1 };
|
||||
assert.equal(hasAny(inv, ["bread", "apple"]), true);
|
||||
assert.equal(hasAny(inv, ["apple"]), false);
|
||||
assert.equal(countAny(inv, ["bread", "cooked_beef"]), 4);
|
||||
});
|
||||
|
||||
test("helpers: countLogs / countPlanks sum across variants", () => {
|
||||
const { countLogs, countPlanks } = __testing;
|
||||
assert.equal(countLogs({ oak_log: 3, birch_log: 2, dirt: 5 }), 5);
|
||||
assert.equal(countPlanks({ oak_planks: 4, birch_planks: 2 }), 6);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Manifesto state: cached "active need" for the current tick.
|
||||
//
|
||||
// Each reflex pass calls pickActiveNeed(snapshot) — it walks the
|
||||
// needs ladder from level 0 upward and returns the FIRST need whose
|
||||
// detect() is false AND whose pursue() returns a non-null skill id.
|
||||
// Needs whose pursue() returns null (e.g. armour while we lack craft
|
||||
// skills) are recorded as "blocked at this level" but the ladder
|
||||
// continues — that way the bot still makes progress on lower-priority
|
||||
// concerns instead of stalling.
|
||||
|
||||
import { NEEDS, getNeed } from "./needs.js";
|
||||
import { isRegistered } from "../skill-registry.js";
|
||||
import { info } from "../log.js";
|
||||
|
||||
const CACHE_TTL_MS = 3_000;
|
||||
|
||||
let _cache = null;
|
||||
let _lastNeedId = null;
|
||||
|
||||
export function _resetForTest() {
|
||||
_cache = null;
|
||||
_lastNeedId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* pickActiveNeed(snapshot) →
|
||||
* {
|
||||
* need: { id, level, title },
|
||||
* skillId: string, // dispatch this skill
|
||||
* args: object | undefined,
|
||||
* blockedNeeds: Array<{id, level}> // needs above this one whose pursue=null
|
||||
* } | null
|
||||
*
|
||||
* Returns null only when *every* need is satisfied (i.e. village_full
|
||||
* is detected, which is never true in practice — global goal). In
|
||||
* that case callers should fall back to the curriculum.
|
||||
*/
|
||||
export function pickActiveNeed(snapshot) {
|
||||
if (!snapshot?.connected) return null;
|
||||
const now = Date.now();
|
||||
if (_cache && _cache.snapshot === snapshot && now - _cache.ts < CACHE_TTL_MS) {
|
||||
return _cache.result;
|
||||
}
|
||||
const blocked = [];
|
||||
let chosen = null;
|
||||
for (const need of NEEDS) {
|
||||
let satisfied;
|
||||
try {
|
||||
satisfied = !!need.detect(snapshot);
|
||||
} catch (e) {
|
||||
info("manifesto", `need ${need.id}.detect threw: ${e?.message ?? e}`);
|
||||
satisfied = true;
|
||||
}
|
||||
if (satisfied) continue;
|
||||
let plan;
|
||||
try {
|
||||
plan = need.pursue(snapshot);
|
||||
} catch (e) {
|
||||
info("manifesto", `need ${need.id}.pursue threw: ${e?.message ?? e}`);
|
||||
plan = null;
|
||||
}
|
||||
if (!plan || !plan.skillId) {
|
||||
blocked.push({ id: need.id, level: need.level });
|
||||
continue;
|
||||
}
|
||||
if (!isRegistered(plan.skillId)) {
|
||||
info("manifesto", `need ${need.id}: pursue suggested unknown skill ${plan.skillId}; skipping`);
|
||||
blocked.push({ id: need.id, level: need.level });
|
||||
continue;
|
||||
}
|
||||
chosen = { need: { id: need.id, level: need.level, title: need.title }, skillId: plan.skillId, args: plan.args, blockedNeeds: blocked };
|
||||
break;
|
||||
}
|
||||
if (chosen) {
|
||||
if (_lastNeedId !== chosen.need.id) {
|
||||
info("manifesto", `active need: L${chosen.need.level} ${chosen.need.id} → ${chosen.skillId}`);
|
||||
_lastNeedId = chosen.need.id;
|
||||
}
|
||||
}
|
||||
_cache = { snapshot, ts: now, result: chosen };
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function describeActiveNeed(snapshot) {
|
||||
const a = pickActiveNeed(snapshot);
|
||||
if (!a) return null;
|
||||
return `L${a.need.level} ${a.need.id} → ${a.skillId}`;
|
||||
}
|
||||
|
||||
// Re-export ladder for callers that want to enumerate.
|
||||
export { NEEDS, getNeed };
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { pickActiveNeed, describeActiveNeed, _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,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("pickActiveNeed: disconnected → null", () => {
|
||||
_resetForTest();
|
||||
assert.equal(pickActiveNeed({ connected: false }), null);
|
||||
assert.equal(pickActiveNeed(null), null);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: hp ok, no food in inventory → L1 food (acquire)", () => {
|
||||
_resetForTest();
|
||||
const a = pickActiveNeed(snap());
|
||||
assert.equal(a.need.id, "food");
|
||||
assert.equal(a.skillId, "survive.acquire-food");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: food covered → L2 tools_wood (gather logs)", () => {
|
||||
_resetForTest();
|
||||
const a = pickActiveNeed(snap({ inventory: { bread: 8 } }));
|
||||
assert.equal(a.need.id, "tools_wood");
|
||||
assert.equal(a.skillId, "gather.logs");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: tools wood done → L3 shelter (gather wool)", () => {
|
||||
_resetForTest();
|
||||
const a = pickActiveNeed(snap({
|
||||
inventory: {
|
||||
bread: 8,
|
||||
wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1,
|
||||
},
|
||||
}));
|
||||
assert.equal(a.need.id, "shelter_basic");
|
||||
// no wool, no bed → gather.wool
|
||||
assert.equal(a.skillId, "gather.wool");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: shelter done → L4 tools_stone", () => {
|
||||
_resetForTest();
|
||||
const a = pickActiveNeed(snap({
|
||||
nearbyBlocks: { beds: 1 },
|
||||
inventory: {
|
||||
bread: 8,
|
||||
wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1,
|
||||
},
|
||||
}));
|
||||
assert.equal(a.need.id, "tools_stone");
|
||||
assert.equal(a.skillId, "gather.stone");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: armor pursue=null → ladder skips to food_security", () => {
|
||||
_resetForTest();
|
||||
// Everything up through tools_stone satisfied, no armor (pursue=null).
|
||||
// Should advance to food_security, not stall.
|
||||
const a = pickActiveNeed(snap({
|
||||
nearbyBlocks: { beds: 1 },
|
||||
inventory: {
|
||||
bread: 8,
|
||||
wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1,
|
||||
stone_pickaxe: 1, stone_axe: 1, stone_sword: 1,
|
||||
},
|
||||
}));
|
||||
assert.equal(a.need.id, "food_security");
|
||||
assert.ok(a.blockedNeeds.some((b) => b.id === "armor_basic"), "armor_basic recorded as blocked");
|
||||
});
|
||||
|
||||
test("pickActiveNeed: hostile imminent + low HP → L0 takes over", () => {
|
||||
_resetForTest();
|
||||
const a = pickActiveNeed(snap({
|
||||
health: 6,
|
||||
closestHostile: { name: "creeper", distance: 3 },
|
||||
inventory: { bread: 8, wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 },
|
||||
nearbyBlocks: { beds: 1 },
|
||||
}));
|
||||
assert.equal(a.need.id, "alive");
|
||||
assert.equal(a.skillId, "survive.flee");
|
||||
});
|
||||
|
||||
test("describeActiveNeed: returns 'L<n> <id> → <skill>'", () => {
|
||||
_resetForTest();
|
||||
const s = describeActiveNeed(snap());
|
||||
assert.match(s, /^L1 food → /);
|
||||
});
|
||||
|
||||
test("pickActiveNeed: caches within TTL — same snapshot ref returns same result", () => {
|
||||
_resetForTest();
|
||||
const s = snap();
|
||||
const a = pickActiveNeed(s);
|
||||
const b = pickActiveNeed(s);
|
||||
assert.equal(a, b, "second call returns cached object");
|
||||
});
|
||||
+57
-6
@@ -26,6 +26,9 @@ import {
|
||||
} from "./actions.js";
|
||||
import { runSkill, getSkill } from "./skills/index.js";
|
||||
import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from "./coach/advice.js";
|
||||
import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js";
|
||||
import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js";
|
||||
import { pickActiveNeed } from "./manifesto/state.js";
|
||||
import { situationHash } from "./scenario-memory.js";
|
||||
import { tickModes } from "./modes.js";
|
||||
|
||||
@@ -438,6 +441,22 @@ function curriculumReflex(ctx) {
|
||||
const plan = s.curriculum?.plan;
|
||||
const wanderHintUntil = ctx.skillBackoff?.["__wander_hint__"] ?? 0;
|
||||
const wantWander = wanderHintUntil && Date.now() < wanderHintUntil;
|
||||
|
||||
// v0.3.0-rc.2 — manifesto layer. Walk the L0-L10 needs ladder; the
|
||||
// lowest unsatisfied need dictates the planned skill. The curriculum
|
||||
// plan is used as a fallback when the manifesto has nothing concrete
|
||||
// (e.g. armour pursue=null, or village_full with no specific next
|
||||
// step). This is what makes the bot pursue tangible intermediate
|
||||
// goals (tools_wood → shelter → tools_stone → ...) instead of
|
||||
// wandering in the same quadrant.
|
||||
//
|
||||
// Tests can pass ctx.disableManifesto=true to exercise the curriculum
|
||||
// branch in isolation without having to construct a full snapshot.
|
||||
const activeNeed = ctx.disableManifesto ? null : pickActiveNeed(s);
|
||||
if (activeNeed) {
|
||||
ctx.activeNeed = activeNeed;
|
||||
}
|
||||
const manifestoSkillId = activeNeed?.skillId ?? null;
|
||||
const metricRecovery = metricRecoverySkill(ctx, plan?.skillId);
|
||||
if (metricRecovery) {
|
||||
ctx.lastCurriculumAt = Date.now();
|
||||
@@ -476,7 +495,7 @@ function curriculumReflex(ctx) {
|
||||
// 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 || wantWander) {
|
||||
if ((!plan?.skillId && !manifestoSkillId) || 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
|
||||
@@ -511,15 +530,38 @@ function curriculumReflex(ctx) {
|
||||
return { action: "dispatched", kind: "curriculum-wander", label: "wander" };
|
||||
}
|
||||
|
||||
const skillId = plan.skillId;
|
||||
// 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";
|
||||
|
||||
// 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.
|
||||
// This is the closing of the awareness → LLM → action loop.
|
||||
let appliedRecommendationId = null;
|
||||
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);
|
||||
}
|
||||
// Always fire-and-forget another advise() if triggers fire — the
|
||||
// result lands on a future tick. tickAdvisor handles its own
|
||||
// cooldown / in-flight checks so this is safe to call every tick.
|
||||
tickAdvisor(ctx, { plannedSkillId: skillId });
|
||||
}
|
||||
|
||||
const skill = getSkill(skillId);
|
||||
if (!skill) {
|
||||
// Curriculum suggested a skill that isn't registered yet — fall back
|
||||
// Suggested a skill that isn't registered yet — fall back
|
||||
// to wander rather than spinning. This is the right behaviour for
|
||||
// future milestones we haven't wired (e.g. shelter blueprints).
|
||||
ctx.lastCurriculumAt = Date.now();
|
||||
ctx.dispatch(() => wander(ctx.bot, 16), "wander", {});
|
||||
return { action: "dispatched", kind: "curriculum-wander", label: `wander (no skill ${skillId})` };
|
||||
return { action: "dispatched", kind: "curriculum-wander", label: `wander (no skill ${skillId}; source ${skillSource})` };
|
||||
}
|
||||
|
||||
// Per-skill backoff: if this exact skill failed with a non-recoverable
|
||||
@@ -558,10 +600,19 @@ function curriculumReflex(ctx) {
|
||||
}
|
||||
|
||||
ctx.lastCurriculumAt = Date.now();
|
||||
ctx.dispatch(() => runSkill(dispatchSkillId, ctx), dispatchSkillId, {
|
||||
const dispatchArgs = (manifestoSkillId && manifestoSkillId === dispatchSkillId)
|
||||
? (activeNeed.args ?? {})
|
||||
: {};
|
||||
ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, {
|
||||
onComplete: (res) => {
|
||||
ctx.skillBackoff = ctx.skillBackoff ?? {};
|
||||
if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok });
|
||||
if (appliedRecommendationId) {
|
||||
markRecommendationOutcome(appliedRecommendationId, {
|
||||
ok: !!res?.ok,
|
||||
code: res?.code ?? null,
|
||||
});
|
||||
}
|
||||
if (res?.recovery?.hint === "wander") {
|
||||
// Same fix the old autonomous reflex applied for "no reachable
|
||||
// log" — switch to exploration for a minute.
|
||||
@@ -582,7 +633,7 @@ function curriculumReflex(ctx) {
|
||||
}
|
||||
},
|
||||
});
|
||||
return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId };
|
||||
return { action: "dispatched", kind: "curriculum-skill", label: dispatchSkillId, source: skillSource };
|
||||
}
|
||||
|
||||
// ---- idle ------------------------------------------------------------------
|
||||
|
||||
@@ -46,6 +46,11 @@ function makeCtx({
|
||||
lastSleepAttemptAt = 0,
|
||||
lastCurriculumAt = 0,
|
||||
metrics,
|
||||
disableManifesto = true, // curriculum branch tests don't construct
|
||||
// full snapshots; manifesto is exercised by
|
||||
// runtime/manifesto/state.test.js separately.
|
||||
disableAdvisor = true, // advisor-trigger fires real async LLM calls,
|
||||
// tested directly in advisor-trigger.test.js.
|
||||
} = {}) {
|
||||
const dispatches = [];
|
||||
const ctx = {
|
||||
@@ -58,6 +63,8 @@ function makeCtx({
|
||||
lastCurriculumAt,
|
||||
skillBackoff,
|
||||
metrics,
|
||||
disableManifesto,
|
||||
disableAdvisor,
|
||||
dispatch(fn, label, opts = {}) {
|
||||
dispatches.push({ fn, label, opts });
|
||||
},
|
||||
@@ -233,6 +240,55 @@ 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)", () => {
|
||||
const { ctx, dispatches } = makeCtx({
|
||||
disableManifesto: false,
|
||||
snapshot: {
|
||||
connected: true,
|
||||
health: 20,
|
||||
food: 12,
|
||||
hasFood: false,
|
||||
inventory: {}, // no food, no tools
|
||||
equipment: {},
|
||||
nearbyBlocks: {},
|
||||
hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
|
||||
isDay: true,
|
||||
curriculum: { plan: { skillId: "gather.logs" } },
|
||||
},
|
||||
});
|
||||
const out = runTick(ctx);
|
||||
assert.equal(out.reflex, "curriculum");
|
||||
assert.equal(dispatches[0].label, "survive.acquire-food", "manifesto L1 food took over");
|
||||
assert.equal(ctx.activeNeed?.need?.id, "food");
|
||||
});
|
||||
|
||||
test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => {
|
||||
const { ctx, dispatches } = makeCtx({
|
||||
disableManifesto: false,
|
||||
snapshot: {
|
||||
connected: true,
|
||||
health: 20,
|
||||
food: 20,
|
||||
hasFood: true,
|
||||
inventory: {
|
||||
bread: 8,
|
||||
wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1,
|
||||
white_bed: 1,
|
||||
},
|
||||
equipment: {},
|
||||
nearbyBlocks: { beds: 1 },
|
||||
hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" },
|
||||
isDay: true,
|
||||
curriculum: { plan: { skillId: "gather.logs" } },
|
||||
},
|
||||
});
|
||||
const out = runTick(ctx);
|
||||
assert.equal(out.reflex, "curriculum");
|
||||
// L4 tools_stone unmet → gather.stone takes precedence even if curriculum says logs
|
||||
assert.equal(dispatches[0].label, "gather.stone");
|
||||
assert.equal(ctx.activeNeed?.need?.id, "tools_stone");
|
||||
});
|
||||
|
||||
test("curriculum falls back to wander when no plan", () => {
|
||||
const { ctx, dispatches } = makeCtx({
|
||||
snapshot: {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Single source of truth for "what skill ids are real" — exported separately
|
||||
// from skills/index.js so coach/advice.js, coach/postmortem.js, coach/reflect.js,
|
||||
// and coach/fast-advisor.js can all consult the SAME live registry without
|
||||
// circular imports through runSkill.
|
||||
//
|
||||
// Why this exists: in v0.2.x Pi (the LLM coach) routinely fabricated
|
||||
// skill ids that never existed — "relocate.surface", "choose.safe.surface",
|
||||
// "survive.shelter", "gather.visible_log". Of 47 Pi-extracted lessons,
|
||||
// 0 were ever applied because normalisePreferSkill() couldn't map them
|
||||
// to anything real. The fix is two-pronged: (a) hand Pi the real registry
|
||||
// in its system prompt so it doesn't have to guess; (b) reject anything
|
||||
// not in the registry at the consult() boundary.
|
||||
|
||||
import { listSkills } from "./skills/index.js";
|
||||
|
||||
let _cache = null;
|
||||
|
||||
function rebuild() {
|
||||
const all = listSkills();
|
||||
const byId = new Map();
|
||||
const byNamespace = new Map();
|
||||
for (const s of all) {
|
||||
byId.set(s.id, s);
|
||||
const ns = s.id.split(".")[0] || "misc";
|
||||
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
|
||||
byNamespace.get(ns).push(s);
|
||||
}
|
||||
_cache = { all, byId, byNamespace };
|
||||
return _cache;
|
||||
}
|
||||
|
||||
function get() { return _cache ?? rebuild(); }
|
||||
|
||||
export function listSkillIds() {
|
||||
return Array.from(get().byId.keys());
|
||||
}
|
||||
|
||||
export function isRegistered(id) {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
return get().byId.has(id);
|
||||
}
|
||||
|
||||
export function describeSkill(id) {
|
||||
return get().byId.get(id) ?? null;
|
||||
}
|
||||
|
||||
// Human-readable block to drop into LLM system prompts. Groups by
|
||||
// namespace, lists "id — title (timeoutMs)". Capped at ~2KB to stay
|
||||
// well within the model's instruction window.
|
||||
export function skillRegistryPrompt({ limit = 2000 } = {}) {
|
||||
const { byNamespace } = get();
|
||||
const namespaces = Array.from(byNamespace.keys()).sort();
|
||||
const lines = ["Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill):"];
|
||||
for (const ns of namespaces) {
|
||||
const skills = byNamespace.get(ns).sort((a, b) => a.id.localeCompare(b.id));
|
||||
lines.push(` ${ns}:`);
|
||||
for (const s of skills) {
|
||||
lines.push(` - ${s.id} — ${s.title ?? s.id}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("If no listed skill fits, set the field to null. NEVER invent new ids.");
|
||||
const text = lines.join("\n");
|
||||
return text.length > limit ? text.slice(0, limit - 4) + "\n..." : text;
|
||||
}
|
||||
|
||||
// For tests / hot-reload scenarios.
|
||||
export function _resetForTest() { _cache = null; }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
listSkillIds,
|
||||
isRegistered,
|
||||
describeSkill,
|
||||
skillRegistryPrompt,
|
||||
} from "./skill-registry.js";
|
||||
|
||||
test("registry: lists at least the known v0.2 skill set", () => {
|
||||
const ids = listSkillIds();
|
||||
assert.ok(ids.length >= 20, `expected 20+ skills, got ${ids.length}`);
|
||||
for (const must of [
|
||||
"gather.logs",
|
||||
"survive.eat",
|
||||
"survive.sleep",
|
||||
"survive.flee",
|
||||
"survive.pillar-up",
|
||||
"explore.far",
|
||||
"explore.wander",
|
||||
"recovery.tunnel-out",
|
||||
"village.choose-base",
|
||||
"village.build-shelter",
|
||||
]) {
|
||||
assert.ok(ids.includes(must), `registry missing ${must}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isRegistered: true for real, false for hallucinated", () => {
|
||||
assert.equal(isRegistered("survive.flee"), true);
|
||||
assert.equal(isRegistered("relocate.surface"), false);
|
||||
assert.equal(isRegistered("choose.safe.surface"), false);
|
||||
assert.equal(isRegistered("gather.visible_log"), false);
|
||||
assert.equal(isRegistered("survive.shelter"), false);
|
||||
assert.equal(isRegistered("tunnel-out"), false, "missing recovery. prefix");
|
||||
assert.equal(isRegistered(null), false);
|
||||
assert.equal(isRegistered(""), false);
|
||||
assert.equal(isRegistered(42), false);
|
||||
});
|
||||
|
||||
test("describeSkill: returns shape for known id", () => {
|
||||
const s = describeSkill("survive.pillar-up");
|
||||
assert.ok(s, "expected description");
|
||||
assert.equal(s.id, "survive.pillar-up");
|
||||
assert.ok(typeof s.timeoutMs === "number" && s.timeoutMs > 0);
|
||||
});
|
||||
|
||||
test("registryPrompt: contains the real ids grouped by namespace", () => {
|
||||
const txt = skillRegistryPrompt();
|
||||
assert.match(txt, /Valid skill ids/);
|
||||
assert.match(txt, /survive:/);
|
||||
assert.match(txt, /- survive\.flee/);
|
||||
assert.match(txt, /- recovery\.tunnel-out/);
|
||||
assert.match(txt, /NEVER invent/);
|
||||
// must NOT contain hallucinated ids
|
||||
assert.doesNotMatch(txt, /relocate\.surface/);
|
||||
assert.doesNotMatch(txt, /survive\.shelter[^-]/);
|
||||
});
|
||||
|
||||
test("registryPrompt: respects limit parameter", () => {
|
||||
const short = skillRegistryPrompt({ limit: 200 });
|
||||
assert.ok(short.length <= 200, `expected <=200, got ${short.length}`);
|
||||
assert.ok(short.endsWith("..."));
|
||||
});
|
||||
@@ -166,3 +166,63 @@ test("result missing code defaults to runner DONE on success", async () => {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("abortSignal: mid-execute abort surfaces code: preempted", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.preempt-midflight",
|
||||
timeoutMs: 5000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const runP = runSkill("test.preempt-midflight", { abortSignal: controller.signal });
|
||||
setTimeout(() => controller.abort(), 30);
|
||||
try {
|
||||
const res = await runP;
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.PREEMPTED);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("abortSignal: pre-aborted signal short-circuits to preempted", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.preempt-prearm",
|
||||
timeoutMs: 5000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
try {
|
||||
const res = await runSkill("test.preempt-prearm", { abortSignal: controller.signal });
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.PREEMPTED);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("abortSignal: not aborted → skill completes normally", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.preempt-clear",
|
||||
timeoutMs: 5000,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done" }),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const res = await runSkill("test.preempt-clear", { abortSignal: controller.signal });
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.code, "done");
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
+46
-2
@@ -118,6 +118,7 @@ export const RUNNER_CODES = Object.freeze({
|
||||
TIMEOUT: "timeout",
|
||||
THREW: "threw",
|
||||
VALIDATION_FAILED: "validation_failed",
|
||||
PREEMPTED: "preempted",
|
||||
DONE: "done",
|
||||
});
|
||||
|
||||
@@ -139,6 +140,42 @@ function withTimeout(promise, ms, label) {
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// v0.3.0-rc.3 — wrap execute() so that if ctx.abortSignal fires we
|
||||
// stop awaiting (and surface code: "preempted"). The skill itself
|
||||
// doesn't need to read the signal — the race below ensures runSkill
|
||||
// returns control to the reflex within one microtask of abort(). The
|
||||
// skill's own async work may continue in the background harmlessly,
|
||||
// because the next dispatch will overwrite any shared state.
|
||||
function raceWithAbort(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(Object.assign(new Error("preempted"), { _preempted: true }));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(Object.assign(new Error("preempted"), { _preempted: true }));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(v) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener?.("abort", onAbort);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener?.("abort", onAbort);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Drive one skill through its full lifecycle. The caller (typically reflex.js
|
||||
// or, eventually, a higher-level scheduler) decides when to invoke; runSkill
|
||||
// only owns the contract enforcement.
|
||||
@@ -172,12 +209,19 @@ export async function runSkill(id, ctx, args = {}) {
|
||||
const timeoutMs = skill.timeoutMs ?? 30_000;
|
||||
let raw;
|
||||
try {
|
||||
raw = await withTimeout(skill.execute(ctx, args), timeoutMs, `skill(${id})`);
|
||||
raw = await withTimeout(
|
||||
raceWithAbort(skill.execute(ctx, args), ctx?.abortSignal),
|
||||
timeoutMs,
|
||||
`skill(${id})`,
|
||||
);
|
||||
} catch (e) {
|
||||
const isTimeout = /timed out after/.test(e.message);
|
||||
const isPreempted = e?._preempted === true;
|
||||
const result = {
|
||||
ok: false,
|
||||
code: isTimeout ? RUNNER_CODES.TIMEOUT : RUNNER_CODES.THREW,
|
||||
code: isPreempted
|
||||
? RUNNER_CODES.PREEMPTED
|
||||
: isTimeout ? RUNNER_CODES.TIMEOUT : RUNNER_CODES.THREW,
|
||||
detail: e.message,
|
||||
worldDelta: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Smoke test for the TIMEWEB_* fast-LLM env vars.
|
||||
//
|
||||
// Loads .env, asks the model a tiny structured question, prints
|
||||
// {ok, latency, code, first 200 chars of reply}. No bot state is
|
||||
// touched — this is purely a connectivity check.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-timeweb.js
|
||||
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
loadDotenv();
|
||||
|
||||
import { complete, isAvailable, getConfig } from "../runtime/llm/provider.js";
|
||||
import { advise, getUsageSnapshot, _resetForTest as resetAdvisor } from "../runtime/coach/fast-advisor.js";
|
||||
import { tickAdvisor, consumeFreshRecommendation, _resetForTest as resetTrigger } from "../runtime/coach/advisor-trigger.js";
|
||||
|
||||
function redact(key) {
|
||||
if (!key) return "(unset)";
|
||||
if (key.length < 12) return "(set, short)";
|
||||
return `${key.slice(0, 6)}…${key.slice(-4)} (${key.length} chars)`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== TimeWeb / fast-advisor smoke test ===");
|
||||
const cfg = getConfig();
|
||||
console.log(`BASE_URL: ${cfg.baseUrl || "(unset)"}`);
|
||||
console.log(`API_KEY: ${redact(cfg.apiKey)}`);
|
||||
console.log(`MODEL: ${cfg.model || "(unset)"}`);
|
||||
console.log(`TIMEOUT: ${cfg.timeoutMs}ms`);
|
||||
console.log(`isAvailable: ${isAvailable()}`);
|
||||
console.log("");
|
||||
|
||||
if (!isAvailable()) {
|
||||
console.error("ERROR: TIMEWEB_API_KEY not set in .env — aborting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("→ probe 1: plain prompt, no JSON mode");
|
||||
const r1 = await complete({
|
||||
system: "Reply in 5 words or less.",
|
||||
user: "Say 'pepa hears you'.",
|
||||
json: false,
|
||||
});
|
||||
logResult(r1);
|
||||
|
||||
console.log("");
|
||||
console.log("→ probe 2: JSON mode with a tiny structured request");
|
||||
const r2 = await complete({
|
||||
system: "Reply with strict JSON only.",
|
||||
user: 'Return {"alive": true, "name": "pepa"}',
|
||||
json: true,
|
||||
});
|
||||
logResult(r2);
|
||||
|
||||
console.log("");
|
||||
console.log("→ probe 3: full fast-advisor stack (registry injection + skill validation)");
|
||||
resetAdvisor();
|
||||
const r3 = await advise({
|
||||
snapshot: {
|
||||
position: { x: 608, y: 90, z: 91 },
|
||||
health: 14, food: 18, isDay: true,
|
||||
inventory: { dirt: 4 },
|
||||
activeSkill: "explore.far",
|
||||
},
|
||||
reason: "wedged_60s",
|
||||
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
|
||||
lessonsTail: [
|
||||
{ text: "Если позиция почти не меняется и инвентарь не растёт, прекращай текущий exploration skill." },
|
||||
],
|
||||
force: true,
|
||||
});
|
||||
console.log(` ok: ${r3.ok}`);
|
||||
console.log(` latency: ${r3.latencyMs}ms`);
|
||||
if (r3.ok) {
|
||||
console.log(` action: ${r3.action}`);
|
||||
console.log(` skillId: ${r3.skillId ?? "(n/a)"}`);
|
||||
console.log(` why: ${r3.rationale}`);
|
||||
if (r3.usage) {
|
||||
console.log(` tokens: in=${r3.usage.in} out=${r3.usage.out} total=${r3.usage.total}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` code: ${r3.code}`);
|
||||
console.log(` detail: ${String(r3.detail).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("→ probe 4: auto-trigger flow (tickAdvisor → wait → consumeFreshRecommendation)");
|
||||
resetAdvisor();
|
||||
resetTrigger();
|
||||
const ctx = {
|
||||
snapshot: { position: { x: 608, y: 90, z: 91 }, health: 14, food: 18, isDay: true,
|
||||
inventory: { dirt: 4 }, activeSkill: "explore.far" },
|
||||
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
|
||||
lastSignificantMoveAt: Date.now() - 90_000,
|
||||
};
|
||||
const t = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
|
||||
console.log(` trigger fired: ${t.fired} (${t.reason})`);
|
||||
// wait up to 25s for async advise to land
|
||||
const waitStart = Date.now();
|
||||
while (!ctx.advisorRecommendation && Date.now() - waitStart < 25_000) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
const consumed = consumeFreshRecommendation(ctx);
|
||||
if (consumed) {
|
||||
console.log(` recommendation: ${consumed.skillId}`);
|
||||
console.log(` rationale: ${consumed.rationale}`);
|
||||
console.log(` latency: ${consumed.latencyMs}ms`);
|
||||
if (consumed.usage) {
|
||||
console.log(` tokens: in=${consumed.usage.in} out=${consumed.usage.out} total=${consumed.usage.total}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` no recommendation (timeout or non-switch action)`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("=== Usage budget summary ===");
|
||||
const usage = getUsageSnapshot();
|
||||
console.log(` calls (last hour): ${usage.callsLastHour}/${usage.hourlyBudget}`);
|
||||
console.log(` calls total: ${usage.callsTotal}`);
|
||||
console.log(` tokens in (total): ${usage.tokensInTotal}`);
|
||||
console.log(` tokens out (total): ${usage.tokensOutTotal}`);
|
||||
// Rough cost estimate for context — TimeWeb pricing unknown, OpenAI
|
||||
// gpt-5-mini hypothetical: $0.15/M input + $0.60/M output.
|
||||
const estUsd = (usage.tokensInTotal * 0.15 + usage.tokensOutTotal * 0.60) / 1_000_000;
|
||||
console.log(` est. cost (OpenAI gpt-5-mini pricing): $${estUsd.toFixed(6)}`);
|
||||
console.log(` per-call avg in: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal))}t`);
|
||||
console.log(` hourly @ budget: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t in / ${Math.round(usage.tokensOutTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t out`);
|
||||
}
|
||||
|
||||
function logResult(r) {
|
||||
console.log(` ok: ${r.ok}`);
|
||||
console.log(` latency: ${r.latencyMs}ms`);
|
||||
if (!r.ok) {
|
||||
console.log(` code: ${r.code}`);
|
||||
console.log(` detail: ${String(r.detail).slice(0, 400)}`);
|
||||
return;
|
||||
}
|
||||
console.log(` reply: ${typeof r.text === "string" ? r.text.slice(0, 200) : JSON.stringify(r.text).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("UNHANDLED:", e?.message ?? e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
// Operator-facing view of bot-flagged improvement requests.
|
||||
//
|
||||
// The LLM (postmortem + reflect + trigger-tuner) writes here when it
|
||||
// notices a structural gap — a missing skill or a misconfigured policy.
|
||||
// You read this, decide what's worth implementing, and ship it.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/list-improvements.js # all open, sorted by priority
|
||||
// node scripts/list-improvements.js --status all # everything
|
||||
// node scripts/list-improvements.js --status implemented
|
||||
// node scripts/list-improvements.js --source reflect
|
||||
// node scripts/list-improvements.js --category skill
|
||||
// node scripts/list-improvements.js --done 17 "shipped in 0.3.1"
|
||||
// node scripts/list-improvements.js --reject 18 "duplicate"
|
||||
// node scripts/list-improvements.js --stats # aggregate counts
|
||||
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
loadDotenv();
|
||||
|
||||
import { initKnowledge, listImprovements, markImprovementStatus, isAvailable, recommendationStats } from "../runtime/knowledge/index.js";
|
||||
import { stateDir } from "../runtime/config.js";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: "open", source: null, category: null, limit: 50, stats: false, action: null };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--status") out.status = argv[++i];
|
||||
else if (a === "--source") out.source = argv[++i];
|
||||
else if (a === "--category") out.category = argv[++i];
|
||||
else if (a === "--limit") out.limit = Number(argv[++i]) || 50;
|
||||
else if (a === "--stats") out.stats = true;
|
||||
else if (a === "--done") { out.action = "implemented"; out.actionId = Number(argv[++i]); out.actionNote = argv[++i] ?? null; }
|
||||
else if (a === "--reject") { out.action = "rejected"; out.actionId = Number(argv[++i]); out.actionNote = argv[++i] ?? null; }
|
||||
else if (a === "--inprogress") { out.action = "in_progress"; out.actionId = Number(argv[++i]); out.actionNote = argv[++i] ?? null; }
|
||||
else if (a === "--help" || a === "-h") { printHelp(); process.exit(0); }
|
||||
}
|
||||
if (out.status === "all") out.status = null;
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/list-improvements.js [options]
|
||||
|
||||
--status <open|in_progress|implemented|rejected|all> default: open
|
||||
--source <postmortem|reflect|advisor|tuner|manual>
|
||||
--category <skill|tuning|perception|planning|social|other>
|
||||
--limit <n> default: 50
|
||||
--stats show advisor recommendation stats
|
||||
--done <id> [note] mark a request as implemented
|
||||
--inprogress <id> [note] mark a request as in progress
|
||||
--reject <id> [note] mark a request as rejected
|
||||
`);
|
||||
}
|
||||
|
||||
function priorityLabel(p) {
|
||||
return ["", "P1 urgent", "P2 high", "P3 normal", "P4 low", "P5 nice-to-have"][p] ?? `P${p}`;
|
||||
}
|
||||
|
||||
function statusLabel(s) {
|
||||
return ({
|
||||
open: "OPEN",
|
||||
in_progress: "WIP",
|
||||
implemented: "DONE",
|
||||
rejected: "REJECTED",
|
||||
duplicate: "DUP",
|
||||
})[s] ?? s;
|
||||
}
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return "?";
|
||||
const d = new Date(ts);
|
||||
return d.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function renderRow(r) {
|
||||
const lines = [
|
||||
`#${r.id} [${statusLabel(r.status).padEnd(8)}] ${priorityLabel(r.priority).padEnd(18)} ×${r.votes}`,
|
||||
` ${r.title}`,
|
||||
` source=${r.source} category=${r.category ?? "?"} created=${formatTs(r.ts)}${r.implemented_at ? ` done=${formatTs(r.implemented_at)}` : ""}`,
|
||||
];
|
||||
if (r.description) {
|
||||
lines.push(` ${String(r.description).slice(0, 240)}`);
|
||||
}
|
||||
if (r.notes) {
|
||||
lines.push(` notes: ${String(r.notes).slice(0, 200)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
await initKnowledge({ stateDir });
|
||||
if (!isAvailable()) {
|
||||
console.error(`knowledge DB unavailable at ${stateDir}/knowledge.db`);
|
||||
console.error(`(install better-sqlite3 and ensure the bot has run at least once)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.action) {
|
||||
markImprovementStatus(args.actionId, { status: args.action, notes: args.actionNote });
|
||||
console.log(`#${args.actionId} → ${args.action}${args.actionNote ? ` (${args.actionNote})` : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.stats) {
|
||||
const stats = recommendationStats({ sinceHours: 24 });
|
||||
console.log(`=== Advisor recommendation stats (last 24h) ===`);
|
||||
if (stats.length === 0) {
|
||||
console.log("(no recommendations yet)");
|
||||
} else {
|
||||
console.log(" trigger_reason total applied ok fail avg_in avg_out avg_latency");
|
||||
for (const s of stats) {
|
||||
console.log(` ${(s.trigger_reason || "?").padEnd(22)} ${String(s.total).padStart(5)} ${String(s.applied ?? 0).padStart(7)} ${String(s.succeeded ?? 0).padStart(2)} ${String(s.failed ?? 0).padStart(4)} ${String(Math.round(s.avg_in ?? 0)).padStart(6)} ${String(Math.round(s.avg_out ?? 0)).padStart(7)} ${String(Math.round(s.avg_latency_ms ?? 0)).padStart(11)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = listImprovements({
|
||||
status: args.status,
|
||||
source: args.source,
|
||||
category: args.category,
|
||||
limit: args.limit,
|
||||
});
|
||||
const heading = `=== Improvement requests`
|
||||
+ (args.status ? ` (status=${args.status})` : ` (all)`)
|
||||
+ (args.source ? ` source=${args.source}` : "")
|
||||
+ (args.category ? ` category=${args.category}` : "")
|
||||
+ ` — ${rows.length} row${rows.length === 1 ? "" : "s"} ===`;
|
||||
console.log(heading);
|
||||
if (rows.length === 0) {
|
||||
console.log("(empty)");
|
||||
return;
|
||||
}
|
||||
for (const r of rows) {
|
||||
console.log("");
|
||||
console.log(renderRow(r));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("ERROR:", e?.message ?? e);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user