diff --git a/.env.example b/.env.example index 037f70a..b93203a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/dev/v0.3.0/PLAN.md b/dev/v0.3.0/PLAN.md new file mode 100644 index 0000000..47a99a1 --- /dev/null +++ b/dev/v0.3.0/PLAN.md @@ -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 ` на + ветке `revert/v0.3.0-stability` +- Тестовые данные строго в `/tmp/pepa-test-state-*` (исправлено в v0.2.0-rc.2) diff --git a/dev/v0.3.0/STATUS.md b/dev/v0.3.0/STATUS.md new file mode 100644 index 0000000..0b46176 --- /dev/null +++ b/dev/v0.3.0/STATUS.md @@ -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_`, + `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 [note]`, `--inprogress `, `--reject `, + `--source `, + `--category `. + +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:///v1" + export TIMEWEB_API_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` diff --git a/package.json b/package.json index d07bf00..b8a23ed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/runtime/awareness/events.js b/runtime/awareness/events.js new file mode 100644 index 0000000..04589f1 --- /dev/null +++ b/runtime/awareness/events.js @@ -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, +}; diff --git a/runtime/awareness/events.test.js b/runtime/awareness/events.test.js new file mode 100644 index 0000000..33e2c77 --- /dev/null +++ b/runtime/awareness/events.test.js @@ -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); +}); diff --git a/runtime/bot.js b/runtime/bot.js index 977ff25..cbb9887 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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) => { diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js index 47c19ec..e05cf79 100644 --- a/runtime/coach/advice.js +++ b/runtime/coach/advice.js @@ -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 }; } diff --git a/runtime/coach/advice.test.js b/runtime/coach/advice.test.js index 04d6612..ae71c42 100644 --- a/runtime/coach/advice.test.js +++ b/runtime/coach/advice.test.js @@ -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 () => { diff --git a/runtime/coach/advisor-trigger.js b/runtime/coach/advisor-trigger.js new file mode 100644 index 0000000..0520ff6 --- /dev/null +++ b/runtime/coach/advisor-trigger.js @@ -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, +}; diff --git a/runtime/coach/advisor-trigger.test.js b/runtime/coach/advisor-trigger.test.js new file mode 100644 index 0000000..64c955e --- /dev/null +++ b/runtime/coach/advisor-trigger.test.js @@ -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); +}); diff --git a/runtime/coach/fast-advisor.js b/runtime/coach/fast-advisor.js new file mode 100644 index 0000000..7724230 --- /dev/null +++ b/runtime/coach/fast-advisor.js @@ -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": "",', + ' "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 }; diff --git a/runtime/coach/fast-advisor.test.js b/runtime/coach/fast-advisor.test.js new file mode 100644 index 0000000..6cab23c --- /dev/null +++ b/runtime/coach/fast-advisor.test.js @@ -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); + }); +}); diff --git a/runtime/coach/llm-call.js b/runtime/coach/llm-call.js new file mode 100644 index 0000000..7f0fd80 --- /dev/null +++ b/runtime/coach/llm-call.js @@ -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 }; diff --git a/runtime/coach/postmortem.js b/runtime/coach/postmortem.js index f26495e..7b34569 100644 --- a/runtime/coach/postmortem.js +++ b/runtime/coach/postmortem.js @@ -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": "", "next_action": "",', ' "lessons": [', ' { "lesson": "...", "category": "combat|pathing|crafting|survival|social",', ' "trigger_skill": "",', ' "trigger_hostile": "",', - ' "avoid_skill": "",', - ' "prefer_skill": "",', - ' "confidence": 0.7 }', - ' ] }', + ' "avoid_skill": "",', + ' "prefer_skill": "",', + ' "confidence": 0.7 } ],', + ' "improvements": [', + ' { "title": "<≤80 chars: what skill/feature is missing>",', + ' "description": "",', + ' "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 }; diff --git a/runtime/coach/postmortem.test.js b/runtime/coach/postmortem.test.js index 627ca22..f8bf087 100644 --- a/runtime/coach/postmortem.test.js +++ b/runtime/coach/postmortem.test.js @@ -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 }); }); diff --git a/runtime/coach/reflect.js b/runtime/coach/reflect.js index 3820b99..e147dd0 100644 --- a/runtime/coach/reflect.js +++ b/runtime/coach/reflect.js @@ -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": "",', + ' "lessons": [', + ' { "lesson": "<≤30 words, generalised rule>",', + ' "category": "combat|pathing|crafting|survival|self-improve",', + ' "trigger_skill": "",', + ' "trigger_hostile": "",', + ' "avoid_skill": "",', + ' "prefer_skill": "",', + ' "confidence": 0.6 } ],', + ' "improvements": [', + ' { "title": "<≤80 chars: structural gap (e.g. \'No craft.iron-pickaxe skill\')>",', + ' "description": "",', + ' "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": "",', - ' "lessons": [', - ' { "lesson": "<≤30 words, generalised rule>",', - ' "category": "combat|pathing|crafting|survival|self-improve",', - ' "trigger_skill": "",', - ' "trigger_hostile": "",', - ' "avoid_skill": "",', - ' "prefer_skill": "",', - ' "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)); } diff --git a/runtime/coach/reflect.test.js b/runtime/coach/reflect.test.js index 0f192a2..4176e86 100644 --- a/runtime/coach/reflect.test.js +++ b/runtime/coach/reflect.test.js @@ -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 {} }); diff --git a/runtime/coach/trigger-tuner.js b/runtime/coach/trigger-tuner.js new file mode 100644 index 0000000..d6d69de --- /dev/null +++ b/runtime/coach/trigger-tuner.js @@ -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 }; diff --git a/runtime/coach/trigger-tuner.test.js b/runtime/coach/trigger-tuner.test.js new file mode 100644 index 0000000..7021727 --- /dev/null +++ b/runtime/coach/trigger-tuner.test.js @@ -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); +}); diff --git a/runtime/knowledge/index.js b/runtime/knowledge/index.js index 94518b2..cb5938f 100644 --- a/runtime/knowledge/index.js +++ b/runtime/knowledge/index.js @@ -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; } diff --git a/runtime/knowledge/knowledge.test.js b/runtime/knowledge/knowledge.test.js index 43aa731..5d24908 100644 --- a/runtime/knowledge/knowledge.test.js +++ b/runtime/knowledge/knowledge.test.js @@ -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(); diff --git a/runtime/knowledge/schema.sql b/runtime/knowledge/schema.sql index 93b8c36..771e461 100644 --- a/runtime/knowledge/schema.sql +++ b/runtime/knowledge/schema.sql @@ -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); diff --git a/runtime/llm/provider.js b/runtime/llm/provider.js new file mode 100644 index 0000000..f30f526 --- /dev/null +++ b/runtime/llm/provider.js @@ -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 = ""; } + 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 }; diff --git a/runtime/llm/provider.test.js b/runtime/llm/provider.test.js new file mode 100644 index 0000000..7a11796 --- /dev/null +++ b/runtime/llm/provider.test.js @@ -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_", 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; + } +}); diff --git a/runtime/manifesto/needs.js b/runtime/manifesto/needs.js new file mode 100644 index 0000000..750ab45 --- /dev/null +++ b/runtime/manifesto/needs.js @@ -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, +}; diff --git a/runtime/manifesto/needs.test.js b/runtime/manifesto/needs.test.js new file mode 100644 index 0000000..63e1b88 --- /dev/null +++ b/runtime/manifesto/needs.test.js @@ -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); +}); diff --git a/runtime/manifesto/state.js b/runtime/manifesto/state.js new file mode 100644 index 0000000..d0e46db --- /dev/null +++ b/runtime/manifesto/state.js @@ -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 }; diff --git a/runtime/manifesto/state.test.js b/runtime/manifesto/state.test.js new file mode 100644 index 0000000..22960f2 --- /dev/null +++ b/runtime/manifesto/state.test.js @@ -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 '", () => { + _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"); +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index 420292f..a8eace4 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -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 ------------------------------------------------------------------ diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 2b2c87f..8b255b7 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -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: { diff --git a/runtime/skill-registry.js b/runtime/skill-registry.js new file mode 100644 index 0000000..ab8ec2c --- /dev/null +++ b/runtime/skill-registry.js @@ -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; } diff --git a/runtime/skill-registry.test.js b/runtime/skill-registry.test.js new file mode 100644 index 0000000..45bbd82 --- /dev/null +++ b/runtime/skill-registry.test.js @@ -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("...")); +}); diff --git a/runtime/skills/contract.test.js b/runtime/skills/contract.test.js index 566e010..27667ad 100644 --- a/runtime/skills/contract.test.js +++ b/runtime/skills/contract.test.js @@ -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(); + } +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 0fcc65d..5440028 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -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, }; diff --git a/scripts/check-timeweb.js b/scripts/check-timeweb.js new file mode 100644 index 0000000..7031e03 --- /dev/null +++ b/scripts/check-timeweb.js @@ -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); +}); diff --git a/scripts/list-improvements.js b/scripts/list-improvements.js new file mode 100644 index 0000000..d3e6019 --- /dev/null +++ b/scripts/list-improvements.js @@ -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 default: open + --source + --category + --limit default: 50 + --stats show advisor recommendation stats + --done [note] mark a request as implemented + --inprogress [note] mark a request as in progress + --reject [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); +});