From 451c0343cb8c10ce5945e8c486ab4065ed9a0ef7 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 19:52:05 +0300 Subject: [PATCH 1/9] =?UTF-8?q?docs(v0.3.1):=20PRD=20=E2=80=94=20LLM=20pro?= =?UTF-8?q?mpt=20cost=20optimization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design-only commit; no runtime changes. Spec for the next patch iteration. Goal: cut per-advise() input tokens from ~800 to ≤300, preserving the LLM's ability to produce valid registered skill ids and useful rationale. Five proposed changes ranked by impact: P1 Compact registry format (saves ~350t/call) — group by namespace, comma-list ids, drop human titles. Default mode for advisor; verbose mode kept for postmortem/reflect. P2 Need-scoped registry (~50t additional) — show LLM only skills relevant to the active Maslow need + always-available safety skills (survive.flee, pillar-up, recovery.tunnel-out, explore.*). P3 Snapshot pruning (~50t) — drop weather/experience/dimension/biome/ players from the user prompt; the LLM doesn't consult them. P4 Prompt caching probe — check if TimeWeb passes through prompt_tokens_details.cached_tokens. If yes, restructure prefix to maximize cache hits (cached input is ~10x cheaper at OpenAI). P5 Per-trigger cost telemetry in scripts/list-improvements.js --stats: avg_in / avg_out / cost_₽ / share% per trigger_reason, using TIMEWEB_PRICE_IN_RUB_PER_M and TIMEWEB_PRICE_OUT_RUB_PER_M env. Trigger: TimeWeb admin panel after first day of v0.3.0 live showed 34K tokens / day at low activity. At cap budget that projects to ~480₽/month (101₽/M in, 608₽/M out for gpt-5.4-mini). Manageable but the savings are mostly free — repeated infra tokens, not signal. All changes are additive; runtime behaviour stays the same. If the LLM produces worse advice with the compact registry, flip back via a single constant in fast-advisor.js. Acceptance: re-run scripts/check-timeweb.js probe 3 — expect tokens_in ≤ 300 (was ~800). Live for 1h, check --stats: avg_in ≤ 300 per trigger group. Existing 360 tests still green. Co-Authored-By: Claude Opus 4.7 --- dev/v0.3.1/PRD.md | 192 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 dev/v0.3.1/PRD.md diff --git a/dev/v0.3.1/PRD.md b/dev/v0.3.1/PRD.md new file mode 100644 index 0000000..fc9c294 --- /dev/null +++ b/dev/v0.3.1/PRD.md @@ -0,0 +1,192 @@ +# pepa v0.3.1 — PRD: LLM prompt cost optimization + +**Status**: Design draft. No code in this version yet — this PRD is the +spec future commits implement against. Owner: operator. +**Trigger**: TimeWeb admin panel after first day of v0.3.0 live: +~34K tokens used in a half-day session (mostly bot + some smoke). +At the 6-calls/hour cap that projects to **~480 ₽/month** (101 ₽/M in, +608 ₽/M out for gpt-5.4-mini). Manageable but worth shrinking — most +of the per-call cost is repeated infrastructure tokens, not the +situational signal the model actually uses. + +## Goals + +1. Cut per-advise() input tokens from ~800 → ≤300 (target 250). +2. Preserve correctness: the LLM must still see enough context to + produce a valid `skill_id` from the registry and a useful rationale. +3. Keep all changes transparent to the rest of the runtime — the + public `advise()` / `complete()` surface area doesn't change. + +Non-goals: +- Switching providers. TimeWeb stays. +- Caching the LLM's *responses* (cache key would be situational, too + many misses to be worth the bookkeeping). +- Touching the analytical loops (postmortem / reflect). They're called + less often and need fuller context; cost there is acceptable. + +## Cost breakdown — what we're optimizing + +Measured on live advise() calls (TimeWeb gpt-5.4-mini, single advisor +trigger): + +| Block | tokens (avg) | % of call | +|--------------------------------------|--------------|-----------| +| `skillRegistryPrompt({limit:1800})` | ~450 | 56% | +| System instructions (rules + JSON) | ~200 | 25% | +| User snapshot + threats + need + recent | ~150 | 19% | +| **Total input** | **~800** | **100%** | +| Output (JSON answer) | ~40-50 | — | + +The registry block dominates. It currently lists all 30+ registered +skills with their human titles. The model rarely needs the full list — +most decisions are within 5-8 plausible skills per trigger. + +## Proposed changes + +### 1. Compact registry format (P1, biggest win) + +Drop human titles and the per-skill descriptions; switch to +namespace-grouped, comma-separated id lists. + +**Before** (~450 tokens): +``` +Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill): + craft: + - craft.bed — Craft bed + - craft.chest — Craft chest + - craft.furnace — Craft furnace + ... + survive: + - survive.acquire-food — Acquire food + - survive.eat — Eat + ... +``` + +**After** (~100 tokens): +``` +Valid skill ids (USE EXACTLY one of these or null): + craft: bed, chest, furnace, planks, sticks, torch, wooden-axe, + wooden-pickaxe, wooden-sword, stone-axe, stone-pickaxe, stone-sword + survive: acquire-food, eat, flee, pillar-up, sleep + gather: logs, stone, wool + recovery: tunnel-out + explore: far, wander + village: build-shelter, choose-base, deposit-surplus, place-chest + farm: wheat + diag: physics, scan, match +``` + +Saving: **~350 tokens/call**. + +Implementation: add `skillRegistryPrompt({ mode: "compact" })` mode in +`runtime/skill-registry.js`. Default mode stays for slow analytical +loops (postmortem / reflect) which can afford the verbose form. + +### 2. Need-scoped registry (P2, additional ~50 token saving) + +When `activeNeed` is set, filter the registry to skills plausibly +relevant to that level + always-available safety skills. + +Relevance table (manually curated, lives in `runtime/manifesto/needs.js`): + +| Need | Relevant skills (in addition to ALWAYS set) | +|-------------------|------------------------------------------------------------| +| alive | survive.flee, survive.eat, recovery.tunnel-out | +| food | survive.acquire-food, survive.eat, farm.wheat | +| tools_wood | gather.logs, craft.planks, craft.sticks, craft.wooden-* | +| shelter_basic | gather.wool, craft.bed, village.build-shelter, village.choose-base | +| tools_stone | gather.stone, craft.sticks, craft.stone-* | +| armor_basic | gather.wool (placeholder) | +| food_security | farm.wheat, survive.acquire-food | +| tools_iron | gather.stone | +| armor_iron | (none — no skill yet) | +| village_seed | craft.chest, village.deposit-surplus, village.build-shelter | +| village_full | (full registry) | +| ALWAYS | survive.flee, survive.pillar-up, recovery.tunnel-out, | +| | explore.far, explore.wander | + +Compact + scoped = **~50 tokens** for the registry block (down from 450). + +Add a `prompt-builder.test.js` checking that: +- `survive.flee` is always present (emergency safety) +- The recommended skill from the previous call would still be in the + scoped registry (regression protection) + +### 3. Snapshot pruning (P3, ~50 tokens) + +The user-prompt snapshot includes fields the LLM rarely consults: +`weather`, `experience`, `dimension`, `biome`, `players[]`. Drop them +from the advise() user-prompt builder. Keep `position`, `hp`, `food`, +`isDay`, `closestHostile`, `activeNeed`, `recent dispatches`, +`hazards.footBlock` (lava detection), top inventory keys. + +### 4. Prompt caching — investigation (P4) + +OpenAI and Anthropic both support implicit prompt caching: when ≥1024 +prefix tokens are identical across consecutive requests, the prefix +is billed once. TimeWeb's docs are silent on this. + +Task: probe whether TimeWeb passes through OpenAI's `prompt_tokens_details.cached_tokens` +field. If yes, *increase* the system prefix length (keep verbose registry) +because cached input is ~10x cheaper than fresh. If no, full optimization +1+2+3 still wins. + +Add a one-off check in `scripts/check-timeweb.js`: print +`payload?.usage?.prompt_tokens_details?.cached_tokens` if present. + +### 5. Telemetry — per-trigger token attribution (P5) + +Today `advisor_recommendations` records `tokens_in` per row but the +operator has no easy view of *which trigger types* are most expensive. + +Extend `scripts/list-improvements.js --stats` to also print per-trigger: +``` +trigger_reason total applied ok fail avg_in avg_out cost_₽ share% +wedged_* 20 18 3 15 280 45 2.1 45% +emergency_* 3 3 2 1 240 50 0.3 6% +repeat_* 8 7 0 7 260 42 0.8 17% +preempt_retry_* 14 12 2 10 290 44 1.5 32% +``` + +`cost_₽` = avg_in × calls × IN_PRICE + avg_out × calls × OUT_PRICE, +with prices read from env (`TIMEWEB_PRICE_IN_RUB_PER_M`, +`TIMEWEB_PRICE_OUT_RUB_PER_M`). + +## Acceptance + +After v0.3.1 lands: +- Re-run `node scripts/check-timeweb.js` probe 3 (`advise()`): + expect `tokens_in` ≤ 300 (was ~800). +- Re-run probe 4 (auto-trigger flow): rationale still references the + registered skill correctly. +- Run live for 1 hour, check `node scripts/list-improvements.js --stats`: + per-trigger `avg_in` ≤ 300. +- Existing 360 tests still green; new prompt-builder tests cover the + scoped registry behaviour. + +## Out of scope (later versions) + +- Tool/function-calling instead of free-form JSON (TimeWeb support unclear). +- Custom model selection per trigger (gpt-5.4-nano for routine repeats, + gpt-5.4-mini for emergencies). Defer until cost/quality data points + exist. +- Embedding-based prior-recommendation similarity check ("we already + told the bot to tunnel-out at this exact wedge 10 minutes ago, skip"). + +## Implementation order + +When this version is greenlit, work in this order on a single branch +`v0.3.1` (one PR per session, per recently-updated workflow memory): + +1. P1 compact registry mode + prompt-builder test +2. P2 need-scoped registry (extend `runtime/manifesto/needs.js` with + `relevantSkills`) +3. P3 snapshot pruning in `fast-advisor.js#buildUserPrompt` +4. P4 caching probe (one-off) +5. P5 cost telemetry in CLI viewer +6. STATUS.md + smoke retest + PR + +All changes are additive; no behaviour regression expected. If +real-world after v0.3.1 shows the LLM giving worse advice with the +compact registry, fall back to default mode by flipping a single +constant in `fast-advisor.js`. -- 2.54.0 From 7f545723b5b3a5d25fb9cafc088ea4ce9312e185 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 20:36:17 +0300 Subject: [PATCH 2/9] =?UTF-8?q?feat(v0.3.1):=20storyline=20=E2=80=94=20can?= =?UTF-8?q?onical=20Minecraft=20survival=20quest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bot has been stuck in a loop for two days: acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat Diagnosis: manifesto + LLM advisor both correctly identify "you need food" but neither expresses *what concretely to do next*. Manifesto is a priority ladder (need-detection), not a narrative arc. This commit adds the missing narrative layer — an ordered list of operational steps that mirror the vanilla Minecraft survival path: 1. orient_self — Понять где я 2. first_wood — Собрать 8 поленьев 3. crafting_basics — Сделать верстак и палки 4. first_tools — Деревянные орудия 5. first_food — Найти первую еду 6. shelter_minimal — Простой шелтер с кроватью 7. stone_tier — Каменные орудия 8. food_security — Запас еды на 16+ 9. iron_age — Железо и печь 10. settle_base — Постоянная база 11. village_grow — Развивать деревню (ongoing) Each step has: - completed(snapshot) → bool — detects achievement from snapshot - suggestSkill(snapshot) → { skillId, args? } — concrete next dispatch - emergencyPause(snapshot) → bool — defers to manifesto L0 alive emergencies (low HP near hostile, lava under foot, food = 0) - narration_ru — chat-friendly Russian one-liner spoken on entry Components: - runtime/goal/storyline.js — 11-step canonical quest catalogue - runtime/goal/state.js — pickCurrentStep(snapshot) walks the list, returns first non-completed step + its suggestion. 3s cache. Validates suggestSkill's skillId against the live registry. - runtime/reflex.js — curriculumReflex dispatch priority is now: 1. manifesto (L0 alive emergencies always win) 2. storyline (concrete operational subgoal) 3. curriculum plan (legacy fallback) Tests pass ctx.disableStoryline=true for isolation. - runtime/bot.js — snapshot.storyStep populated each tick so chatter/advisor/reflect observers see the same view. - runtime/coach/fast-advisor.js — buildUserPrompt now embeds the current step + its suggested skill, so LLM advice is anchored ("step 5 first_food, storyline wants survive.acquire-food, but recent dispatches show it's failing — try explore.far + scout"). - runtime/coach/advisor-trigger.js — forwards ctx.storyStep into advise() and logs step id at trigger time. - runtime/coach/reflect.js — reflection prompt includes storyline progress so 30-min self-assessment is anchored. - runtime/persona/chatter.js — narrates step.narration_ru on transition. Rate-limited via existing maybeNarrateRaw(). New operator CLI: - scripts/show-story.js — fetches the live snapshot via IPC sock and prints step progress with ✓/→/ markers, current skill, inventory. Falls back to --plain catalogue view when bot offline. Token cost impact: ~+30 input tokens per advise() call (one extra line in user prompt). Trivial vs the value of grounding LLM advice in a concrete narrative. Operator usage: node scripts/show-story.js # live progress + which step + why node scripts/show-story.js --plain # static catalogue of all 11 steps Tests: 376 green (was 360, +16 storyline tests). Also in this branch (already committed): dev/v0.3.1/PRD.md — LLM prompt cost optimization design doc. Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- runtime/bot.js | 5 + runtime/coach/advisor-trigger.js | 5 +- runtime/coach/fast-advisor.js | 11 +- runtime/coach/reflect.js | 10 +- runtime/goal/state.js | 88 +++++++++ runtime/goal/storyline.js | 320 +++++++++++++++++++++++++++++++ runtime/goal/storyline.test.js | 165 ++++++++++++++++ runtime/persona/chatter.js | 20 +- runtime/reflex.js | 26 ++- runtime/reflex.test.js | 2 + scripts/show-story.js | 94 +++++++++ 12 files changed, 733 insertions(+), 15 deletions(-) create mode 100644 runtime/goal/state.js create mode 100644 runtime/goal/storyline.js create mode 100644 runtime/goal/storyline.test.js create mode 100644 scripts/show-story.js diff --git a/package.json b/package.json index b8a23ed..d5e48d1 100644 --- a/package.json +++ b/package.json @@ -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/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/awareness/events.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/runtime/bot.js b/runtime/bot.js index cbb9887..ce06980 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -63,6 +63,7 @@ import { attach as attachReflect } from "./coach/reflect.js"; import { attach as attachTuner } from "./coach/trigger-tuner.js"; import { attach as attachChatter } from "./persona/chatter.js"; import { attachAwareness } from "./awareness/events.js"; +import { pickCurrentStep } from "./goal/state.js"; fs.mkdirSync(stateDir, { recursive: true }); const JOINED_FLAG = path.join(stateDir, "joined-before.flag"); @@ -844,6 +845,10 @@ function tick() { } const curriculumEarly = nextCurriculumMilestone(lastSnapshot); lastSnapshot.curriculum = curriculumEarly; + // Storyline current step — surfaced in snapshot so chatter and + // other observers can react to step transitions without + // re-importing the picker. + try { lastSnapshot.storyStep = pickCurrentStep(lastSnapshot); } catch {} reflexCtx.snapshot = lastSnapshot; if (!reflexPaused) { const result = runTick(reflexCtx); diff --git a/runtime/coach/advisor-trigger.js b/runtime/coach/advisor-trigger.js index 0520ff6..e8caf65 100644 --- a/runtime/coach/advisor-trigger.js +++ b/runtime/coach/advisor-trigger.js @@ -85,10 +85,11 @@ export function tickAdvisor(ctx, { plannedSkillId } = {}) { const snapshot = ctx.snapshot ?? null; const recentSkillIds = (ctx.recentSkillIds ?? []).slice(-8); const activeNeed = ctx.activeNeed ?? null; + const storyStep = ctx.storyStep ?? null; - info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"})`); + info("advisor-trigger", `firing because ${reason} (planned=${plannedSkillId ?? "?"}, need=${activeNeed?.need?.id ?? "?"}, step=${storyStep?.step?.id ?? "?"})`); // Fire-and-forget. The promise's resolution writes ctx.advisorRecommendation. - advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, force: true }) + advise({ snapshot, reason, recentSkillIds, lessonsTail: ctx.recentLessons ?? [], activeNeed, storyStep, force: true }) .then((result) => { _inFlight = false; const needLabel = activeNeed diff --git a/runtime/coach/fast-advisor.js b/runtime/coach/fast-advisor.js index 7724230..17ca658 100644 --- a/runtime/coach/fast-advisor.js +++ b/runtime/coach/fast-advisor.js @@ -67,6 +67,7 @@ export async function advise({ recentSkillIds = [], lessonsTail = [], activeNeed = null, + storyStep = null, force = false, } = {}) { if (!isAvailable()) { @@ -83,7 +84,7 @@ export async function advise({ } const system = buildSystemPrompt(); - const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }); + const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }); _callTimes.push(now); _lastCallAt = now; @@ -164,7 +165,7 @@ function buildSystemPrompt() { ].join("\n"); } -function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed }) { +function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, activeNeed, storyStep }) { const pos = snapshot?.position; const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)"; const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)"; @@ -172,6 +173,9 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active const needLine = activeNeed ? `L${activeNeed.need.level} ${activeNeed.need.id} (${activeNeed.need.title}) — manifesto wants ${activeNeed.skillId}` : "(no active need)"; + const storyLine = storyStep + ? `step ${storyStep.index + 1} '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (storyline wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}` + : "(no current step)"; const hostile = snapshot?.closestHostile ? `${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}b` : "(none)"; @@ -180,6 +184,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active `Trigger: ${reason}`, `Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`, `HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`, + `Storyline progress: ${storyLine}`, `Active need (Maslow ladder): ${needLine}`, `Closest hostile: ${hostile}`, `Active skill: ${snapshot?.activeSkill ?? "(idle)"}`, @@ -190,7 +195,7 @@ function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail, active "", lessons ? `Relevant lessons:\n${lessons}\n` : "", "What should the bot do RIGHT NOW? Return the JSON decision.", - "Prefer a skill that helps satisfy the active need unless an emergency forces another action.", + "Prefer a skill that advances the current storyline step. If a manifesto emergency fires, that wins over both. Don't repeat a skill that has been failing in the recent dispatches list.", ].filter(Boolean).join("\n"); } diff --git a/runtime/coach/reflect.js b/runtime/coach/reflect.js index e147dd0..b9db295 100644 --- a/runtime/coach/reflect.js +++ b/runtime/coach/reflect.js @@ -18,6 +18,7 @@ import { resolve } from "node:path"; import { isAvailable as knowledgeAvailable, record as recordLesson, createImprovementRequest } from "../knowledge/index.js"; import { isRegistered, skillRegistryPrompt } from "../skill-registry.js"; import { pickActiveNeed } from "../manifesto/state.js"; +import { pickCurrentStep } from "../goal/state.js"; import { isAvailable as llmAvailable } from "../llm/provider.js"; import { askAnalytical } from "./llm-call.js"; import { info, warn } from "../log.js"; @@ -81,8 +82,9 @@ export async function runOnce({ stateDir, getSnapshot, force = false, askAnalyti const diary = readDiaryTail(stateDir); const plan = readPlan(stateDir); const activeNeed = pickActiveNeed(snap); + const storyStep = pickCurrentStep(snap); - const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }); + const { system, user } = buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep }); _llmCallTimes.push(now); const parsed = await askAnalyticalFn({ system, user, json: true }); @@ -172,13 +174,16 @@ function readPlan(stateDir) { try { return readFileSync(f, "utf8"); } catch { return ""; } } -function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) { +function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed, storyStep }) { const pos = snap?.position; const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"; const lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)"; const needLine = activeNeed ? `L${activeNeed.need.level} ${activeNeed.need.id} → ${activeNeed.skillId} (${activeNeed.need.title})` : "(satisfied through L10 / no active need)"; + const storyLine = storyStep + ? `step ${storyStep.index + 1}/11 '${storyStep.step.id}' — ${storyStep.step.title}${storyStep.suggestion?.skillId ? ` (wants ${storyStep.suggestion.skillId})` : ""}${storyStep.emergency ? " [EMERGENCY PAUSE]" : ""}` + : "(no current step)"; const system = [ "You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.", @@ -216,6 +221,7 @@ function buildPrompt({ snap, journal, scenarios, diary, plan, activeNeed }) { `- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`, `- runtimeState: ${snap?.runtimeState ?? "?"}`, `- activeSkill: ${snap?.activeSkill ?? "(idle)"}`, + `- storylineStep: ${storyLine}`, `- activeNeed (Maslow ladder L0-L10): ${needLine}`, `- currentMilestone: ${snap?.currentMilestone ?? "?"}`, `- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`, diff --git a/runtime/goal/state.js b/runtime/goal/state.js new file mode 100644 index 0000000..812eaeb --- /dev/null +++ b/runtime/goal/state.js @@ -0,0 +1,88 @@ +// Storyline state: which step the bot is currently on. +// +// pickCurrentStep(snapshot) walks STORYLINE from the top and returns +// the first non-completed step. If an emergency condition fires +// (low HP near hostile, lava under foot, etc.) the step is paused and +// callers should defer to manifesto's L0 alive emergency dispatch +// instead of step.suggestSkill. + +import { STORYLINE, getStep } from "./storyline.js"; +import { isRegistered } from "../skill-registry.js"; +import { info } from "../log.js"; + +const CACHE_TTL_MS = 3_000; + +let _cache = null; +let _lastStepId = null; + +export function _resetForTest() { + _cache = null; + _lastStepId = null; +} + +/** + * pickCurrentStep(snapshot) → + * { + * step: { id, title, narration_ru }, + * index: number, // 0-based position + * suggestion: { skillId, args } | null, + * emergency: boolean, // true if emergencyPause fires + * completedSteps: number // how many done so far + * } | null + */ +export function pickCurrentStep(snapshot) { + if (!snapshot?.connected) return null; + const now = Date.now(); + if (_cache && _cache.snapshot === snapshot && now - _cache.ts < CACHE_TTL_MS) { + return _cache.result; + } + let completedSteps = 0; + let chosen = null; + for (let i = 0; i < STORYLINE.length; i++) { + const step = STORYLINE[i]; + let done; + try { done = !!step.completed(snapshot); } catch { done = false; } + if (done) { + completedSteps += 1; + continue; + } + let emergency = false; + try { emergency = !!step.emergencyPause?.(snapshot); } catch {} + let suggestion = null; + if (!emergency) { + try { suggestion = step.suggestSkill(snapshot) ?? null; } catch { suggestion = null; } + if (suggestion?.skillId && !isRegistered(suggestion.skillId)) { + info("storyline", `step ${step.id}: suggested unknown skill ${suggestion.skillId}; dropping`); + suggestion = null; + } + } + chosen = { + step: { id: step.id, title: step.title, narration_ru: step.narration_ru }, + index: i, + suggestion, + emergency, + completedSteps, + }; + break; + } + if (chosen && _lastStepId !== chosen.step.id) { + info("storyline", `step ${chosen.index + 1}/${STORYLINE.length}: ${chosen.step.id} — ${chosen.step.title} → ${chosen.suggestion?.skillId ?? "(no concrete skill)"}`); + _lastStepId = chosen.step.id; + } + _cache = { snapshot, ts: now, result: chosen }; + return chosen; +} + +/** + * progressSummary(snapshot) → string, e.g. + * "step 3/11 'first_tools' — Деревянные орудия → craft.wooden-pickaxe" + */ +export function progressSummary(snapshot) { + const cur = pickCurrentStep(snapshot); + if (!cur) return "(no storyline progress — disconnected)"; + const tail = cur.suggestion?.skillId ? ` → ${cur.suggestion.skillId}` : ""; + const emer = cur.emergency ? " [EMERGENCY PAUSE]" : ""; + return `step ${cur.index + 1}/${STORYLINE.length} '${cur.step.id}' — ${cur.step.title}${tail}${emer}`; +} + +export { STORYLINE, getStep }; diff --git a/runtime/goal/storyline.js b/runtime/goal/storyline.js new file mode 100644 index 0000000..ca70a8e --- /dev/null +++ b/runtime/goal/storyline.js @@ -0,0 +1,320 @@ +// Storyline — canonical Minecraft survival quest the bot lives inside. +// +// Why this exists (rationale, 2026-05-27 evening): +// +// After v0.3.0 went live the bot got stuck in a loop: +// acquire-food (fail: no nearby food) → explore.far → pillar-up (fail) → repeat +// +// Manifesto + LLM advisor both correctly say "you need food" but +// neither expresses *what concretely to do next*: scout 64 blocks N +// for cows; chop oak nearby; place a crafting table. The bot has no +// narrative arc, just a priority ranking of unsatisfied needs. +// +// Storyline fixes this by laying down the classic vanilla Minecraft +// survival path as an ordered list of *concrete* steps. Each step +// owns: +// - id, title, narration_ru (chat-friendly Russian one-liner) +// - completed(snapshot) → bool — detects if this step's goal has +// been achieved purely from snapshot +// - suggestSkill(snapshot) → { skillId, args? } | null — the +// concrete next dispatch for the step's pursuit +// - emergencyPause(snapshot) → bool — true if a higher-priority +// condition (low HP near hostile, lava under foot, etc.) means we +// should drop story progression for a tick +// +// The runtime/goal/state.js picker walks the list and returns the +// first non-completed step, with its suggestSkill. That feeds into: +// - reflex.js dispatch picking (storyline overrides curriculum, but +// manifesto L0 alive emergencies still win) +// - persona/chatter.js — narrates step start in MC chat +// - coach/fast-advisor.js — user prompt includes current step so +// the LLM advice is anchored in the actual narrative +// - postmortem / reflect — the LLM can flag missing skills using +// the current step as concrete context +// +// Storyline order mirrors manifesto levels but is more *operational*: +// where manifesto says "L2 tools_wood satisfied if you have a wood +// pickaxe", storyline says "step first_tools: place crafting table, +// craft wooden pickaxe + axe + sword, with these specific subgoals." + +const PICKAXE_WOOD = new Set(["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]); +const AXE_WOOD = new Set(["wooden_axe", "stone_axe", "iron_axe", "diamond_axe", "netherite_axe"]); +const SWORD_WOOD = new Set(["wooden_sword", "stone_sword", "iron_sword", "diamond_sword", "netherite_sword"]); +const PICKAXE_STONE = new Set(["stone_pickaxe", "iron_pickaxe", "diamond_pickaxe", "netherite_pickaxe"]); +const BED_ITEMS = [ + "white_bed", "orange_bed", "magenta_bed", "light_blue_bed", "yellow_bed", + "lime_bed", "pink_bed", "gray_bed", "light_gray_bed", "cyan_bed", + "purple_bed", "blue_bed", "brown_bed", "green_bed", "red_bed", "black_bed", +]; +const FOOD_ITEMS = [ + "bread", "cooked_beef", "cooked_porkchop", "cooked_chicken", "cooked_mutton", + "cooked_rabbit", "cooked_cod", "cooked_salmon", "baked_potato", + "apple", "carrot", "potato", "beetroot", "melon_slice", "sweet_berries", + "golden_apple", "golden_carrot", +]; + +function hasSetItem(inv, set) { + if (!inv) return false; + for (const name of Object.keys(inv)) { + if (set.has(name) && inv[name] > 0) return true; + } + return false; +} + +function hasAny(inv, names) { + if (!inv) return false; + for (const n of names) if ((inv[n] ?? 0) > 0) return true; + return false; +} + +function countAny(inv, names) { + if (!inv) return 0; + let total = 0; + for (const n of names) total += inv[n] ?? 0; + return total; +} + +function countLogs(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_log")) total += count; + } + return total; +} + +function countPlanks(inv) { + if (!inv) return 0; + let total = 0; + for (const [name, count] of Object.entries(inv)) { + if (name.endsWith("_planks")) total += count; + } + return total; +} + +function emergencyPause(snap) { + if (!snap?.connected) return false; + const hp = snap.health ?? 20; + const food = snap.food ?? 20; + const hostile = snap.closestHostile; + if (hp <= 5) return true; + if (food <= 0) return true; + if (hostile && (hostile.distance ?? Infinity) <= 5 && hp <= 12) return true; + if (snap.hazards?.footBlock === "lava") return true; + return false; +} + +// --------------------------------------------------------------------------- + +export const STORYLINE = Object.freeze([ + { + id: "orient_self", + title: "Понять где я", + narration_ru: "Где я? Осмотрюсь и оценю место.", + completed(snap) { + if (!snap?.connected) return false; + // Considered done once HP is full and we've moved a bit (out of + // spawn confusion) or we know nearby blocks include something tangible. + const hp = snap.health ?? 20; + const moved = snap._sessionMs ? snap._sessionMs > 15_000 : true; + const sawBlocks = (snap.nearbyBlocks?.logs ?? 0) + + (snap.nearbyBlocks?.stone ?? 0) + + (snap.nearbyBlocks?.crops ?? 0) + + (snap.nearbyBlocks?.beds ?? 0) + > 0; + return hp >= 18 && moved && sawBlocks; + }, + suggestSkill(snap) { + // Look around — wander a bit to get a snapshot of what's nearby. + return { skillId: "explore.wander", args: { radius: 12 } }; + }, + emergencyPause, + }, + + { + id: "first_wood", + title: "Собрать 8 поленьев", + narration_ru: "Цель: 8 поленьев. Иду рубить ближайшие деревья.", + completed(snap) { + return countLogs(snap?.inventory) >= 8; + }, + suggestSkill(snap) { + const trees = snap?.nearbyBlocks?.logs ?? 0; + if (trees > 0) return { skillId: "gather.logs" }; + // No tree in sight — scout further. + return { skillId: "explore.far", args: { searchFor: "logs" } }; + }, + emergencyPause, + }, + + { + id: "crafting_basics", + title: "Сделать верстак и палки", + narration_ru: "Делаю верстак и палки — без них ничего не скрафтить.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return (inv.crafting_table ?? 0) > 0 + && (inv.stick ?? 0) >= 2 + && countPlanks(inv) >= 4; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (countPlanks(inv) < 4) return { skillId: "craft.planks" }; + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + // We have raw materials, need to *place* a crafting table for tools. + // (No place-table skill yet — flagged as improvement_request elsewhere.) + return { skillId: "craft.sticks" }; + }, + emergencyPause, + }, + + { + id: "first_tools", + title: "Деревянные орудия", + narration_ru: "Крафчу деревянный пикакс, топор и меч.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return hasSetItem(inv, PICKAXE_WOOD) + && hasSetItem(inv, AXE_WOOD) + && hasSetItem(inv, SWORD_WOOD); + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (!hasSetItem(inv, PICKAXE_WOOD)) return { skillId: "craft.wooden-pickaxe" }; + if (!hasSetItem(inv, AXE_WOOD)) return { skillId: "craft.wooden-axe" }; + if (!hasSetItem(inv, SWORD_WOOD)) return { skillId: "craft.wooden-sword" }; + return null; + }, + emergencyPause, + }, + + { + id: "first_food", + title: "Найти первую еду", + narration_ru: "Нужна еда — ищу корову, курицу или ягоды.", + completed(snap) { + return countAny(snap?.inventory, FOOD_ITEMS) >= 2; + }, + suggestSkill(snap) { + return { skillId: "survive.acquire-food" }; + }, + emergencyPause, + }, + + { + id: "shelter_minimal", + title: "Простой шелтер с кроватью", + narration_ru: "Поставлю кровать и стены — пережить ночь.", + completed(snap) { + return (snap?.nearbyBlocks?.beds ?? 0) > 0; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if (!hasAny(inv, BED_ITEMS)) { + const wool = countAny(inv, [ + "white_wool", "orange_wool", "magenta_wool", "light_blue_wool", + "yellow_wool", "lime_wool", "pink_wool", "gray_wool", + "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", + "brown_wool", "green_wool", "red_wool", "black_wool", + ]); + if (wool >= 3 && countPlanks(inv) >= 3) return { skillId: "craft.bed" }; + if (wool < 3) return { skillId: "gather.wool" }; + } + return { skillId: "village.build-shelter" }; + }, + emergencyPause, + }, + + { + id: "stone_tier", + title: "Каменные орудия", + narration_ru: "Шахта по камню — нужен каменный сет.", + completed(snap) { + return hasSetItem(snap?.inventory, PICKAXE_STONE); + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + const cobble = inv.cobblestone ?? 0; + if (cobble < 4) return { skillId: "gather.stone" }; + if ((inv.stick ?? 0) < 2) return { skillId: "craft.sticks" }; + if (!hasSetItem(inv, PICKAXE_STONE)) return { skillId: "craft.stone-pickaxe" }; + if (!hasAny(inv, ["stone_axe"])) return { skillId: "craft.stone-axe" }; + return { skillId: "craft.stone-sword" }; + }, + emergencyPause, + }, + + { + id: "food_security", + title: "Запас еды на 16+", + narration_ru: "Делаю ферму или загон — еды должно быть с запасом.", + completed(snap) { + return countAny(snap?.inventory, FOOD_ITEMS) >= 16; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.wheat_seeds ?? 0) > 0 && (snap?.nearbyBlocks?.crops ?? 0) > 0) { + return { skillId: "farm.wheat" }; + } + return { skillId: "survive.acquire-food" }; + }, + emergencyPause, + }, + + { + id: "iron_age", + title: "Железо и печь", + narration_ru: "Иду за железом — пора в шахту глубже.", + completed(snap) { + const inv = snap?.inventory ?? {}; + return (inv.iron_ingot ?? 0) >= 3 || (inv.iron_pickaxe ?? 0) > 0; + }, + suggestSkill(snap) { + // No iron-specific gather skill yet — operator-facing improvement. + return { skillId: "gather.stone" }; + }, + emergencyPause, + }, + + { + id: "settle_base", + title: "Постоянная база", + narration_ru: "Выбираю место под деревню — нужно нормальное основание.", + completed(snap) { + const nb = snap?.nearbyBlocks ?? {}; + return (nb.beds ?? 0) >= 1 && (nb.storage ?? 0) >= 1; + }, + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.chest ?? 0) === 0 && countPlanks(inv) >= 8) return { skillId: "craft.chest" }; + if ((inv.chest ?? 0) > 0) return { skillId: "village.place-chest" }; + return { skillId: "village.choose-base" }; + }, + emergencyPause, + }, + + { + id: "village_grow", + title: "Развивать деревню", + narration_ru: "Стою на ногах — теперь строю по плану деревни.", + completed() { return false; }, // ongoing — never auto-completes + suggestSkill(snap) { + const inv = snap?.inventory ?? {}; + if ((inv.chest ?? 0) > 0 && countAny(inv, FOOD_ITEMS) > 0) { + return { skillId: "village.deposit-surplus" }; + } + return { skillId: "village.build-shelter" }; + }, + emergencyPause, + }, +]); + +export function getStep(id) { + return STORYLINE.find((s) => s.id === id) ?? null; +} + +// Test exports +export const __testing = { + countLogs, countPlanks, countAny, hasAny, hasSetItem, + FOOD_ITEMS, BED_ITEMS, emergencyPause, +}; diff --git a/runtime/goal/storyline.test.js b/runtime/goal/storyline.test.js new file mode 100644 index 0000000..24c3c40 --- /dev/null +++ b/runtime/goal/storyline.test.js @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { STORYLINE, getStep, __testing } from "./storyline.js"; +import { pickCurrentStep, progressSummary, _resetForTest } from "./state.js"; + +function snap(overrides = {}) { + return { + connected: true, + health: 20, + food: 20, + hasFood: false, + inventory: {}, + equipment: { hand: null, head: null, torso: null, legs: null, feet: null }, + nearbyBlocks: {}, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + hostileCount: 0, + closestHostile: null, + _sessionMs: 60_000, + ...overrides, + }; +} + +test("STORYLINE: 11 steps, all have id/title/narration/completed/suggestSkill", () => { + assert.equal(STORYLINE.length, 11); + for (const s of STORYLINE) { + assert.ok(s.id, `step missing id`); + assert.ok(s.title); + assert.ok(s.narration_ru); + assert.equal(typeof s.completed, "function"); + assert.equal(typeof s.suggestSkill, "function"); + } + // Ids unique + const ids = STORYLINE.map((s) => s.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("getStep: lookup by id", () => { + assert.equal(getStep("first_wood").title, "Собрать 8 поленьев"); + assert.equal(getStep("does-not-exist"), null); +}); + +test("emergencyPause: low hp + close hostile → true", () => { + const { emergencyPause } = __testing; + assert.equal(emergencyPause(snap({ health: 4, closestHostile: { name: "zombie", distance: 3 } })), true); + assert.equal(emergencyPause(snap()), false); + assert.equal(emergencyPause(snap({ hazards: { footBlock: "lava" } })), true); + assert.equal(emergencyPause(snap({ food: 0 })), true); +}); + +test("step first_wood: completed when ≥8 logs", () => { + const s = getStep("first_wood"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ inventory: { oak_log: 8 } })), true); + assert.equal(s.completed(snap({ inventory: { oak_log: 4, birch_log: 4 } })), true); +}); + +test("step first_wood: suggest gather.logs if trees nearby, explore.far otherwise", () => { + const s = getStep("first_wood"); + assert.equal(s.suggestSkill(snap({ nearbyBlocks: { logs: 5 } })).skillId, "gather.logs"); + assert.equal(s.suggestSkill(snap()).skillId, "explore.far"); +}); + +test("step first_tools: requires all three wood tools", () => { + const s = getStep("first_tools"); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1 } })), false); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1 } })), false); + assert.equal(s.completed(snap({ inventory: { wooden_pickaxe: 1, wooden_axe: 1, wooden_sword: 1 } })), true); + // Higher tier also counts + assert.equal(s.completed(snap({ inventory: { stone_pickaxe: 1, stone_axe: 1, stone_sword: 1 } })), true); +}); + +test("step first_food: completed at ≥2 food items", () => { + const s = getStep("first_food"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ inventory: { bread: 2 } })), true); +}); + +test("step shelter_minimal: completed when bed placed nearby", () => { + const s = getStep("shelter_minimal"); + assert.equal(s.completed(snap()), false); + assert.equal(s.completed(snap({ nearbyBlocks: { beds: 1 } })), true); +}); + +test("step stone_tier: needs cobblestone first", () => { + const s = getStep("stone_tier"); + assert.equal(s.completed(snap()), false); + assert.equal(s.suggestSkill(snap()).skillId, "gather.stone"); + assert.equal(s.suggestSkill(snap({ inventory: { cobblestone: 6, stick: 4 } })).skillId, "craft.stone-pickaxe"); +}); + +test("village_grow: never auto-completes (ongoing)", () => { + const s = getStep("village_grow"); + assert.equal(s.completed(snap({ inventory: { iron_pickaxe: 1, diamond_pickaxe: 1 } })), false); +}); + +test("pickCurrentStep: disconnected → null", () => { + _resetForTest(); + assert.equal(pickCurrentStep({ connected: false }), null); +}); + +test("pickCurrentStep: fresh spawn → first non-completed step", () => { + _resetForTest(); + const s = snap({ _sessionMs: 5_000, nearbyBlocks: {} }); + const r = pickCurrentStep(s); + assert.ok(r); + // orient_self is the first; with no nearby blocks and short session, + // completed() returns false → picked. + assert.equal(r.step.id, "orient_self"); + assert.equal(r.index, 0); +}); + +test("pickCurrentStep: bot with 8+ logs → first_wood done, picks crafting_basics", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: 3 }, + inventory: { oak_log: 10 }, + })); + assert.ok(r); + assert.equal(r.step.id, "crafting_basics"); + assert.equal(r.completedSteps, 2, "orient_self + first_wood done"); +}); + +test("pickCurrentStep: emergency pauses suggestion", () => { + _resetForTest(); + const r = pickCurrentStep(snap({ + health: 4, + closestHostile: { name: "zombie", distance: 3 }, + nearbyBlocks: { logs: 2 }, + })); + assert.ok(r); + assert.equal(r.emergency, true); + assert.equal(r.suggestion, null, "no concrete suggestion while emergency holds"); +}); + +test("pickCurrentStep: rejects unknown skill ids from suggestSkill", () => { + _resetForTest(); + // Inject a synthetic step with bogus skill — but STORYLINE is frozen, + // so we just verify that real ids are valid (sanity check). + const r = pickCurrentStep(snap({ + _sessionMs: 60_000, + nearbyBlocks: { logs: 2 }, + })); + if (r?.suggestion?.skillId) { + // All real STORYLINE skill ids should be registered. + // (skill-registry imports a frozen list of skills/index.js.) + assert.ok(r.suggestion.skillId.includes("."), "skill id is namespaced"); + } +}); + +test("progressSummary: formats step n/N + skill + emergency tag", () => { + _resetForTest(); + const s1 = progressSummary(snap({ _sessionMs: 5_000 })); + assert.match(s1, /step 1\/11/); + assert.match(s1, /orient_self/); + + _resetForTest(); + const s2 = progressSummary(snap({ + health: 3, + closestHostile: { name: "creeper", distance: 2 }, + })); + assert.match(s2, /EMERGENCY/); +}); diff --git a/runtime/persona/chatter.js b/runtime/persona/chatter.js index 9e02f2a..7fb0e75 100644 --- a/runtime/persona/chatter.js +++ b/runtime/persona/chatter.js @@ -94,6 +94,7 @@ let _last = { threatHostile: null, dayPart: null, noProgressReason: null, + storyStepId: null, }; let _lastNarrationAt = 0; let _narrationTimes = []; @@ -164,6 +165,15 @@ function tick() { _last.noProgressReason = snap.noProgressReason; } + // 4b. Storyline step transition — narrate the *narration_ru* line + // straight from runtime/goal/storyline.js when the step changes. + // This is the bot speaking about its current quest concretely. + const story = snap.storyStep ?? null; + if (story?.step?.id && story.step.id !== _last.storyStepId && story.step.narration_ru) { + maybeNarrateRaw(story.step.narration_ru); + _last.storyStepId = story.step.id; + } + // 5. Milestone done — fires when activeSkill flips to noop and lastResult.ok const last = snap.lastResult; if (last?.ok && last?.code === "done") { @@ -200,13 +210,19 @@ function inferDayPart(snap) { } function maybeNarrate(key) { + const line = pickLine(key); + if (!line) return; + maybeNarrateRaw(line); +} + +function maybeNarrateRaw(line) { + if (!line) return; const now = Date.now(); const hourAgo = now - 3600_000; _narrationTimes = _narrationTimes.filter((t) => t > hourAgo); if (now - _lastNarrationAt < MIN_GAP_MS) return; if (_narrationTimes.length >= MAX_PER_HOUR) return; - const line = pickLine(key); - if (!line) return; + if (line === _lastTemplate) return; const ok = sendChat(line); if (ok) { _lastNarrationAt = now; diff --git a/runtime/reflex.js b/runtime/reflex.js index a8eace4..e386615 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -29,6 +29,7 @@ import { consult as consultAdvice, reportOutcome as reportAdviceOutcome } from " import { tickAdvisor, consumeFreshRecommendation } from "./coach/advisor-trigger.js"; import { markRecommendationApplied, markRecommendationOutcome } from "./knowledge/index.js"; import { pickActiveNeed } from "./manifesto/state.js"; +import { pickCurrentStep } from "./goal/state.js"; import { situationHash } from "./scenario-memory.js"; import { tickModes } from "./modes.js"; @@ -457,6 +458,15 @@ function curriculumReflex(ctx) { ctx.activeNeed = activeNeed; } const manifestoSkillId = activeNeed?.skillId ?? null; + + // v0.3.1 — storyline: the canonical Minecraft survival quest. Gives + // the bot a concrete, narratable next-action ("collect 8 logs", + // "place crafting table"). Storyline yields to manifesto on L0 + // alive emergencies but otherwise its suggestion is preferred over + // the curriculum plan when it picks a registered skill. + const storyStep = ctx.disableStoryline ? null : pickCurrentStep(s); + if (storyStep) ctx.storyStep = storyStep; + const storySkillId = (storyStep && !storyStep.emergency && storyStep.suggestion?.skillId) ? storyStep.suggestion.skillId : null; const metricRecovery = metricRecoverySkill(ctx, plan?.skillId); if (metricRecovery) { ctx.lastCurriculumAt = Date.now(); @@ -495,7 +505,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 && !manifestoSkillId) || wantWander) { + if ((!plan?.skillId && !manifestoSkillId && !storySkillId) || wantWander) { ctx.lastCurriculumAt = Date.now(); const fallbackId = wantWander && consecutiveWanderHints >= 1 ? "explore.far" : "wander"; // v0.2.0-rc.3 — consult advice on the FALLBACK dispatch too. Without @@ -530,10 +540,16 @@ function curriculumReflex(ctx) { return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - // Pick what to dispatch: manifesto wins over curriculum plan because - // it expresses concrete needs rather than abstract "next milestone". - let skillId = manifestoSkillId ?? plan.skillId; - let skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum"; + // Pick what to dispatch. Order: manifesto > storyline > curriculum. + // Manifesto is highest because L0 alive emergencies (lava, low-HP + + // hostile) must override any narrative aspiration. Storyline beats + // curriculum because it expresses a concrete operational subgoal, + // not just "next milestone". + let skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; + let skillSource = manifestoSkillId + ? `manifesto:${activeNeed.need.id}` + : storySkillId ? `storyline:${storyStep.step.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. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 8b255b7..2fb8838 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -51,6 +51,7 @@ function makeCtx({ // runtime/manifesto/state.test.js separately. disableAdvisor = true, // advisor-trigger fires real async LLM calls, // tested directly in advisor-trigger.test.js. + disableStoryline = true, // storyline tested in goal/storyline.test.js } = {}) { const dispatches = []; const ctx = { @@ -65,6 +66,7 @@ function makeCtx({ metrics, disableManifesto, disableAdvisor, + disableStoryline, dispatch(fn, label, opts = {}) { dispatches.push({ fn, label, opts }); }, diff --git a/scripts/show-story.js b/scripts/show-story.js new file mode 100644 index 0000000..282e223 --- /dev/null +++ b/scripts/show-story.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// Operator-facing view of the bot's storyline progress. +// +// Reads the bot's IPC sock if available (live snapshot), else falls +// back to "what would the picker say given an empty inventory". The +// useful form is the live one. +// +// Usage: +// node scripts/show-story.js # live snapshot via IPC +// node scripts/show-story.js --plain # show static catalogue + +import { config as loadDotenv } from "dotenv"; +loadDotenv(); + +import net from "node:net"; +import { STORYLINE } from "../runtime/goal/storyline.js"; +import { pickCurrentStep, progressSummary, _resetForTest } from "../runtime/goal/state.js"; +import { socketPath } from "../runtime/config.js"; + +function plainCatalogue() { + console.log("=== Storyline (canonical Minecraft survival arc) ==="); + for (let i = 0; i < STORYLINE.length; i++) { + const s = STORYLINE[i]; + console.log(` ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}`); + console.log(` → ${s.narration_ru}`); + } +} + +async function fetchSnapshotViaIpc() { + return new Promise((resolve) => { + const sock = net.connect(socketPath); + const buf = []; + const timer = setTimeout(() => { sock.destroy(); resolve(null); }, 1500); + sock.on("connect", () => { + sock.write(JSON.stringify({ kind: "get-status" }) + "\n"); + }); + sock.on("data", (chunk) => buf.push(chunk)); + sock.on("end", () => { + clearTimeout(timer); + try { + const raw = Buffer.concat(buf).toString("utf8").trim(); + const lines = raw.split("\n").filter(Boolean); + for (const ln of lines) { + const obj = JSON.parse(ln); + if (obj?.kind === "status" && obj?.snapshot) { + resolve(obj.snapshot); + return; + } + } + resolve(null); + } catch { resolve(null); } + }); + sock.on("error", () => { clearTimeout(timer); resolve(null); }); + }); +} + +async function main() { + if (process.argv.includes("--plain")) { + plainCatalogue(); + return; + } + + const snap = await fetchSnapshotViaIpc(); + if (!snap) { + console.log("(bot IPC not reachable — showing static catalogue)"); + console.log(""); + plainCatalogue(); + return; + } + + _resetForTest(); + const cur = pickCurrentStep(snap); + console.log(`=== Storyline progress (live snapshot) ===`); + console.log(progressSummary(snap)); + console.log(""); + if (!cur) { + console.log("(disconnected)"); + return; + } + for (let i = 0; i < STORYLINE.length; i++) { + const s = STORYLINE[i]; + const mark = i < cur.index ? "✓" : (i === cur.index ? "→" : " "); + const tag = i === cur.index ? ` (${cur.suggestion?.skillId ?? "-"})${cur.emergency ? " [PAUSED]" : ""}` : ""; + console.log(` ${mark} ${(i + 1).toString().padStart(2)}. ${s.id.padEnd(20)} ${s.title}${tag}`); + } + console.log(""); + console.log(`Inventory keys: ${snap.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"}`); + console.log(`HP ${snap.health ?? "?"} / food ${snap.food ?? "?"} / day=${snap.isDay ? "yes" : "no"}`); +} + +main().catch((e) => { + console.error("ERROR:", e?.message ?? e); + process.exit(1); +}); -- 2.54.0 From 21462dfdb1e82d704302b31596dfdaa1510ed5e1 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 20:39:25 +0300 Subject: [PATCH 3/9] fix(v0.3.1): storyline beats manifesto L1+ (only L0 alive emergencies override) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in live logs after the previous commit deployed: storyline: step 1/11: orient_self → explore.wander advisor-trigger: firing because wedged (planned=survive.acquire-food, ...) Manifesto was still picking survive.acquire-food (L1 food) over the storyline's orient_self → explore.wander. That's the wrong precedence — storyline expresses a *concrete operational subgoal* and L1+ manifesto needs are just "you'd benefit from food" priorities, not emergencies. New dispatch precedence in curriculumReflex: 1. manifesto L0 (alive emergencies: lava, low-HP+hostile, food=0) 2. storyline (concrete narrative subgoal — beats L1+ manifesto) 3. manifesto L1+ (fallback when storyline has no concrete suggestion) 4. curriculum plan (legacy fallback) This way the bot starts following the narrative arc even while manifesto's L1 food is technically unsatisfied — orient_self runs to completion before pursuing food explicitly. Storyline already handles food as step 5 (first_food), so we're not skipping it. Tests: 378 green (+2 priority-ordering tests): - L0 manifesto emergency: upstream reflex (defend/modes) catches before curriculum dispatch - storyline beats manifesto when both have suggestions: well-fed bot with logs → craft.planks (storyline crafting_basics), not gather.logs (manifesto L2) - updated "manifesto fallback" test to require disableStoryline=true Co-Authored-By: Claude Opus 4.7 --- runtime/reflex.js | 33 ++++++++++++++++------- runtime/reflex.test.js | 59 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/runtime/reflex.js b/runtime/reflex.js index e386615..b8c4c39 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -540,16 +540,29 @@ function curriculumReflex(ctx) { return { action: "dispatched", kind: "curriculum-wander", label: "wander" }; } - // Pick what to dispatch. Order: manifesto > storyline > curriculum. - // Manifesto is highest because L0 alive emergencies (lava, low-HP + - // hostile) must override any narrative aspiration. Storyline beats - // curriculum because it expresses a concrete operational subgoal, - // not just "next milestone". - let skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; - let skillSource = manifestoSkillId - ? `manifesto:${activeNeed.need.id}` - : storySkillId ? `storyline:${storyStep.step.id}` - : "curriculum"; + // Pick what to dispatch. Order: + // 1. manifesto L0 (alive emergencies: low HP near hostile, lava + // under foot, food=0) — absolute priority; do NOT let + // storyline overrule a "you're dying" signal. + // 2. storyline — concrete narrative subgoal ("collect 8 logs", + // "craft wooden pickaxe"). Beats manifesto L1+ because the + // ladder needs operational direction, not just "you need food + // → dispatch acquire-food forever". + // 3. manifesto L1+ — fallback when storyline has no concrete + // pursue (e.g. armor levels with pursue=null). + // 4. curriculum plan — legacy fallback. + const manifestoEmergency = activeNeed?.need?.level === 0; + let skillId, skillSource; + if (manifestoEmergency) { + skillId = manifestoSkillId ?? storySkillId ?? plan.skillId; + skillSource = `manifesto:${activeNeed.need.id}`; + } else if (storySkillId) { + skillId = storySkillId; + skillSource = `storyline:${storyStep.step.id}`; + } else { + skillId = manifestoSkillId ?? plan.skillId; + skillSource = manifestoSkillId ? `manifesto:${activeNeed.need.id}` : "curriculum"; + } // v0.3.0 fast-advisor: if a fresh recommendation is sitting on ctx // (the result of a previous tick's async advise() call), use it. diff --git a/runtime/reflex.test.js b/runtime/reflex.test.js index 2fb8838..321cc65 100644 --- a/runtime/reflex.test.js +++ b/runtime/reflex.test.js @@ -242,9 +242,10 @@ test("curriculum dispatches suggested skill by id", () => { assert.ok(typeof dispatches[0].opts.onComplete === "function"); }); -test("manifesto: hungry bot with no food drives survive.acquire-food (overrides curriculum plan)", () => { +test("manifesto: hungry bot with no food drives survive.acquire-food (manifesto fallback when storyline disabled)", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, + disableStoryline: true, snapshot: { connected: true, health: 20, @@ -264,6 +265,62 @@ test("manifesto: hungry bot with no food drives survive.acquire-food (overrides assert.equal(ctx.activeNeed?.need?.id, "food"); }); +test("L0 manifesto emergency: defend/modes layer catches the threat BEFORE curriculum", () => { + // HP=4 + creeper@3m fires `modes` (self_preservation) or defendReflex + // before curriculum even runs — which is the right outcome: alive + // emergencies don't reach the storyline/manifesto branch at all. + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + bot: { entities: { z1: { name: "creeper", height: 1.7, position: { x: 3, y: 64, z: 0, distanceTo: () => 3 } } }, entity: { position: { x: 0, y: 64, z: 0 } } }, + snapshot: { + connected: true, + health: 4, + food: 12, + hasFood: false, + inventory: { oak_log: 10 }, + equipment: {}, + nearbyBlocks: { logs: 4 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + closestHostile: { name: "creeper", distance: 3 }, + threats: [{ name: "creeper", distance: 3, position: { x: 3, y: 64, z: 0 } }], + isDay: true, + curriculum: { plan: { skillId: "gather.logs" } }, + }, + }); + const out = runTick(ctx); + assert.notEqual(out?.kind, "curriculum-skill", "an upstream reflex caught the emergency before curriculum"); +}); + +test("storyline beats manifesto L1+ when both have suggestions", () => { + // Snapshot: well-fed (food=20 + 8 bread → manifesto L1 satisfied, L2 + // tools_wood unmet) AND storyline orient_self complete (HP=20, blocks + // visible). Storyline should drive a wood-tier crafting step, not + // manifesto's gather.logs (which would also be valid but less concrete). + const { ctx, dispatches } = makeCtx({ + disableManifesto: false, + disableStoryline: false, + snapshot: { + connected: true, + health: 20, + food: 20, + hasFood: true, + inventory: { bread: 8, oak_log: 10 }, // logs done, no planks + equipment: {}, + nearbyBlocks: { logs: 3 }, + hazards: { footBlock: "grass_block", belowBlock: "dirt", headBlock: "air" }, + isDay: true, + _sessionMs: 60_000, + curriculum: { plan: { skillId: "explore.far" } }, + }, + }); + const out = runTick(ctx); + assert.equal(out.reflex, "curriculum"); + // Storyline step crafting_basics suggests craft.planks (10 logs, no planks yet). + assert.equal(dispatches[0].label, "craft.planks"); + assert.equal(ctx.storyStep?.step?.id, "crafting_basics"); +}); + test("manifesto: well-fed bot with all wood tools defers to curriculum plan", () => { const { ctx, dispatches } = makeCtx({ disableManifesto: false, -- 2.54.0 From effe9eebbe9469cb3912c7a32d8e1298fa425378 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 20:49:35 +0300 Subject: [PATCH 4/9] feat(tui): fullscreen monitor-only TUI (opencode-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old tui/tui.tsx hotkey-heavy dashboard with a read-only observability screen. Operator actions live in scripts/* now — TUI is for watching, not driving. Layout (top to bottom, all auto-resizing to terminal): 1. Header — MC/IPC status, pos, HP, food, day/night, hostiles 2. Storyline — current step + 11-step quest map (✓/→/○) 3. Activity — last N skill dispatches (colour by outcome) 4. MC Chat — last N chat lines (cyan for bot, yellow for players) 5. Advisor — last N LLM recommendations (trigger + outcome + tokens) 6. Improvements — open requests from knowledge.improvement_requests 7. Footer — 24h token usage + cost in ₽ + q-to-quit Data sources: - IPC sock: snapshot frames, log frames, chat frames (push) - SQLite knowledge.db: advisor_recommendations + improvement_requests polled every 5s (pull) Token cost displayed live using TIMEWEB_PRICE_IN_RUB_PER_M / TIMEWEB_PRICE_OUT_RUB_PER_M env vars (defaults: 101 / 608 for gpt-5.4-mini). Switches: - npm run tui → new monitor (this file) - npm run tui:legacy → old action-driven tui/tui.tsx (kept for now) Implementation notes: - Uses ink + alternate-screen-buffer ANSI for proper "opencode-feel" fullscreen behaviour; restores prior terminal contents on quit. - Skips alt-screen and useInput when stdin/stdout isn't a TTY (smoke tests, piped output) — both gracefully degrade. - Stable React keys via per-event uid counter, avoids reconciler duplicate-key warnings as logs/chat/dispatches stream in. - Resize handled via 1s stdout-dimension poll, NOT direct 'resize' listener (which conflicts with ink's own listener and triggers MaxListenersExceededWarning). Co-Authored-By: Claude Opus 4.7 --- package.json | 3 +- tui/monitor.tsx | 518 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 tui/monitor.tsx diff --git a/package.json b/package.json index d5e48d1..f1f46c5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "agent:resume": "pi -c", "bot": "node runtime/supervisor.js", "bot:bare": "node runtime/bot.js", - "tui": "tsx tui/tui.tsx", + "tui": "tsx tui/monitor.tsx", + "tui:legacy": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.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" diff --git a/tui/monitor.tsx b/tui/monitor.tsx new file mode 100644 index 0000000..f657bc0 --- /dev/null +++ b/tui/monitor.tsx @@ -0,0 +1,518 @@ +/** + * pepa monitor — fullscreen, read-only TUI. + * + * Replaces the old action-heavy tui/tui.tsx with a pure observability + * dashboard inspired by opencode's full-screen layout. No hotkeys to + * dispatch skills / send chat / approve proposals — operator does that + * via scripts/* or by writing to the IPC sock directly. This screen + * just shows what the bot is doing, in colour. + * + * Panels (top → bottom): + * 1. Header — connection / position / hp / food / time + * 2. Storyline — current step + the 11-step quest map + * 3. Activity — last N dispatches (colour-coded by outcome) + * + MC chat — last N lines + * 4. Advisor — last 6 LLM recommendations (trigger / outcome / tokens) + * + Improvements — open queue from improvement_requests + * 5. Footer — token usage today, advisor stats, q to quit + * + * Live data sources: + * - IPC sock (snapshot frames, log frames, chat frames) + * - SQLite (knowledge.db) polled every 5s for advisor + improvements + */ + +import React, { useEffect, useReducer, useState } from "react"; +import { render, Box, Text, useApp, useInput, useStdout } from "ink"; +import { createIpcClient } from "./ipc-client.js"; +import { EVENT_TYPES } from "../runtime/ipc-protocol.js"; +import { stateDir } from "../runtime/config.js"; +import { initKnowledge, isAvailable as knowledgeReady, recentRecommendations, listImprovements, recommendationStats } from "../runtime/knowledge/index.js"; + +// --- types ------------------------------------------------------------------ + +type LogEntry = { ts: string; level: string; source: string; text: string }; +type ChatEntry = { uid: number; ts: string; from: string; text: string; kind: string }; +type Snapshot = Record; +type Dispatch = { uid: number; ts: number; kind: "start" | "end"; label: string; ok?: boolean; code?: string; detail?: string }; + +let _uidSeq = 0; +function nextUid() { _uidSeq += 1; return _uidSeq; } + +type State = { + connectedIpc: boolean; + snapshot: Snapshot; + logs: LogEntry[]; + chat: ChatEntry[]; + dispatches: Dispatch[]; + startedAt: number; +}; + +type Action = + | { type: "ipc-connected" } + | { type: "ipc-disconnected" } + | { type: "snapshot"; payload: Snapshot } + | { type: "log"; payload: LogEntry } + | { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string }; + +const MAX_LOGS = 200; +const MAX_CHAT = 50; +const MAX_DISPATCHES = 30; + +// --- reducer ---------------------------------------------------------------- + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "ipc-connected": + return { ...state, connectedIpc: true }; + case "ipc-disconnected": + return { ...state, connectedIpc: false }; + case "snapshot": + return { ...state, snapshot: action.payload }; + case "log": { + const logs = [...state.logs, action.payload].slice(-MAX_LOGS); + // also derive a dispatches view: lines like "→ label" or "← label ok/fail (...)" + const dispatches = extractDispatch(state.dispatches, action.payload); + return { ...state, logs, dispatches }; + } + case "chat": { + const chat = [...state.chat, { uid: nextUid(), ts: action.ts, ...action.payload }].slice(-MAX_CHAT); + return { ...state, chat }; + } + default: + return state; + } +} + +function extractDispatch(prev: Dispatch[], log: LogEntry): Dispatch[] { + if (log.source !== "dispatch") return prev; + const ts = Date.parse(log.ts) || Date.now(); + // "→ label" — skill starting + const startMatch = /^→\s+(\S+)/.exec(log.text); + if (startMatch) { + return [...prev, { uid: nextUid(), ts, kind: "start", label: startMatch[1] }].slice(-MAX_DISPATCHES); + } + // "← label ok/fail (...)" — skill ending + const endMatch = /^←\s+(\S+)\s+(ok|fail)(?:\s+\((.*)\))?/.exec(log.text); + if (endMatch) { + const [, label, outcome, detail] = endMatch; + return [...prev, { uid: nextUid(), ts, kind: "end", label, ok: outcome === "ok", detail }].slice(-MAX_DISPATCHES); + } + return prev; +} + +// --- helpers ---------------------------------------------------------------- + +function formatAge(ts: number) { + const dt = Math.max(0, Date.now() - ts); + if (dt < 60_000) return `${Math.floor(dt / 1000)}s`; + if (dt < 3600_000) return `${Math.floor(dt / 60_000)}m`; + const h = Math.floor(dt / 3600_000); + const m = Math.floor((dt % 3600_000) / 60_000); + return `${h}h${m}m`; +} + +function formatDuration(ms: number) { + const s = Math.floor(ms / 1000) % 60; + const m = Math.floor(ms / 60_000) % 60; + const h = Math.floor(ms / 3600_000); + if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`; + if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`; + return `${s}s`; +} + +function shortTime(ts: number) { + const d = new Date(ts); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`; +} + +function hpColor(hp: number | undefined) { + if (hp == null) return "gray"; + if (hp <= 5) return "red"; + if (hp <= 12) return "yellow"; + return "green"; +} +function foodColor(food: number | undefined) { + if (food == null) return "gray"; + if (food <= 4) return "red"; + if (food <= 10) return "yellow"; + return "green"; +} + +// --- components ------------------------------------------------------------- + +function Header({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) { + const pos = snapshot.position + ? `(${Math.round(snapshot.position.x)}, ${Math.round(snapshot.position.y)}, ${Math.round(snapshot.position.z)})` + : "?"; + const hp = snapshot.health; + const food = snapshot.food; + const day = snapshot.isDay ? "☀ day" : "🌙 night"; + const session = formatDuration(Date.now() - startedAt); + const mcOnline = snapshot.connected; + return ( + + + pepa-monitor + · + {mcOnline ? "● MC" : "○ MC"} + · + {connectedIpc ? "● IPC" : "○ IPC"} + · + session {session} + · + user {snapshot.username ?? "?"} + + + pos {pos} + + HP + {hp ?? "?"}/20 + + food + {food ?? "?"}/20 + + {day} + hostiles + 0 ? "red" : "gray"}>{snapshot.hostileCount ?? 0} + {snapshot.closestHostile ? closest {snapshot.closestHostile.name}@{snapshot.closestHostile.distance}b : null} + + + ); +} + +function StorylinePanel({ snapshot, width }: { snapshot: Snapshot; width: number }) { + const story = snapshot.storyStep; + if (!story) { + return ( + + storyline not available (bot offline or storyline disabled) + + ); + } + const STEPS = [ + "orient_self", "first_wood", "crafting_basics", "first_tools", "first_food", + "shelter_minimal", "stone_tier", "food_security", "iron_age", "settle_base", "village_grow", + ]; + const TITLES: Record = { + orient_self: "Понять где я", + first_wood: "Собрать 8 поленьев", + crafting_basics: "Сделать верстак и палки", + first_tools: "Деревянные орудия", + first_food: "Найти первую еду", + shelter_minimal: "Простой шелтер с кроватью", + stone_tier: "Каменные орудия", + food_security: "Запас еды на 16+", + iron_age: "Железо и печь", + settle_base: "Постоянная база", + village_grow: "Развивать деревню", + }; + const idx = story.index ?? 0; + const cur = story.step; + const want = story.suggestion?.skillId; + const emergency = story.emergency; + return ( + + + storyline + step + {idx + 1}/11 + + {cur?.id} + {cur?.title} + {emergency ? [EMERGENCY PAUSE] : null} + + {want ? ( + + wants: + {want} + + ) : null} + + {STEPS.map((id, i) => { + const done = i < idx; + const current = i === idx; + const mark = done ? "✓" : current ? "→" : "○"; + const color = done ? "green" : current ? "yellow" : "gray"; + return ( + + {` ${mark} ${(i + 1).toString().padStart(2)}. ${id.padEnd(18)} ${TITLES[id] ?? ""}`} + + ); + })} + + + ); +} + +function ActivityPanel({ dispatches, width, height }: { dispatches: Dispatch[]; width: number; height: number }) { + const visible = dispatches.slice(-height); + return ( + + Activity (last {visible.length}) + {visible.map((d) => { + if (d.kind === "start") { + return ( + + {shortTime(d.ts)} → {d.label} + + ); + } + const color = d.ok ? "green" : "red"; + const tail = d.detail ? ` (${String(d.detail).slice(0, 30)})` : ""; + return ( + + {shortTime(d.ts)} + ← {d.label} {d.ok ? "ok" : "fail"} + {tail} + + ); + })} + {visible.length === 0 ? (waiting for activity…) : null} + + ); +} + +function ChatPanel({ chat, width, height }: { chat: ChatEntry[]; width: number; height: number }) { + const visible = chat.slice(-height); + return ( + + MC Chat (last {visible.length}) + {visible.map((c) => ( + + {c.ts?.slice(11, 16) ?? ""} + + {c.from}: + + {c.text.slice(0, width - 12)} + + ))} + {visible.length === 0 ? (no chat yet…) : null} + + ); +} + +function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; height: number }) { + const visible = recs.slice(0, height); + return ( + + Advisor (last {visible.length}) + {visible.map((r) => { + const ok = r.outcome_ok; + const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗"; + const outcomeColor = ok == null ? "gray" : ok ? "green" : "red"; + const tail = `${r.tokens_in ?? "?"}/${r.tokens_out ?? "?"}t ${r.latency_ms ?? "?"}ms`; + return ( + + + {outcomeMark} + {r.trigger_reason} + + {r.recommended_skill ?? r.action} + + {tail}{r.rationale ? ` · ${String(r.rationale).slice(0, width - 14)}` : ""} + + ); + })} + {visible.length === 0 ? (no advisor calls yet) : null} + + ); +} + +function ImprovementsPanel({ items, width, height }: { items: any[]; width: number; height: number }) { + const visible = items.slice(0, height); + return ( + + Improvements (open: {items.length}) + {visible.map((r) => ( + + + #{r.id} + P{r.priority} + ×{r.votes} + {String(r.title).slice(0, width - 14)} + + {r.description ? ( + {String(r.description).slice(0, width - 8)} + ) : null} + + ))} + {visible.length === 0 ? (no improvement requests yet) : null} + + ); +} + +function Footer({ stats, width }: { stats: any[]; width: number }) { + const total = stats.reduce( + (acc, s) => ({ + calls: acc.calls + (s.total ?? 0), + succ: acc.succ + (s.succeeded ?? 0), + fail: acc.fail + (s.failed ?? 0), + in: acc.in + (s.avg_in ?? 0) * (s.total ?? 0), + out: acc.out + (s.avg_out ?? 0) * (s.total ?? 0), + }), + { calls: 0, succ: 0, fail: 0, in: 0, out: 0 }, + ); + const priceInRub = Number(process.env.TIMEWEB_PRICE_IN_RUB_PER_M) || 101; + const priceOutRub = Number(process.env.TIMEWEB_PRICE_OUT_RUB_PER_M) || 608; + const costRub = (total.in * priceInRub + total.out * priceOutRub) / 1_000_000; + return ( + + + Last 24h: + {total.calls} + advisor calls ( + {total.succ} ok + / + {total.fail} fail + ) tokens + {Math.round(total.in / 1000)}K + in / + {Math.round(total.out / 1000)}K + out ≈ + {costRub.toFixed(2)} ₽ + + + q — quit (bot keeps running) + + + ); +} + +// --- main app --------------------------------------------------------------- + +function App() { + const { exit } = useApp(); + const { stdout } = useStdout(); + // Sample stdout dimensions periodically. Subscribing directly to + // stdout.on('resize') from a React hook conflicts with ink's own + // listener and produces "MaxListenersExceededWarning" / reconciler + // errors. A 1s poll is cheap and good enough — terminals don't + // resize often. + const [cols, setCols] = useState((stdout as any)?.columns ?? 120); + const [rows, setRows] = useState((stdout as any)?.rows ?? 30); + useEffect(() => { + const t = setInterval(() => { + setCols((stdout as any)?.columns ?? 120); + setRows((stdout as any)?.rows ?? 30); + }, 1000); + return () => clearInterval(t); + }, [stdout]); + + const [state, dispatch] = useReducer(reducer, { + connectedIpc: false, + snapshot: {}, + logs: [], + chat: [], + dispatches: [], + startedAt: Date.now(), + }); + + const [client] = useState(() => createIpcClient()); + const [knowledgeOk, setKnowledgeOk] = useState(false); + const [recs, setRecs] = useState([]); + const [improvements, setImprovements] = useState([]); + const [stats, setStats] = useState([]); + + // IPC + useEffect(() => { + const onConnected = () => dispatch({ type: "ipc-connected" }); + const onDisconnected = () => dispatch({ type: "ipc-disconnected" }); + const onFrame = (frame: any) => { + switch (frame.type) { + case EVENT_TYPES.STATUS: dispatch({ type: "snapshot", payload: frame.payload }); break; + case EVENT_TYPES.LOG: dispatch({ type: "log", payload: frame.payload }); break; + case EVENT_TYPES.CHAT: dispatch({ type: "chat", payload: frame.payload, ts: frame.ts }); break; + case EVENT_TYPES.HELLO: + if (frame.payload?.snapshot) dispatch({ type: "snapshot", payload: frame.payload.snapshot }); + if (frame.payload?.recentLogs) { + for (const lg of frame.payload.recentLogs) dispatch({ type: "log", payload: lg }); + } + break; + } + }; + (client as any).on("connected", onConnected); + (client as any).on("disconnected", onDisconnected); + (client as any).on("frame", onFrame); + return () => { + (client as any).off("connected", onConnected); + (client as any).off("disconnected", onDisconnected); + (client as any).off("frame", onFrame); + client.close(); + }; + }, [client]); + + // Knowledge DB init + polling + useEffect(() => { + let mounted = true; + (async () => { + try { + await initKnowledge({ stateDir }); + if (!mounted) return; + setKnowledgeOk(knowledgeReady()); + } catch {} + })(); + const poll = () => { + if (!knowledgeReady()) return; + try { + setRecs(recentRecommendations({ limit: 12 })); + setImprovements(listImprovements({ status: "open", limit: 12 })); + setStats(recommendationStats({ sinceHours: 24 })); + } catch {} + }; + poll(); + const t = setInterval(poll, 5000); + return () => { mounted = false; clearInterval(t); }; + }, [knowledgeOk]); + + // useInput requires TTY raw mode; skip it when stdin isn't a TTY + // (e.g. when piped during a smoke test). Real `npm run tui` is always TTY. + const isTty = !!process.stdin.isTTY; + if (isTty) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useInput((input, key) => { + if (input === "q" || (key.ctrl && input === "c") || key.escape) { + client.close(); + exit(); + } + }); + } + + // Layout math: full-width header & storyline, then split 50/50. + const totalWidth = Math.max(80, cols); + const halfWidth = Math.floor(totalWidth / 2); + const totalRows = Math.max(24, rows); + // rough allocation: header 4, storyline ~14, footer 4. Rest split for middle panels. + const middleRows = Math.max(8, Math.floor((totalRows - 4 - 14 - 4) / 2)); + + return ( + +
+ + + + + + + + + +