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