* 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>
1151 lines
42 KiB
JavaScript
1151 lines
42 KiB
JavaScript
// Long-running Mineflayer process. Owns:
|
||
// - the MC TCP connection + reconnect policy
|
||
// - the reflex tick loop (no LLM in hot path)
|
||
// - the IPC server for TUI clients
|
||
// - on-demand Pi-headless escalation (manual or automatic)
|
||
//
|
||
// MC chat is dialog-only (see plans/autonomous-survival-bot-prd.md, FR1).
|
||
// Player/operator chat may produce a social reply but never dispatches a
|
||
// movement/build/mining task. TUI is the only local control plane.
|
||
//
|
||
// Lifecycle: started by `npm run bot`. Connects to MC, spawns IPC server,
|
||
// ticks every TICK_INTERVAL_SECONDS, broadcasts STATUS to clients each tick.
|
||
// SIGINT / SIGTERM: graceful disconnect + socket cleanup + exit.
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import mineflayer from "mineflayer";
|
||
|
||
import { config, stateDir, redactedConfig } from "./config.js";
|
||
import { info, warn, error } from "./log.js";
|
||
import { snapshot as buildSnapshot } from "./perceive.js";
|
||
import { runTick } from "./reflex.js";
|
||
import { createIpcServer } from "./ipc-server.js";
|
||
import { askPi } from "./pi-bridge.js";
|
||
import { COMMAND_TYPES, EVENT_TYPES } from "./ipc-protocol.js";
|
||
import {
|
||
readCurrentTask,
|
||
writeCurrentTask,
|
||
clearCurrentTask,
|
||
appendDiary,
|
||
writeProposal,
|
||
listProposals,
|
||
readProposal,
|
||
approveProposal,
|
||
writeEscalation,
|
||
readDiaryTail,
|
||
} from "./state-store.js";
|
||
import { startAutoImprover } from "./auto-improve.js";
|
||
import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./planner.js";
|
||
import { computeState, STATES } from "./state.js";
|
||
import { createNoProgressDetector } from "./no-progress.js";
|
||
import { maybeStartViewer } from "./viewer.js";
|
||
import { createPathfinderWatchdog } from "./pathfinder-watchdog.js";
|
||
import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
|
||
import { listLocations } from "./locations.js";
|
||
import { runSkill } from "./skills/index.js";
|
||
import { classifyIntent, INTENTS } from "./social/intent.js";
|
||
import { generateReply } from "./social/reply.js";
|
||
import { createChatMemory } from "./social/memory.js";
|
||
import { appendChat as appendChatHistory } from "./social/chat-history.js";
|
||
import { piReply } from "./social/reply-pi.js";
|
||
import { openConversation, peekConversation, listConversations } from "./social/conversation.js";
|
||
import { takeScreenshot } from "./viewer.js";
|
||
import { createStuckIncidentDetector, attachCritique } from "./stuck-incident.js";
|
||
import { requestCritique } from "./critic.js";
|
||
import { createSkillMetrics } from "./skill-metrics.js";
|
||
import { createWorldJournal } from "./world-journal.js";
|
||
import { createScenarioMemory, situationHash } from "./scenario-memory.js";
|
||
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");
|
||
|
||
// Knowledge subsystem boots in the background. If better-sqlite3 isn't
|
||
// installed the call returns false and every knowledge API becomes a
|
||
// safe no-op. See docs/v0.2.0-self-learning.md.
|
||
initKnowledge({ stateDir }).catch((e) => warn("knowledge", `init failed: ${e?.message ?? e}`));
|
||
|
||
// Auto-escalation tunables. With tick=3s, 20 noops ≈ 1 minute idle before we
|
||
// even consider asking Pi. Cooldown prevents spamming the LLM when the bot
|
||
// is permanently stuck on the same situation.
|
||
const ESCALATE_AFTER_NOOPS = 20;
|
||
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;
|
||
let shuttingDown = false;
|
||
let lastSnapshot = { connected: false };
|
||
|
||
let consecutiveNoops = 0;
|
||
let lastEscalationAt = 0;
|
||
|
||
// Observability state — surfaced in every STATUS snapshot so the TUI (and
|
||
// future Telegram/diary surfaces) can answer "what is the bot doing and why
|
||
// isn't it doing more?" without parsing the log stream.
|
||
const noProgress = createNoProgressDetector();
|
||
const stuckIncident = createStuckIncidentDetector({
|
||
thresholdMs: config.stuckThresholdMs,
|
||
cooldownMs: config.stuckCooldownMs,
|
||
});
|
||
const skillMetrics = createSkillMetrics();
|
||
const worldJournal = createWorldJournal();
|
||
const scenarioMemory = createScenarioMemory();
|
||
const ownedBlocks = createOwnedBlocksLedger();
|
||
let lastResult = null; // { label, ok, code, detail, ts }
|
||
let lastFailureAt = 0;
|
||
let lastPlanReadAt = 0;
|
||
let cachedMilestone = null;
|
||
let cachedPlanExists = false;
|
||
const MILESTONE_CACHE_MS = 30_000;
|
||
|
||
// Reflex context — passed into reflex.js every tick. Mutable across ticks.
|
||
// Memory stores (journal + memory) live here so skill code can consult
|
||
// them directly — gather.* can preferentially target known log positions,
|
||
// deposit-surplus can mark the chest it placed, etc.
|
||
const reflexCtx = {
|
||
bot: null,
|
||
snapshot: lastSnapshot,
|
||
busy: false,
|
||
currentActionLabel: null,
|
||
idleCounter: 0,
|
||
lastEatAt: 0,
|
||
lastSleepAttemptAt: 0,
|
||
// Tracks repeated failure of the same labelled action — triggers a proposal.
|
||
recentFailures: [], // [{label, detail, ts}], capped at 10
|
||
dispatch: dispatchAction,
|
||
journal: worldJournal,
|
||
memory: scenarioMemory,
|
||
metrics: skillMetrics,
|
||
owned: ownedBlocks,
|
||
};
|
||
|
||
let chatTimestamps = [];
|
||
const CHAT_WINDOW_MS = 60_000;
|
||
|
||
let ipc;
|
||
|
||
// ---- chat rate limit -------------------------------------------------------
|
||
|
||
function chatRateAllowed() {
|
||
const now = Date.now();
|
||
chatTimestamps = chatTimestamps.filter((t) => now - t < CHAT_WINDOW_MS);
|
||
if (chatTimestamps.length >= config.chatRateLimitPerMin) return false;
|
||
chatTimestamps.push(now);
|
||
return true;
|
||
}
|
||
|
||
function botChat(text) {
|
||
if (!bot) return;
|
||
if (!chatRateAllowed()) {
|
||
warn("chat", `dropped chat (rate-limited): ${text.slice(0, 60)}`);
|
||
return;
|
||
}
|
||
bot.chat(text);
|
||
}
|
||
|
||
// ---- auth ------------------------------------------------------------------
|
||
|
||
function hasJoinedBefore() {
|
||
return fs.existsSync(JOINED_FLAG);
|
||
}
|
||
|
||
function markJoinedBefore() {
|
||
try {
|
||
fs.writeFileSync(JOINED_FLAG, new Date().toISOString());
|
||
} catch (e) {
|
||
warn("auth", `could not write joined flag: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function maybeHandleAuthPrompt(text) {
|
||
if (!bot || !config.authmePassword) return;
|
||
const lower = text.toLowerCase();
|
||
const sawRegister = lower.includes("/register");
|
||
const sawLogin = lower.includes("/login");
|
||
if (!sawRegister && !sawLogin) return;
|
||
|
||
const cmd = sawLogin ? "login" : hasJoinedBefore() ? "login" : "register";
|
||
if (cmd === "register") {
|
||
bot.chat(`/register ${config.authmePassword} ${config.authmePassword}`);
|
||
info("auth", "sent /register (password redacted)");
|
||
} else {
|
||
bot.chat(`/login ${config.authmePassword}`);
|
||
info("auth", "sent /login (password redacted)");
|
||
}
|
||
markJoinedBefore();
|
||
}
|
||
|
||
// ---- action dispatch -------------------------------------------------------
|
||
|
||
// Reflexes call this to fire an async action without blocking the tick.
|
||
// Sets busy=true, runs fn, clears busy when done; optional onComplete callback
|
||
// receives the action's { ok, detail } result.
|
||
// Pull worldDelta fields written by skills (e.g. {choppedAt, logType,
|
||
// minedAt, blockType, gotWool, baseAt, shelterAt, depositedTotal,
|
||
// plantedAt, harvestedAt, tilledAt}) and turn them into journal lines.
|
||
// Any unknown delta is silently skipped — skills can extend the world
|
||
// journal without bot.js needing to know each schema.
|
||
function recordWorldDeltaToJournal(label, res, snapshot) {
|
||
const wd = res?.worldDelta;
|
||
if (!wd || typeof wd !== "object") return;
|
||
try {
|
||
if (wd.choppedAt) worldJournal.append({ kind: "chopped", name: wd.logType ?? "log", at: wd.choppedAt });
|
||
if (wd.minedAt) worldJournal.append({ kind: "chopped", name: wd.blockType ?? "stone", at: wd.minedAt });
|
||
if (wd.placedAt) worldJournal.append({ kind: "placed", name: wd.placedType ?? "block", at: wd.placedAt });
|
||
if (wd.baseAt) worldJournal.append({ kind: "base", name: "base", at: wd.baseAt });
|
||
if (wd.shelterAt) worldJournal.append({ kind: "shelter", name: "shelter", at: wd.shelterAt });
|
||
if (wd.chestAt) worldJournal.append({ kind: "chest", name: "storage", at: wd.chestAt });
|
||
if (wd.fledTo) worldJournal.append({ kind: "retreat", name: label, at: wd.fledTo });
|
||
if (wd.acquiredFood && snapshot?.position) worldJournal.append({ kind: "food", name: wd.source ?? "food", at: snapshot.position });
|
||
if (wd.plantedAt) worldJournal.append({ kind: "farm", name: "planted", at: wd.plantedAt });
|
||
if (wd.harvestedAt) worldJournal.append({ kind: "farm", name: "harvested", at: wd.harvestedAt });
|
||
if (wd.tilledAt) worldJournal.append({ kind: "farm", name: "tilled", at: wd.tilledAt });
|
||
// failures: blacklisted / no_target — log a dead-end at current pos
|
||
if (res?.code === "no_target" && snapshot?.position) {
|
||
worldJournal.append({
|
||
kind: "dead_end",
|
||
name: label,
|
||
reason: res?.detail ? String(res.detail).slice(0, 80) : "no_target",
|
||
at: snapshot.position,
|
||
});
|
||
}
|
||
if (res?.code === "silent_dig_failure" && snapshot?.position) {
|
||
worldJournal.append({
|
||
kind: "dead_end",
|
||
name: label,
|
||
reason: "silent_dig_failure",
|
||
at: wd?.blacklisted ?? snapshot.position,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
warn("journal", `append from ${label} failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function dispatchAction(fn, label, opts = {}) {
|
||
if (reflexCtx.busy) {
|
||
warn("dispatch", `tried to dispatch ${label} while busy with ${reflexCtx.currentActionLabel}`);
|
||
return;
|
||
}
|
||
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
|
||
// (partial) effect.
|
||
const startSnap = lastSnapshot;
|
||
const startSituation = situationHash(startSnap);
|
||
// current-task is a resume anchor — keep it small. Embedding the full
|
||
// perception snapshot blows the file up to ~3 KB per write × every action.
|
||
writeCurrentTask({ label, status: "in_progress", position: startSnap.position });
|
||
info("dispatch", `→ ${label}`);
|
||
Promise.resolve()
|
||
.then(() => fn())
|
||
.then((res) => {
|
||
const ok = !!res?.ok;
|
||
info(
|
||
"dispatch",
|
||
`← ${label} ${ok ? "ok" : "fail"}${res?.detail ? ` (${JSON.stringify(res.detail).slice(0, 80)})` : ""}`,
|
||
);
|
||
writeCurrentTask({ label, status: ok ? "completed" : "failed", detail: res?.detail });
|
||
lastResult = {
|
||
label,
|
||
ok,
|
||
code: res?.code ?? (ok ? "done" : classifyFailure(res?.detail)),
|
||
detail: res?.detail,
|
||
ts: Date.now(),
|
||
};
|
||
skillMetrics.record(label, ok, { code: lastResult.code, durationMs: Date.now() - startedAt });
|
||
scenarioMemory.record({
|
||
skillId: label,
|
||
situation: startSituation,
|
||
code: lastResult.code,
|
||
ok,
|
||
detail: res?.detail,
|
||
});
|
||
recordWorldDeltaToJournal(label, res, startSnap);
|
||
stuckIncident.noteResult(res);
|
||
if (!ok) {
|
||
lastFailureAt = lastResult.ts;
|
||
recordFailure(label, res?.detail);
|
||
} else {
|
||
clearRecentFailures(label);
|
||
}
|
||
|
||
if (opts.onComplete) {
|
||
try {
|
||
opts.onComplete(res ?? { ok: false, detail: "no result" });
|
||
} catch (e) {
|
||
warn("dispatch", `onComplete threw for ${label}: ${e.message}`);
|
||
}
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
warn("dispatch", `${label} threw: ${e?.message ?? e}`);
|
||
writeCurrentTask({ label, status: "threw", detail: String(e?.message ?? e) });
|
||
lastResult = {
|
||
label,
|
||
ok: false,
|
||
code: "threw",
|
||
detail: String(e?.message ?? e),
|
||
ts: Date.now(),
|
||
};
|
||
skillMetrics.record(label, false, { code: "threw", durationMs: Date.now() - startedAt });
|
||
scenarioMemory.record({
|
||
skillId: label,
|
||
situation: startSituation,
|
||
code: "threw",
|
||
ok: false,
|
||
detail: String(e?.message ?? e),
|
||
});
|
||
lastFailureAt = lastResult.ts;
|
||
recordFailure(label, String(e?.message ?? e));
|
||
})
|
||
.finally(() => {
|
||
reflexCtx.busy = false;
|
||
reflexCtx.currentActionLabel = null;
|
||
if (reflexCtx.currentAbort === dispatchAbort) {
|
||
reflexCtx.currentAbort = null;
|
||
reflexCtx.abortSignal = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- failure tracking + proposal detection --------------------------------
|
||
//
|
||
// A proposal is a request to the LLM to patch the codebase. They cost tokens
|
||
// and may produce risky patches that need rolling back. We file them ONLY for
|
||
// failures that genuinely look like bugs the reflex layer can't handle on its
|
||
// own. Everything else is a feature gap the script should solve via reflex
|
||
// chain reordering, cooldowns, or new primitives.
|
||
|
||
const PROPOSAL_THRESHOLD = 5; // raised from 3 to dampen spam
|
||
let lastProposalAt = 0;
|
||
const PROPOSAL_COOLDOWN_MS = 30 * 60 * 1000;
|
||
|
||
// Detail substrings that mean "this is a known feature gap, the bot handles
|
||
// it via reflex routing already". Don't file a proposal — the bot will switch
|
||
// strategies on its own. If something here is wrong, fix the routing.
|
||
const NORMAL_FAILURE_SUBSTRINGS = [
|
||
"no reachable log",
|
||
"no log within",
|
||
"no bed in range",
|
||
"no food in inventory",
|
||
"no target in reach",
|
||
"rate-limited",
|
||
"can't see you nearby",
|
||
"returned false",
|
||
"no result",
|
||
];
|
||
|
||
// Detail substrings that look like a real bug — patch-worthy.
|
||
const BUG_FAILURE_SUBSTRINGS = [
|
||
"TypeError",
|
||
"ReferenceError",
|
||
"Cannot read properties",
|
||
"is not a function",
|
||
"is not iterable",
|
||
"is not defined",
|
||
"unknown block",
|
||
"unknown item",
|
||
];
|
||
|
||
function classifyFailure(detail) {
|
||
const s = String(detail ?? "");
|
||
if (BUG_FAILURE_SUBSTRINGS.some((sub) => s.includes(sub))) return "bug";
|
||
if (NORMAL_FAILURE_SUBSTRINGS.some((sub) => s.includes(sub))) return "feature-gap";
|
||
if (s.includes("timed out")) return "timeout";
|
||
return "other";
|
||
}
|
||
|
||
function recordFailure(label, detail) {
|
||
const kind = classifyFailure(detail);
|
||
reflexCtx.recentFailures.push({ ts: Date.now(), label, detail, kind });
|
||
if (reflexCtx.recentFailures.length > 20) reflexCtx.recentFailures.shift();
|
||
maybeFileProposal(label);
|
||
}
|
||
|
||
function clearRecentFailures(label) {
|
||
reflexCtx.recentFailures = reflexCtx.recentFailures.filter((f) => f.label !== label);
|
||
}
|
||
|
||
function maybeFileProposal(label) {
|
||
// Same-label trailing run.
|
||
const trailing = [];
|
||
for (let i = reflexCtx.recentFailures.length - 1; i >= 0; i--) {
|
||
const f = reflexCtx.recentFailures[i];
|
||
if (f.label === label) trailing.push(f);
|
||
else break;
|
||
}
|
||
if (trailing.length < PROPOSAL_THRESHOLD) return;
|
||
if (Date.now() - lastProposalAt < PROPOSAL_COOLDOWN_MS) return;
|
||
|
||
// Only file when the run is dominated by bug-class failures (any single
|
||
// bug counts) OR persistent timeouts on the same operation. Feature gaps
|
||
// are skipped — the reflex layer should re-route, not the LLM.
|
||
const anyBug = trailing.some((f) => f.kind === "bug");
|
||
const allTimeout = trailing.every((f) => f.kind === "timeout");
|
||
if (!anyBug && !allTimeout) return;
|
||
|
||
lastProposalAt = Date.now();
|
||
|
||
const summary = `${label} failed ${trailing.length}× in a row (${anyBug ? "bug" : "persistent timeout"})`;
|
||
const slimSnapshot = lastSnapshot && {
|
||
position: lastSnapshot.position,
|
||
health: lastSnapshot.health,
|
||
food: lastSnapshot.food,
|
||
inventory: lastSnapshot.inventory,
|
||
isDay: lastSnapshot.isDay,
|
||
closestHostile: lastSnapshot.closestHostile,
|
||
dimension: lastSnapshot.dimension,
|
||
};
|
||
const body = [
|
||
`# Repeated failure: ${label}`,
|
||
"",
|
||
`Class: **${anyBug ? "bug" : "persistent timeout"}**.`,
|
||
"",
|
||
"## What happened",
|
||
"",
|
||
`The reflex layer dispatched \`${label}\` ${trailing.length} times in succession without a single success.`,
|
||
"",
|
||
"## Most recent failures",
|
||
"",
|
||
...trailing
|
||
.slice(0, 5)
|
||
.map(
|
||
(f, i) =>
|
||
`${i + 1}. \`${new Date(f.ts).toISOString()}\` [${f.kind}] ${JSON.stringify(f.detail).slice(0, 200)}`,
|
||
),
|
||
"",
|
||
"## Slim snapshot",
|
||
"",
|
||
"```json",
|
||
JSON.stringify(slimSnapshot, null, 2),
|
||
"```",
|
||
"",
|
||
"## Constraints for the patch",
|
||
"",
|
||
"- Touch only files under `runtime/`. Don't touch `extensions/`, `tui/`, or any docs.",
|
||
"- Don't introduce new npm dependencies.",
|
||
"- Don't change `.env` or anything in `state/`.",
|
||
"- Don't push, don't open a PR. Commit on the current branch only.",
|
||
"- Prefer the smallest viable fix. A 3-line guard is better than a 30-line refactor.",
|
||
"- If the failure is genuinely irrecoverable (server-side, not code), document it in a code comment and exit 1.",
|
||
].join("\n");
|
||
|
||
const { filename } = writeProposal({ kind: `repeated-fail-${label}`, summary, body });
|
||
warn("proposal", `filed ${filename}: ${summary}`);
|
||
appendDiary(`proposal filed: ${filename} (${summary})`);
|
||
}
|
||
|
||
// Async pre-flight critic — wrapper around writeProposal that asks Pi
|
||
// "did the bot actually fail?" first. Runs detached so reflex keeps
|
||
// ticking while critic burns 1–60s. If critic.success=true we drop the
|
||
// proposal entirely; otherwise the critique is spliced into the body.
|
||
async function filePostCritique(incident, channel) {
|
||
const critique = await requestCritique({
|
||
snapshot: lastSnapshot,
|
||
lastResult,
|
||
scenarioTail: scenarioMemory.recentTailFor({ n: 12 }),
|
||
milestone: lastSnapshot?.curriculum?.milestone?.title,
|
||
kind: incident.kind,
|
||
});
|
||
if (critique?.success) {
|
||
info(channel, `critic says already-recovered (${(critique.reasoning || "").slice(0, 100)}) — skipping proposal`);
|
||
return;
|
||
}
|
||
try {
|
||
const body = attachCritique(incident.body, critique);
|
||
const { filename } = writeProposal({
|
||
kind: incident.kind,
|
||
summary: incident.summary,
|
||
body,
|
||
editScope: incident.editScope,
|
||
});
|
||
warn(channel, `filed ${filename}: ${incident.summary}`);
|
||
appendDiary(`${channel}-proposal filed: ${filename} (${incident.summary})`);
|
||
} catch (e) {
|
||
warn(channel, `writeProposal failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// ---- chat (dialog-only via social/) ----------------------------------------
|
||
//
|
||
// MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes
|
||
// inbound chat through the social/ layer:
|
||
// 1. classifyIntent() decides what the message is.
|
||
// 2. generateReply() produces a templated reply (or signals escalate /
|
||
// record-ignored / record-escalation).
|
||
// 3. We record every line in the chat memory (with redaction) so future
|
||
// Pi calls can quote recent context without leaking secrets.
|
||
|
||
let lastChatReplyAt = 0;
|
||
const CHAT_REPLY_COOLDOWN_MS = 30_000;
|
||
const chatMemory = createChatMemory();
|
||
|
||
// Rate-limit Pi escalations from chat. The chat path is the cheapest
|
||
// way to burn an LLM call accidentally — every addressed banter line
|
||
// would otherwise spawn `pi -p`. We cap at MAX_PI_CHAT_PER_HOUR with a
|
||
// hard minimum gap of MIN_PI_CHAT_GAP_MS between calls.
|
||
const MAX_PI_CHAT_PER_HOUR = 6;
|
||
const MIN_PI_CHAT_GAP_MS = 90_000;
|
||
const recentPiChatTs = [];
|
||
let lastPiChatAt = 0;
|
||
|
||
function piChatAllowed(now = Date.now()) {
|
||
while (recentPiChatTs.length && now - recentPiChatTs[0] > 3600_000) recentPiChatTs.shift();
|
||
if (recentPiChatTs.length >= MAX_PI_CHAT_PER_HOUR) return false;
|
||
if (now - lastPiChatAt < MIN_PI_CHAT_GAP_MS) return false;
|
||
return true;
|
||
}
|
||
|
||
function escalateChatToPi({ speaker, text, intent }) {
|
||
if (!piChatAllowed()) {
|
||
info("chat", `pi escalation suppressed (rate limit) for ${speaker}`);
|
||
return;
|
||
}
|
||
lastPiChatAt = Date.now();
|
||
recentPiChatTs.push(lastPiChatAt);
|
||
|
||
// Compose a slim context — recent chat from this speaker, the bot's
|
||
// own state, and an explicit dialog-only reminder so Pi doesn't try
|
||
// to "act" on a player request via its output. We never write Pi's
|
||
// output to the world; the only side-effect is a single chat line.
|
||
const speakerTail = chatMemory.tail(speaker, 5).map((e) => `${speaker}: ${e.text}`).join("\n");
|
||
const stateLine = JSON.stringify({
|
||
runtimeState: lastSnapshot?.runtimeState,
|
||
activeSkill: lastSnapshot?.activeSkill,
|
||
currentMilestone: lastSnapshot?.currentMilestone,
|
||
noProgressReason: lastSnapshot?.noProgressReason,
|
||
hp: lastSnapshot?.health,
|
||
food: lastSnapshot?.food,
|
||
});
|
||
const prompt = [
|
||
`You are the social cortex for an autonomous Minecraft bot named "${bot?.username}".`,
|
||
`The bot's primary loop ignores chat commands — MC chat is dialog-only.`,
|
||
`Your ONLY output is one short chat line (<= 140 chars) the bot will say to ${speaker}.`,
|
||
`No code, no JSON, no quoting. Just the message. Use the language ${speaker} used.`,
|
||
``,
|
||
`Bot's current state:`,
|
||
stateLine,
|
||
``,
|
||
`Recent chat from ${speaker}:`,
|
||
speakerTail || `(no prior lines)`,
|
||
``,
|
||
`Latest line (intent=${intent}): ${text}`,
|
||
].join("\n");
|
||
|
||
let buf = "";
|
||
askPi({
|
||
prompt,
|
||
onChunk: (chunk) => {
|
||
if (chunk?.stream === "stdout") buf += chunk.text;
|
||
},
|
||
onDone: (result) => {
|
||
info("chat", `pi banter reply done code=${result.code} dur=${result.durationMs}ms len=${buf.length}`);
|
||
if (result.code !== 0) return;
|
||
const line = buf.trim().split("\n").find((l) => l.trim()) ?? "";
|
||
if (!line) return;
|
||
// Be defensive — drop the bot's own name prefix Pi sometimes
|
||
// adds, and cap to 200 chars so we never burn the rate-limit
|
||
// with a wall of text.
|
||
const cleaned = line.replace(/^[`"']+|[`"']+$/g, "").slice(0, 200);
|
||
lastChatReplyAt = Date.now();
|
||
botChat(`${speaker}: ${cleaned}`);
|
||
},
|
||
});
|
||
}
|
||
|
||
function isOperator(username) {
|
||
if (!username) return false;
|
||
return config.operators.includes(username.toLowerCase());
|
||
}
|
||
|
||
function handleChat(username, text) {
|
||
if (!bot) return;
|
||
const trimmed = String(text ?? "").trim();
|
||
if (!trimmed) return;
|
||
if (username === bot.username) return; // never reply to ourselves
|
||
|
||
chatMemory.append(username, trimmed);
|
||
try { appendChatHistory({ player: username, dir: "in", text: trimmed, snapshot: lastSnapshot }); } catch {}
|
||
|
||
const intent = classifyIntent({ text: trimmed, botName: bot.username });
|
||
|
||
// Command-like chat → record + one-per-cooldown notice.
|
||
if (intent === INTENTS.COMMAND_LIKE) {
|
||
const op = isOperator(username) ? "operator" : "player";
|
||
info("chat", `ignored command-like chat from ${op} ${username}: ${trimmed.slice(0, 80)}`);
|
||
appendDiary(`ignored command-like chat from ${username}: ${trimmed.slice(0, 120)}`);
|
||
const since = Date.now() - lastChatReplyAt;
|
||
if (since >= CHAT_REPLY_COOLDOWN_MS) {
|
||
lastChatReplyAt = Date.now();
|
||
botChat(`${username}: MC chat is dialog-only — operator uses the TUI to drive me.`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Unsafe → escalation log + brief notice, no action.
|
||
if (intent === INTENTS.UNSAFE_REQUEST) {
|
||
try {
|
||
writeEscalation({
|
||
from: username,
|
||
request: trimmed.slice(0, 200),
|
||
whyUnsure: "matched unsafe pattern",
|
||
wouldHave: "no action",
|
||
});
|
||
} catch (e) {
|
||
warn("chat", `writeEscalation failed: ${e.message}`);
|
||
}
|
||
const since = Date.now() - lastChatReplyAt;
|
||
if (since >= CHAT_REPLY_COOLDOWN_MS) {
|
||
lastChatReplyAt = Date.now();
|
||
botChat(`${username}: not doing that. logged for operator review.`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Greetings / status / addressed banter → Pi reply with persona +
|
||
// per-player history. The old template path stays as a fast
|
||
// fallback when Pi is unavailable or times out.
|
||
const since = Date.now() - lastChatReplyAt;
|
||
if (since < CHAT_REPLY_COOLDOWN_MS) return;
|
||
const diaryTail = (() => {
|
||
try { return readDiaryTail(1); } catch { return null; }
|
||
})();
|
||
|
||
(async () => {
|
||
try {
|
||
const pi = await piReply({ player: username, text: trimmed, snapshot: lastSnapshot, diaryTail });
|
||
if (pi) {
|
||
lastChatReplyAt = Date.now();
|
||
botChat(pi);
|
||
try { appendChatHistory({ player: username, dir: "out", text: pi, snapshot: lastSnapshot }); } catch {}
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
warn("chat", `piReply threw: ${e.message}`);
|
||
}
|
||
// Fallback: templated reply so the bot still says something.
|
||
const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail });
|
||
if (result?.send) {
|
||
lastChatReplyAt = Date.now();
|
||
botChat(result.send);
|
||
try { appendChatHistory({ player: username, dir: "out", text: result.send, snapshot: lastSnapshot }); } catch {}
|
||
}
|
||
})();
|
||
}
|
||
|
||
// ---- connect ---------------------------------------------------------------
|
||
|
||
function connect() {
|
||
if (bot) return;
|
||
info("mc", `connecting as ${config.username} → ${config.host}:${config.port} (v${config.version})`);
|
||
bot = mineflayer.createBot({
|
||
host: config.host,
|
||
port: config.port,
|
||
username: config.username,
|
||
auth: config.authMode === "microsoft" ? "microsoft" : "offline",
|
||
version: config.mineflayerVersion,
|
||
hideErrors: false,
|
||
});
|
||
reflexCtx.bot = bot;
|
||
|
||
bot.once("spawn", () => {
|
||
info("mc", `spawned at ${JSON.stringify(bot.entity.position)}`);
|
||
appendDiary(`spawned at ${bot.entity.position.x.toFixed(0)},${bot.entity.position.y.toFixed(0)},${bot.entity.position.z.toFixed(0)}`);
|
||
ipc?.broadcast(EVENT_TYPES.STATUS, buildSnapshot(bot));
|
||
maybeStartViewer(bot).catch((e) => warn("viewer", `start threw: ${e?.message ?? e}`));
|
||
// Pathfinder stuck-watchdog: replan when an obstacle appears mid-path
|
||
// (mineflayer-pathfinder doesn't recompute on world changes).
|
||
try {
|
||
pathWatchdog?.stop();
|
||
pathWatchdog = createPathfinderWatchdog(bot);
|
||
info("pathfinder", "stuck-replan watchdog armed");
|
||
} catch (e) { warn("pathfinder", `watchdog start failed: ${e?.message ?? e}`); }
|
||
// 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.
|
||
// 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) => {
|
||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: "server", text, kind: "system" });
|
||
maybeHandleAuthPrompt(text);
|
||
});
|
||
|
||
bot.on("chat", (username, message) => {
|
||
if (username === bot.username) return;
|
||
ipc?.broadcast(EVENT_TYPES.CHAT, { from: username, text: message, kind: "player" });
|
||
try {
|
||
handleChat(username, message);
|
||
} catch (e) {
|
||
warn("chat", `chat handler threw: ${e.message}`);
|
||
}
|
||
});
|
||
|
||
bot.on("death", () => {
|
||
const pos = bot.entity?.position;
|
||
warn("mc", `died at ${JSON.stringify(pos)}`);
|
||
appendDiary(`died at ${pos?.x.toFixed(0)},${pos?.y.toFixed(0)},${pos?.z.toFixed(0)}`);
|
||
ipc?.broadcast(EVENT_TYPES.DEATH, { reason: "unknown", position: pos });
|
||
clearCurrentTask();
|
||
});
|
||
|
||
bot.on("kicked", (reason) => {
|
||
warn("mc", `kicked: ${reason}`);
|
||
});
|
||
|
||
bot.on("error", (err) => {
|
||
error("mc", `bot error: ${err?.message ?? err}`);
|
||
});
|
||
|
||
bot.on("end", (reason) => {
|
||
warn("mc", `connection ended: ${reason}`);
|
||
bot = null;
|
||
reflexCtx.bot = null;
|
||
lastSnapshot = { connected: false };
|
||
if (!shuttingDown) scheduleReconnect();
|
||
});
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (reconnectTimer || shuttingDown) return;
|
||
const delay = 5000;
|
||
info("mc", `reconnecting in ${delay}ms`);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, delay);
|
||
}
|
||
|
||
// ---- escalation ------------------------------------------------------------
|
||
|
||
function maybeAutoEscalate() {
|
||
if (reflexCtx.busy) return;
|
||
if (consecutiveNoops < ESCALATE_AFTER_NOOPS) return;
|
||
const since = Date.now() - lastEscalationAt;
|
||
if (since < ESCALATION_COOLDOWN_MS) return;
|
||
lastEscalationAt = Date.now();
|
||
consecutiveNoops = 0;
|
||
|
||
const promptCtx = JSON.stringify(lastSnapshot, null, 2);
|
||
const prompt = [
|
||
`You are the escalation cortex for an autonomous Minecraft bot. The bot's`,
|
||
`script-driven reflex loop has produced no useful action for ${ESCALATE_AFTER_NOOPS} consecutive ticks`,
|
||
`(~${Math.round((ESCALATE_AFTER_NOOPS * config.tickIntervalMs) / 1000)}s). The reflex chain is:`,
|
||
` defend > eat > sleep > tech-tree > autonomous > idle`,
|
||
`Snapshot:`,
|
||
"```json",
|
||
promptCtx,
|
||
"```",
|
||
`Decide ONE next thing the bot should attempt. Output as plain text — what it should do and why, in 3 lines max.`,
|
||
`If "do nothing" is the right answer, say so.`,
|
||
`Do NOT propose code changes. Do NOT propose anything beyond what mineflayer + the existing actions (attack, flee, eat, sleep, goTo) can do.`,
|
||
].join("\n");
|
||
|
||
info("escalation", `firing askPi after ${ESCALATE_AFTER_NOOPS} noops`);
|
||
askPi({
|
||
prompt,
|
||
onChunk: (chunk) => ipc?.broadcast(EVENT_TYPES.ASK_PI_CHUNK, chunk),
|
||
onDone: (result) => {
|
||
info("escalation", `pi done code=${result.code} dur=${result.durationMs}ms`);
|
||
ipc?.broadcast(EVENT_TYPES.ASK_PI_DONE, result);
|
||
},
|
||
});
|
||
}
|
||
|
||
// ---- tick ------------------------------------------------------------------
|
||
|
||
function failuresByCode() {
|
||
const counts = {};
|
||
for (const f of reflexCtx.recentFailures) {
|
||
const k = f.kind || "other";
|
||
counts[k] = (counts[k] ?? 0) + 1;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
function refreshMilestoneCache(now) {
|
||
if (now - lastPlanReadAt < MILESTONE_CACHE_MS) return;
|
||
lastPlanReadAt = now;
|
||
try {
|
||
cachedMilestone = readNextMilestone();
|
||
cachedPlanExists = planExists();
|
||
} catch (e) {
|
||
warn("planner", `milestone read failed: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
function tick() {
|
||
if (shuttingDown) return;
|
||
const now = Date.now();
|
||
if (bot && bot.entity) {
|
||
lastSnapshot = buildSnapshot(bot);
|
||
lastSnapshot.pendingProposals = listProposals().length;
|
||
lastSnapshot.lastReflex = reflexCtx.lastReflex ?? null;
|
||
lastSnapshot.busy = reflexCtx.busy
|
||
? { label: reflexCtx.currentActionLabel ?? "?" }
|
||
: null;
|
||
// Curriculum + locations MUST be computed BEFORE runTick so the
|
||
// curriculum reflex sees the suggested skill in snapshot.curriculum.
|
||
// (Pre-2026-05-26 they were computed after — every tick fell through
|
||
// to the wander fallback because plan.skillId was undefined.)
|
||
try {
|
||
lastSnapshot.locations = listLocations();
|
||
} catch {
|
||
lastSnapshot.locations = {};
|
||
}
|
||
const curriculumEarly = nextCurriculumMilestone(lastSnapshot);
|
||
lastSnapshot.curriculum = curriculumEarly;
|
||
reflexCtx.snapshot = lastSnapshot;
|
||
if (!reflexPaused) {
|
||
const result = runTick(reflexCtx);
|
||
if (!result || result.action === "noop") {
|
||
consecutiveNoops++;
|
||
maybeAutoEscalate();
|
||
} else if (result.action === "skipped") {
|
||
// busy — neither productive nor stuck; don't increment noops
|
||
} else {
|
||
consecutiveNoops = 0;
|
||
}
|
||
}
|
||
|
||
// Observability: compute runtime state + no-progress reason and
|
||
// stamp them on the snapshot so the TUI / future surfaces can show
|
||
// one concrete answer to "why is the bot idle?".
|
||
refreshMilestoneCache(now);
|
||
const plannerInFlight = isPlannerBusy();
|
||
const runtimeState = computeState({
|
||
snapshot: lastSnapshot,
|
||
ctx: reflexCtx,
|
||
plannerInFlight,
|
||
lastChatReplyAt,
|
||
lastFailureAt,
|
||
now,
|
||
});
|
||
const noProgressReason = noProgress.detect({
|
||
snapshot: lastSnapshot,
|
||
ctx: reflexCtx,
|
||
planExists: cachedPlanExists,
|
||
now,
|
||
});
|
||
|
||
lastSnapshot.runtimeState = runtimeState;
|
||
lastSnapshot.activeSkill = reflexCtx.busy
|
||
? reflexCtx.currentActionLabel
|
||
: reflexCtx.lastReflex?.label ?? null;
|
||
// Curriculum + locations were already computed before runTick (above).
|
||
// Re-stamp the title here so observability fields stay together.
|
||
const curriculum = lastSnapshot.curriculum;
|
||
lastSnapshot.currentMilestone = curriculum?.milestone?.title ?? cachedMilestone;
|
||
lastSnapshot.lastResult = lastResult;
|
||
lastSnapshot.noProgressReason = noProgressReason;
|
||
lastSnapshot.failuresByCode = failuresByCode();
|
||
lastSnapshot.skillMetrics = skillMetrics.snapshot();
|
||
lastSnapshot.lastEscalation = lastEscalationAt
|
||
? { ts: lastEscalationAt, ageMs: now - lastEscalationAt }
|
||
: null;
|
||
lastSnapshot.reflexPaused = reflexPaused;
|
||
|
||
// Stuck-incident detector: file a structured proposal when the same
|
||
// no-progress reason persists past the threshold. Kept separate from
|
||
// the existing bug-class failure tracker — they target different
|
||
// classes of breakage. The detector enforces its own cooldown so we
|
||
// don't spam the proposals dir.
|
||
const stuck = stuckIncident.check({
|
||
snapshot: lastSnapshot,
|
||
lastResult,
|
||
metrics: lastSnapshot.skillMetrics,
|
||
journalSummary: worldJournal.summary(),
|
||
scenarioTail: scenarioMemory.recentTailFor({ n: 12 }),
|
||
now,
|
||
});
|
||
if (stuck?.fire) {
|
||
void filePostCritique(stuck, "stuck");
|
||
}
|
||
|
||
// Second fast-track trigger: explicit wedged loop (escape-pit ran N
|
||
// times in a row without freeing the bot). Auto-improve picks this
|
||
// up like any other proposal — Pi writes a new escape strategy.
|
||
const wedged = stuckIncident.checkWedged({
|
||
snapshot: lastSnapshot,
|
||
lastResult,
|
||
metrics: lastSnapshot.skillMetrics,
|
||
journalSummary: worldJournal.summary(),
|
||
scenarioTail: scenarioMemory.recentTailFor({ n: 12 }),
|
||
now,
|
||
});
|
||
if (wedged?.fire) {
|
||
void filePostCritique(wedged, "wedged");
|
||
}
|
||
|
||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||
} else {
|
||
lastSnapshot = { connected: false };
|
||
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
|
||
}
|
||
}
|
||
|
||
function startTickLoop() {
|
||
if (tickTimer) clearInterval(tickTimer);
|
||
tickTimer = setInterval(tick, config.tickIntervalMs);
|
||
}
|
||
|
||
// ---- IPC commands ----------------------------------------------------------
|
||
|
||
function handleCommand(msg, send) {
|
||
switch (msg.type) {
|
||
case COMMAND_TYPES.PAUSE:
|
||
reflexPaused = true;
|
||
info("ipc", "reflex paused by client");
|
||
break;
|
||
case COMMAND_TYPES.RESUME:
|
||
reflexPaused = false;
|
||
info("ipc", "reflex resumed by client");
|
||
break;
|
||
case COMMAND_TYPES.STOP:
|
||
info("ipc", "stop requested by client");
|
||
gracefulExit(0);
|
||
break;
|
||
case COMMAND_TYPES.CHAT: {
|
||
const text = (msg.payload?.text || "").trim();
|
||
if (!text || !bot) return;
|
||
if (!chatRateAllowed()) {
|
||
send(EVENT_TYPES.ERROR, { source: "chat", text: "rate-limited" });
|
||
return;
|
||
}
|
||
bot.chat(text);
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.ASK_PI: {
|
||
const prompt = msg.payload?.prompt;
|
||
if (!prompt) return;
|
||
askPi({
|
||
prompt,
|
||
onChunk: (chunk) => ipc?.broadcast(EVENT_TYPES.ASK_PI_CHUNK, chunk),
|
||
onDone: (result) => ipc?.broadcast(EVENT_TYPES.ASK_PI_DONE, result),
|
||
});
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.SNAPSHOT:
|
||
send(EVENT_TYPES.STATUS, lastSnapshot);
|
||
break;
|
||
case COMMAND_TYPES.PROPOSAL_LATEST: {
|
||
const all = listProposals();
|
||
if (all.length === 0) {
|
||
send(EVENT_TYPES.PROPOSAL, { filename: null, body: null, total: 0 });
|
||
return;
|
||
}
|
||
const filename = all[all.length - 1];
|
||
send(EVENT_TYPES.PROPOSAL, {
|
||
filename,
|
||
body: readProposal(filename),
|
||
total: all.length,
|
||
});
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.PROPOSAL_APPROVE: {
|
||
const filename = msg.payload?.filename;
|
||
if (!filename) return;
|
||
try {
|
||
const dst = approveProposal(filename);
|
||
info("proposal", `approved ${filename} → ${dst}`);
|
||
appendDiary(`proposal approved: ${filename}`);
|
||
} catch (e) {
|
||
send(EVENT_TYPES.ERROR, { source: "proposal", text: e.message });
|
||
}
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.RUN_SKILL: {
|
||
const skillId = msg.payload?.skillId;
|
||
const args = msg.payload?.args ?? {};
|
||
if (!skillId) {
|
||
send(EVENT_TYPES.ERROR, { source: "run-skill", text: "missing skillId" });
|
||
return;
|
||
}
|
||
info("ipc", `run-skill: queued ${skillId} args=${JSON.stringify(args)} (will wait for current action)`);
|
||
// Pause the reflex loop while we wait so it doesn't immediately
|
||
// schedule another action and starve our request.
|
||
const wasPaused = reflexPaused;
|
||
reflexPaused = true;
|
||
const deadline = Date.now() + 120_000;
|
||
const tryDispatch = () => {
|
||
if (!reflexCtx.busy) {
|
||
info("ipc", `run-skill: dispatching ${skillId}`);
|
||
dispatchAction(() => runSkill(skillId, reflexCtx, args), `ipc:${skillId}`, {
|
||
onComplete: (res) => {
|
||
reflexPaused = wasPaused;
|
||
send(EVENT_TYPES.LOG, {
|
||
ts: new Date().toISOString(),
|
||
level: "info",
|
||
source: "run-skill",
|
||
text: `${skillId} → ${res.code ?? (res.ok ? "ok" : "fail")}`,
|
||
details: res,
|
||
});
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
if (Date.now() > deadline) {
|
||
reflexPaused = wasPaused;
|
||
send(EVENT_TYPES.ERROR, { source: "run-skill", text: `still busy after 2 min with ${reflexCtx.currentActionLabel}` });
|
||
return;
|
||
}
|
||
setTimeout(tryDispatch, 500);
|
||
};
|
||
tryDispatch();
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.CONV_SAY: {
|
||
const { topic: topic_, text, intent, position } = msg.payload ?? {};
|
||
if (!topic_ || !text) { send(EVENT_TYPES.ERROR, { source: "conv", text: "topic and text required" }); return; }
|
||
try {
|
||
const h = openConversation(topic_, { speaker: config.username });
|
||
const turn = h.append({ text, intent, position: position ?? lastSnapshot?.position });
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: `say to ${topic_}`, details: turn });
|
||
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.CONV_RECENT: {
|
||
const { topic: topic_, n } = msg.payload ?? {};
|
||
if (!topic_) { send(EVENT_TYPES.ERROR, { source: "conv", text: "topic required" }); return; }
|
||
try {
|
||
const turns = peekConversation(topic_, n ?? 10);
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: `recent ${topic_}`, details: { topic: topic_, turns } });
|
||
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.CONV_LIST: {
|
||
try {
|
||
const topics = listConversations();
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: "list", details: { topics } });
|
||
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.SCREENSHOT: {
|
||
const { reason, frames } = msg.payload ?? {};
|
||
(async () => {
|
||
if (!bot) { send(EVENT_TYPES.ERROR, { source: "viewer", text: "bot not connected" }); return; }
|
||
const res = await takeScreenshot(bot, { reason: reason ?? "ipc", frames: frames ?? 1 });
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "viewer", text: res.ok ? `screenshot ok` : `screenshot fail`, details: res });
|
||
})();
|
||
break;
|
||
}
|
||
case COMMAND_TYPES.FORCE_INCIDENT: {
|
||
const { kind, reason } = msg.payload ?? {};
|
||
(async () => {
|
||
const fakeIncident = {
|
||
kind: kind ?? "force-demo",
|
||
summary: `forced incident: ${reason ?? "operator demo"}`,
|
||
body: stuckIncident._renderFake
|
||
? stuckIncident._renderFake({ snapshot: lastSnapshot, lastResult, reason: reason ?? "operator demo" })
|
||
: `# Forced incident\n\nOperator triggered via cmd:force-incident.\n\n## Snapshot\n\n\`\`\`json\n${JSON.stringify(lastSnapshot ?? {}, null, 2).slice(0, 2000)}\n\`\`\`\n\n## Last action\n\n${lastResult ? `\`${lastResult.label}\` → ${lastResult.code}` : "_(none)_"}\n\n## Suggested fix\n\nReview the snapshot and propose a productive next skill, or document why no productive action is possible from this state.\n\n## Edit scope\n\n- runtime/skills/\n- runtime/reflex.js\n`,
|
||
editScope: ["runtime/skills/", "runtime/reflex.js"],
|
||
};
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "force", text: `dispatching critic for ${fakeIncident.kind}` });
|
||
await filePostCritique(fakeIncident, "force");
|
||
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "force", text: `force-incident done — check state/proposals/` });
|
||
})();
|
||
break;
|
||
}
|
||
default:
|
||
warn("ipc", `unknown command type: ${msg.type}`);
|
||
}
|
||
}
|
||
|
||
// ---- shutdown --------------------------------------------------------------
|
||
|
||
function gracefulExit(code) {
|
||
if (shuttingDown) return;
|
||
shuttingDown = true;
|
||
info("runtime", "shutting down");
|
||
if (tickTimer) clearInterval(tickTimer);
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
try { pathWatchdog?.stop(); } catch {}
|
||
pathWatchdog = null;
|
||
try {
|
||
bot?.quit("shutdown");
|
||
} catch {}
|
||
ipc?.close();
|
||
setTimeout(() => process.exit(code), 500);
|
||
}
|
||
|
||
process.on("SIGINT", () => gracefulExit(0));
|
||
process.on("SIGTERM", () => gracefulExit(0));
|
||
|
||
info("runtime", `pepa runtime starting; cfg=${JSON.stringify(redactedConfig())}`);
|
||
|
||
// Resume info: surface stale state across restarts. We don't auto-resume any
|
||
// action — but we tell the operator if the bot died mid-task last time, and
|
||
// the count of pending proposals.
|
||
const lastTask = readCurrentTask();
|
||
if (lastTask && lastTask.label) {
|
||
info("resume", `previous task: ${lastTask.label} (${lastTask.status ?? "?"}, ${lastTask.ts ?? "?"})`);
|
||
if (lastTask.status === "in_progress") {
|
||
warn("resume", `last shutdown happened mid-action — operator should review state/<host>/current-task.json`);
|
||
}
|
||
}
|
||
const pendingProposals = listProposals();
|
||
if (pendingProposals.length > 0) {
|
||
warn("resume", `${pendingProposals.length} pending proposal(s) — see state/<host>/proposals/`);
|
||
}
|
||
|
||
ipc = createIpcServer({
|
||
getStatusSnapshot: () => ({
|
||
...lastSnapshot,
|
||
pendingProposals: listProposals().length,
|
||
}),
|
||
onCommand: handleCommand,
|
||
});
|
||
connect();
|
||
startTickLoop();
|
||
startAutoImprover();
|
||
startPlanner(() => lastSnapshot);
|