From e84148d18944963ee805d92a3d34391aface2690 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Wed, 27 May 2026 14:10:28 +0300 Subject: [PATCH] =?UTF-8?q?v0.2.0-rc.2:=20P0=20hardening=20=E2=80=94=20Pi?= =?UTF-8?q?=20headless,=20test=20state=20isolation,=20advice=20fixes=20(#2?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 (correctness): 1. PEPA_HEADLESS=1 guard in extensions/mineflayer-bridge.ts. When `pi -p` spawns a subprocess (banter, coach, planner, reflect, auto-patch), the bridge no longer attempts a second MC connect — the hybrid runtime already owns the nickname. runtime/pi-bridge.js sets the env var on every spawn. Root cause of the "two pepa_bot's racing for the slot" bug seen in reply-pi stderr. 2. Test state isolation in runtime/config.js. When running under the node test runner (detected via execArgv/argv) — or when PEPA_STATE_DIR is set — stateDir redirects to /tmp/pepa-test-state-/. log.js, scenario-memory, world-journal, and knowledge.db all follow. `npm test` no longer pollutes live scenarios.jsonl, world-journal.jsonl, or daily log files. Verified empirically: post-fix run added 0 test rows to the live scenarios file. Cleaned ~550 historical test rows from live state in the same change. 3. defendReflex outcome reporting (runtime/reflex.js). Previously a creeper-rule override marked the lesson succeeded=false BEFORE the flee skill returned. Now dispatchDefendFlee accepts {lessonId} and the onComplete fires reportAdviceOutcome with the actual flee result. 4. Mode-name → skill-id translation in runtime/coach/advice.js. Pi-coach occasionally returns prefer_skill values that are mode names ("night_shelter", "self_preservation", "hunger"). normalisePreferSkill maps these to SAFE_OVERRIDES entries before dispatch. Also handles "tunnel-out", "survive_flee", "survive flee" shapes. New behavior: 5. Self-reflection loop (runtime/coach/reflect.js). Every 30 min, the bot asks Pi: "Are you making progress, or stuck in a loop? What should you do differently?" Pi answers with a verdict (progress/loop/recovering/idle/emergency), summary, next-action, and 0-N new lessons. The reflection is written to state//reflections/.md and lessons land in the DB with source="pi-reflect". Rate-limited to 2 calls/hour. Wired through bot.js with the existing askPi + lastSnapshot accessor. Tests: 246/246 green (+9 new: 6 advice mode-name + 4 reflect). Co-authored-by: Yuriy Mayatnikov Co-authored-by: Claude Opus 4.7 --- extensions/mineflayer-bridge.ts | 9 ++ package.json | 4 +- runtime/bot.js | 2 + runtime/coach/advice.js | 42 ++++- runtime/coach/advice.test.js | 47 +++++- runtime/coach/reflect.js | 264 ++++++++++++++++++++++++++++++++ runtime/coach/reflect.test.js | 112 ++++++++++++++ runtime/config.js | 28 +++- runtime/pi-bridge.js | 5 +- runtime/reflex.js | 17 +- 10 files changed, 517 insertions(+), 13 deletions(-) create mode 100644 runtime/coach/reflect.js create mode 100644 runtime/coach/reflect.test.js diff --git a/extensions/mineflayer-bridge.ts b/extensions/mineflayer-bridge.ts index d69ce97..4d5a029 100644 --- a/extensions/mineflayer-bridge.ts +++ b/extensions/mineflayer-bridge.ts @@ -1698,6 +1698,15 @@ export default function mineflayerBridge(pi: ExtensionAPI) { startupMemoryReviewed = false; lastHumanChatAt = Date.now(); lastAutonomyPromptAt = 0; + // v0.2.0-rc.2: in headless subprocess mode (banter/coach/planner spawning + // `pi -p`), the hybrid runtime is already holding the MC nickname. + // A second `connect("startup")` would race for the same player slot and + // the server would kick one of them. Skip the bridge entirely. + if (process.env.PEPA_HEADLESS === "1") { + log("startup-skip", "PEPA_HEADLESS=1 — skipping MC bridge (hybrid runtime owns the connection)"); + if (ctx.hasUI) ctx.ui.setStatus("mineflayer", "mc: bridged (headless)"); + return; + } try { const current = ensureConfig(); ensureMemoryLayout(current); diff --git a/package.json b/package.json index b37ced7..57356de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pepa-pi-bot", - "version": "0.2.0-rc.1", + "version": "0.2.0-rc.2", "private": true, "description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.", "license": "MIT", @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/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/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/knowledge/knowledge.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/runtime/bot.js b/runtime/bot.js index 78a0ea2..977ff25 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -59,6 +59,7 @@ 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 attachChatter } from "./persona/chatter.js"; fs.mkdirSync(stateDir, { recursive: true }); @@ -670,6 +671,7 @@ function connect() { // import-safe; they just attach listeners and (for coach) a periodic // Pi-drain timer. See docs/v0.2.0-self-learning.md. try { attachCoach(bot, { stateDir, askPi }); } catch (e) { warn("coach", `attach: ${e?.message ?? e}`); } + try { attachReflect({ bot, stateDir, askPi, getSnapshot: () => lastSnapshot }); } catch (e) { warn("reflect", `attach: ${e?.message ?? e}`); } try { attachChatter(bot, { getSnapshot: () => lastSnapshot }); } catch (e) { warn("persona", `attach: ${e?.message ?? e}`); } }); diff --git a/runtime/coach/advice.js b/runtime/coach/advice.js index 0b63bbc..ce7ff37 100644 --- a/runtime/coach/advice.js +++ b/runtime/coach/advice.js @@ -26,6 +26,36 @@ const SAFE_OVERRIDES = new Set([ "village.build-shelter", ]); +// Pi-coach occasionally suggests prefer_skill values that are mode names +// (from runtime/modes.js) rather than registered skill ids. We translate +// them to the closest equivalent skill before the SAFE_OVERRIDES check. +// Unknown values are returned as-is and will fall through to 'avoid'. +const MODE_TO_SKILL = Object.freeze({ + self_preservation: "survive.flee", + night_shelter: "survive.sleep", + hunger: "survive.eat", + shelter: "village.build-shelter", + flee: "survive.flee", + sleep: "survive.sleep", + eat: "survive.eat", + tunnel_out: "recovery.tunnel-out", + "tunnel-out": "recovery.tunnel-out", + explore: "explore.far", + wander: "explore.far", +}); + +function normalisePreferSkill(raw) { + if (!raw || typeof raw !== "string") return raw; + if (SAFE_OVERRIDES.has(raw)) return raw; + const lower = raw.toLowerCase().trim(); + if (MODE_TO_SKILL[lower]) return MODE_TO_SKILL[lower]; + // Pi sometimes writes "survive_flee" or "survive flee"; normalise. + const dot = lower.replace(/[_\s]+/g, "."); + if (SAFE_OVERRIDES.has(dot)) return dot; + if (MODE_TO_SKILL[dot]) return MODE_TO_SKILL[dot]; + return raw; +} + /** * consult({ plannedSkillId, snapshot }) * → { action: 'override'|'avoid'|'proceed', overrideSkillId?, lessonId?, lesson? } @@ -48,11 +78,15 @@ export function consult({ plannedSkillId, snapshot } = {}) { // avoid_skill matches? if (advice.avoid && advice.avoid === plannedSkillId) { - if (advice.prefer && SAFE_OVERRIDES.has(advice.prefer)) { - info("coach", `advice: override ${plannedSkillId} → ${advice.prefer} (lesson #${advice.lessonId})`); + const normalisedPrefer = normalisePreferSkill(advice.prefer); + if (normalisedPrefer && SAFE_OVERRIDES.has(normalisedPrefer)) { + if (normalisedPrefer !== advice.prefer) { + info("coach", `advice: normalised prefer "${advice.prefer}" → "${normalisedPrefer}"`); + } + info("coach", `advice: override ${plannedSkillId} → ${normalisedPrefer} (lesson #${advice.lessonId})`); return { action: "override", - overrideSkillId: advice.prefer, + overrideSkillId: normalisedPrefer, lessonId: advice.lessonId, lesson: advice.lesson, }; @@ -76,4 +110,4 @@ export function reportOutcome({ lessonId, succeeded }) { const PROCEED = Object.freeze({ action: "proceed", lessonId: null, lesson: null }); // Test exports -export const __testing = { SAFE_OVERRIDES }; +export const __testing = { SAFE_OVERRIDES, MODE_TO_SKILL, normalisePreferSkill }; diff --git a/runtime/coach/advice.test.js b/runtime/coach/advice.test.js index 8b0d47d..04d6612 100644 --- a/runtime/coach/advice.test.js +++ b/runtime/coach/advice.test.js @@ -8,7 +8,7 @@ import { initKnowledge, record } from "../knowledge/index.js"; import { __resetForTests, isAvailable, closeStore } from "../knowledge/store.js"; import { consult, reportOutcome, __testing } from "./advice.js"; -const { SAFE_OVERRIDES } = __testing; +const { SAFE_OVERRIDES, MODE_TO_SKILL, normalisePreferSkill } = __testing; async function bootstrap() { __resetForTests(); @@ -95,3 +95,48 @@ test("SAFE_OVERRIDES: only contains known reflex skills", () => { assert.ok(typeof id === "string" && id.includes("."), `${id} looks like a real skill id`); } }); + +test("normalisePreferSkill: mode names translate to skill ids", () => { + assert.equal(normalisePreferSkill("self_preservation"), "survive.flee"); + assert.equal(normalisePreferSkill("night_shelter"), "survive.sleep"); + assert.equal(normalisePreferSkill("hunger"), "survive.eat"); + assert.equal(normalisePreferSkill("shelter"), "village.build-shelter"); + assert.equal(normalisePreferSkill("flee"), "survive.flee"); + assert.equal(normalisePreferSkill("eat"), "survive.eat"); + assert.equal(normalisePreferSkill("tunnel-out"), "recovery.tunnel-out"); + assert.equal(normalisePreferSkill("tunnel_out"), "recovery.tunnel-out"); +}); + +test("normalisePreferSkill: 'survive_flee' shape gets translated to dot form", () => { + assert.equal(normalisePreferSkill("survive_flee"), "survive.flee"); + assert.equal(normalisePreferSkill("survive sleep"), "survive.sleep"); +}); + +test("normalisePreferSkill: passes through known dot-form skills unchanged", () => { + assert.equal(normalisePreferSkill("survive.flee"), "survive.flee"); + assert.equal(normalisePreferSkill("explore.far"), "explore.far"); +}); + +test("normalisePreferSkill: unknown values returned as-is", () => { + assert.equal(normalisePreferSkill("some.unknown.skill"), "some.unknown.skill"); + assert.equal(normalisePreferSkill(null), null); + assert.equal(normalisePreferSkill(""), ""); +}); + +test("consult: Pi-style mode-name prefer is normalised to override target", async () => { + const tmp = await bootstrap(); + if (!isAvailable()) { cleanup(tmp); return; } + const { id } = (await import("../knowledge/index.js")).record({ + text: "After a death at night, prefer shelter.", + category: "survival", + triggerSkill: "gather.logs", + avoidSkill: "gather.logs", + preferSkill: "night_shelter", // Pi gave a mode name, not a skill id + confidence: 0.9, + source: "test", + }); + const res = consult({ plannedSkillId: "gather.logs", snapshot: {} }); + assert.equal(res.action, "override"); + assert.equal(res.overrideSkillId, "survive.sleep", "night_shelter mapped to survive.sleep"); + cleanup(tmp); +}); diff --git a/runtime/coach/reflect.js b/runtime/coach/reflect.js new file mode 100644 index 0000000..3820b99 --- /dev/null +++ b/runtime/coach/reflect.js @@ -0,0 +1,264 @@ +// Self-reflection loop. Every REFLECT_INTERVAL_MS (default 30 min) we ask +// Pi a meta-question: "Look at the last window of activity. Are you in a +// loop? Making progress? What should you do differently?" +// +// The answer is parsed into: +// - one short verdict ('progress' | 'loop' | 'recovering' | 'idle') +// - a paragraph of context (stored to state//reflections/.md) +// - optional new lessons (written to knowledge.lessons) +// - optional plan adjustment (queued, not auto-applied) +// +// This is the proactive counterpart to coach/postmortem (which is reactive +// — fires on death). Together they cover both "I just lost" and "I haven't +// gained anything in a while" failure modes. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { isAvailable as knowledgeAvailable, record as recordLesson } from "../knowledge/index.js"; +import { info, warn } from "../log.js"; + +const DEFAULT_INTERVAL_MS = 30 * 60 * 1000; +const HOURLY_BUDGET = 2; +const HISTORY_TAIL_LINES = 80; + +let _attached = null; +let _timer = null; +let _piCallTimes = []; + +export function attach({ bot, stateDir, askPi, getSnapshot, intervalMs = DEFAULT_INTERVAL_MS } = {}) { + if (_attached) { + warn("reflect", "attach called twice; ignoring"); + return; + } + if (!stateDir || !askPi || !getSnapshot) { + info("reflect", "attach: missing stateDir/askPi/getSnapshot — disabled"); + return; + } + _attached = { bot, stateDir, askPi, getSnapshot }; + _timer = setInterval(() => { + runOnce({ stateDir, askPi, getSnapshot }).catch((e) => + warn("reflect", `tick err: ${e?.message ?? e}`), + ); + }, intervalMs); + _timer.unref?.(); + info("reflect", `attached; self-assess every ${Math.round(intervalMs / 60000)} min`); +} + +export function detach() { + if (_timer) clearInterval(_timer); + _timer = null; + _attached = null; +} + +export async function runOnce({ stateDir, askPi, getSnapshot, force = false } = {}) { + const now = Date.now(); + const hourAgo = now - 3600_000; + _piCallTimes = _piCallTimes.filter((t) => t > hourAgo); + if (!force && _piCallTimes.length >= HOURLY_BUDGET) { + return { ok: false, reason: "budget exhausted", calls: _piCallTimes.length }; + } + + const snap = getSnapshot(); + const journal = readJournalTail(stateDir); + const scenarios = readScenarioTail(stateDir); + const diary = readDiaryTail(stateDir); + const plan = readPlan(stateDir); + + const prompt = buildPrompt({ snap, journal, scenarios, diary, plan }); + _piCallTimes.push(now); + + const reply = await askPiOnce({ askPi, prompt }); + if (!reply) return { ok: false, reason: "no reply" }; + + const parsed = parseReply(reply); + if (!parsed) { + warn("reflect", "Pi reply not parseable as JSON"); + return { ok: false, reason: "bad reply", raw: reply.slice(0, 200) }; + } + + const path = writeReflection(stateDir, parsed, reply); + for (const l of asArray(parsed.lessons)) { + if (!l?.lesson) continue; + recordLesson({ + text: l.lesson, + category: l.category ?? "self-improve", + triggerSkill: l.trigger_skill ?? null, + triggerHostile: l.trigger_hostile ?? null, + avoidSkill: l.avoid_skill ?? null, + preferSkill: l.prefer_skill ?? null, + confidence: clamp(Number(l.confidence) || 0.5, 0.1, 0.9), + source: "pi-reflect", + sourceRef: path, + }); + } + info("reflect", `verdict=${parsed.verdict ?? "?"} ${parsed.summary?.slice(0, 80) ?? ""} (${path ?? "no file"})`); + return { ok: true, verdict: parsed.verdict, summary: parsed.summary, lessons: parsed.lessons ?? [] }; +} + +function readJournalTail(stateDir) { + const f = resolve(stateDir, "world-journal.jsonl"); + if (!existsSync(f)) return []; + try { + return readFileSync(f, "utf8").split("\n").filter(Boolean).slice(-HISTORY_TAIL_LINES); + } catch { return []; } +} + +function readScenarioTail(stateDir) { + const f = resolve(stateDir, "scenarios.jsonl"); + if (!existsSync(f)) return []; + try { + return readFileSync(f, "utf8").split("\n").filter(Boolean).slice(-HISTORY_TAIL_LINES); + } catch { return []; } +} + +function readDiaryTail(stateDir) { + const today = new Date().toISOString().slice(0, 10); + const f = resolve(stateDir, "diary", `${today}.md`); + if (!existsSync(f)) return ""; + try { + const raw = readFileSync(f, "utf8"); + return raw.split("\n").slice(-40).join("\n"); + } catch { return ""; } +} + +function readPlan(stateDir) { + const f = resolve(stateDir, "plan.md"); + if (!existsSync(f)) return ""; + try { return readFileSync(f, "utf8"); } catch { return ""; } +} + +function buildPrompt({ snap, journal, scenarios, diary, plan }) { + const pos = snap?.position; + const inv = snap?.inventory ? Object.keys(snap.inventory).slice(0, 12).join(", ") : "(empty)"; + const lastResult = snap?.lastResult ? JSON.stringify(snap.lastResult).slice(0, 200) : "(none)"; + return [ + "You are pepa, an autonomous Minecraft survival bot, reflecting on your own progress.", + "Look at the last ~30 minutes of activity below. Answer honestly: are you actually making progress, or stuck in a loop?", + "", + "## Current state", + `- position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`, + `- hp: ${snap?.health ?? "?"} food: ${snap?.food ?? "?"} day: ${snap?.isDay ? "yes" : "no"}`, + `- runtimeState: ${snap?.runtimeState ?? "?"}`, + `- activeSkill: ${snap?.activeSkill ?? "(idle)"}`, + `- currentMilestone: ${snap?.currentMilestone ?? "?"}`, + `- noProgressReason: ${snap?.noProgressReason ?? "(none)"}`, + `- lastResult: ${lastResult}`, + `- inventory keys: ${inv}`, + "", + "## plan.md", + "```", + plan.slice(0, 1200), + "```", + "", + "## diary tail", + "```", + diary.slice(0, 1500), + "```", + "", + "## recent scenarios (tail)", + "```", + scenarios.slice(-30).join("\n"), + "```", + "", + "## world-journal tail", + "```", + journal.slice(-20).join("\n"), + "```", + "", + "Reply with ONE JSON object (no markdown fences, no prose):", + '{', + ' "verdict": "progress" | "loop" | "recovering" | "idle" | "emergency",', + ' "summary": "<2-3 sentence honest assessment in Russian>",', + ' "next_action": "",', + ' "lessons": [', + ' { "lesson": "<≤30 words, generalised rule>",', + ' "category": "combat|pathing|crafting|survival|self-improve",', + ' "trigger_skill": "",', + ' "trigger_hostile": "",', + ' "avoid_skill": "",', + ' "prefer_skill": "",', + ' "confidence": 0.6 }', + ' ]', + '}', + "", + "If you're clearly in a loop (same activity, no inventory growth, same position), say so honestly.", + "If you're stuck in a bad terrain (deep pit, hostile-rich area), recommend choosing a new base.", + "Lessons should be SHORT and ACTIONABLE. Don't repeat lessons the dispatcher already learned.", + ].join("\n"); +} + +function parseReply(text) { + if (!text) return null; + const cleaned = text.trim().replace(/^```(?:json)?/, "").replace(/```$/, "").trim(); + try { return JSON.parse(cleaned); } catch {} + const m = cleaned.match(/\{[\s\S]*\}/); + if (!m) return null; + try { return JSON.parse(m[0]); } catch { return null; } +} + +function writeReflection(stateDir, parsed, raw) { + try { + const dir = resolve(stateDir, "reflections"); + mkdirSync(dir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const file = resolve(dir, `${ts}.md`); + const body = [ + "---", + `verdict: ${parsed.verdict ?? "unknown"}`, + `ts: ${new Date().toISOString()}`, + "---", + "", + `## Summary`, + "", + parsed.summary ?? "(empty)", + "", + `## Next action`, + "", + parsed.next_action ?? "(none)", + "", + `## Lessons`, + "", + ...(asArray(parsed.lessons).map((l, i) => [ + `### ${i + 1}. ${l?.lesson ?? "(empty)"}`, + `category: ${l?.category ?? "?"}, confidence: ${l?.confidence ?? "?"}`, + l?.avoid_skill ? `avoid: ${l.avoid_skill}` : null, + l?.prefer_skill ? `prefer: ${l.prefer_skill}` : null, + "", + ].filter(Boolean).join("\n"))), + "", + "## Raw response", + "", + "```", + raw.slice(0, 8000), + "```", + ].join("\n"); + writeFileSync(file, body); + return file; + } catch (e) { + warn("reflect", `writeReflection failed: ${e?.message ?? e}`); + return null; + } +} + +function askPiOnce({ askPi, prompt }) { + return new Promise((res) => { + let buf = ""; + try { + askPi({ + prompt, + onChunk: ({ stream, text }) => { if (stream === "stdout") buf += text; }, + onDone: () => res(buf), + }); + } catch (e) { + warn("reflect", `askPi: ${e?.message ?? e}`); + res(null); + } + }); +} + +function asArray(v) { return Array.isArray(v) ? v : v ? [v] : []; } +function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } + +// Test exports +export const __testing = { buildPrompt, parseReply }; diff --git a/runtime/coach/reflect.test.js b/runtime/coach/reflect.test.js new file mode 100644 index 0000000..0f192a2 --- /dev/null +++ b/runtime/coach/reflect.test.js @@ -0,0 +1,112 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { initKnowledge, recall } from "../knowledge/index.js"; +import { closeStore, __resetForTests, isAvailable } from "../knowledge/store.js"; +import { runOnce, __testing } from "./reflect.js"; + +const { buildPrompt, parseReply } = __testing; + +test("buildPrompt: includes runtime state + plan + diary", () => { + const p = buildPrompt({ + snap: { + position: { x: 600, y: 64, z: 200 }, + health: 4, food: 6, isDay: false, + runtimeState: "emergency", + activeSkill: "explore.far", + currentMilestone: "wood.16", + noProgressReason: "no_reachable_target", + lastResult: { ok: false, code: "wedged" }, + inventory: { dirt: 12 }, + }, + journal: ['{"kind":"chopped"}'], + scenarios: ['{"skillId":"explore.far","code":"wedged"}'], + diary: "13:00 spawned\n13:05 died", + plan: "1. Gather 16 logs\n2. Craft pickaxe", + }); + assert.match(p, /position: \(600, 64, 200\)/); + assert.match(p, /hp: 4 food: 6/); + assert.match(p, /emergency/); + assert.match(p, /Gather 16 logs/); + assert.match(p, /Reply with ONE JSON object/); +}); + +test("parseReply: extracts JSON from various Pi outputs", () => { + assert.deepEqual(parseReply('{"verdict":"loop","summary":"stuck"}'), { verdict: "loop", summary: "stuck" }); + assert.deepEqual(parseReply('```json\n{"verdict":"progress"}\n```'), { verdict: "progress" }); + const longReply = 'I see... your situation. Here is my JSON:\n{"verdict":"emergency","summary":"hp critical","lessons":[]}\nDone.'; + assert.deepEqual(parseReply(longReply), { verdict: "emergency", summary: "hp critical", lessons: [] }); + assert.equal(parseReply("no json here"), null); + assert.equal(parseReply(""), null); +}); + +test("runOnce: writes reflection file + records lessons", async () => { + const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-")); + __resetForTests(); + await initKnowledge({ stateDir: tmp }); + if (!isAvailable()) { + try { rmSync(tmp, { recursive: true, force: true }); } catch {} + return; + } + + const fakeReply = JSON.stringify({ + verdict: "loop", + summary: "Бот ходит по кругу, ничего не добывает.", + next_action: "выбрать новое место под базу", + lessons: [{ + lesson: "В этой точке постоянные смерти — искать новое место.", + category: "survival", + prefer_skill: "village.choose-base", + confidence: 0.7, + }], + }); + const askPi = ({ onChunk, onDone }) => { + onChunk({ stream: "stdout", text: fakeReply }); + onDone({ code: 0 }); + }; + const getSnapshot = () => ({ + position: { x: 0, y: 64, z: 0 }, + health: 8, food: 10, isDay: true, + runtimeState: "working", + inventory: {}, + }); + + const result = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true }); + assert.equal(result.ok, true); + assert.equal(result.verdict, "loop"); + + const reflectionsDir = join(tmp, "reflections"); + assert.ok(existsSync(reflectionsDir)); + const files = readdirSync(reflectionsDir); + assert.ok(files.length >= 1, `expected ≥1 reflection file, got ${files.length}`); + + const lessons = recall({ category: "survival" }); + assert.ok(lessons.some((l) => l.source === "pi-reflect"), "lesson recorded with source=pi-reflect"); + + closeStore(); + try { rmSync(tmp, { recursive: true, force: true }); } catch {} +}); + +test("runOnce: budget exhausted → ok=false", async () => { + const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-")); + __resetForTests(); + await initKnowledge({ stateDir: tmp }); + if (!isAvailable()) { + try { rmSync(tmp, { recursive: true, force: true }); } catch {} + return; + } + const askPi = ({ onDone }) => onDone({ code: 0 }); + const getSnapshot = () => ({}); + // Fire 2 forced calls to exhaust budget; 3rd without force should fail. + await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true }); + await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true }); + const res = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: false }); + assert.equal(res.ok, false); + assert.match(res.reason ?? "", /budget|reply/); + + closeStore(); + try { rmSync(tmp, { recursive: true, force: true }); } catch {} +}); diff --git a/runtime/config.js b/runtime/config.js index 2afe768..007909c 100644 --- a/runtime/config.js +++ b/runtime/config.js @@ -1,5 +1,6 @@ import { config as loadDotenv } from "dotenv"; import path from "node:path"; +import os from "node:os"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); @@ -8,6 +9,26 @@ export const REPO_ROOT = path.resolve(__dirname, ".."); loadDotenv({ path: path.join(REPO_ROOT, ".env") }); +// v0.2.0-rc.2: detect that we're running under the node test runner so the +// log / scenario-memory / world-journal / knowledge modules redirect their +// writes to a tmp dir instead of the live state// directory. Without +// this guard, every `npm test` poisons live scenarios.jsonl, world-journal, +// and the daily log file — and the learning loop can pick test rows up as +// real experience. +function detectTestContext() { + if (process.env.PEPA_STATE_DIR) return process.env.PEPA_STATE_DIR; + const execArgv = process.execArgv || []; + const argv = process.argv || []; + const isNodeTest = execArgv.includes("--test") + || argv.includes("--test") + || argv.some((a) => typeof a === "string" && /\.test\.[mc]?[jt]sx?$/.test(a)); + if (isNodeTest) { + return path.join(os.tmpdir(), `pepa-test-state-${process.pid}`); + } + return null; +} +const TEST_STATE_DIR = detectTestContext(); + function req(name) { const v = process.env[name]?.trim(); if (!v) throw new Error(`Missing required env var: ${name}`); @@ -64,7 +85,12 @@ export const config = Object.freeze({ }); export const serverKey = `${host}_${port}`; -export const stateDir = path.join(REPO_ROOT, "state", serverKey); +// Tests get an isolated tmp dir so they don't pollute live scenarios/journal/log. +// Override via PEPA_STATE_DIR if you need a custom location. +export const stateDir = TEST_STATE_DIR + ? path.resolve(TEST_STATE_DIR) + : path.join(REPO_ROOT, "state", serverKey); +export const isTestStateDir = !!TEST_STATE_DIR; export const socketPath = path.join(stateDir, "bot.sock"); // Redacted env view for logs — never include the AuthMe password. diff --git a/runtime/pi-bridge.js b/runtime/pi-bridge.js index ab8e74f..fa792ed 100644 --- a/runtime/pi-bridge.js +++ b/runtime/pi-bridge.js @@ -14,7 +14,10 @@ export function askPi({ prompt, onChunk, onDone, cwd, signal }) { const child = spawn(PI_BIN, ["-p", prompt], { cwd: cwd || process.cwd(), - env: { ...process.env, CI: "1" }, + // PEPA_HEADLESS=1 tells extensions/mineflayer-bridge.ts (loaded by the + // Pi subprocess) NOT to open its own MC connection. The hybrid runtime + // already owns the nickname; a second connection races for the slot. + env: { ...process.env, CI: "1", PEPA_HEADLESS: "1" }, stdio: ["ignore", "pipe", "pipe"], signal, }); diff --git a/runtime/reflex.js b/runtime/reflex.js index fcbc220..388ee95 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -168,9 +168,13 @@ function dispatchDefendFlee(ctx, hostile, dist, opts = {}) { ctx.lastFleeAttempt = { name: hostile.name, ts: Date.now() }; const fromEntity = matchingHostileEntity(ctx, hostile.name, dist); + const onComplete = opts.lessonId + ? (res) => reportAdviceOutcome({ lessonId: opts.lessonId, succeeded: !!res?.ok }) + : undefined; ctx.dispatch( () => fleeFrom(ctx.bot, fromEntity, 16), `flee from ${hostile.name}`, + onComplete ? { onComplete } : {}, ); return { action: "dispatched", kind: "defend-flee", label: hostile.name }; } @@ -203,12 +207,17 @@ function defendReflex(ctx) { } // v0.2.0 — consult learned lessons. If knowledge says "do not // attack in this state" (e.g. creeper rule, or no-weapon - // rule learned from post-mortems), flee instead. This is the - // closing of the learning loop for emergency combat. + // rule learned from post-mortems), flee instead. The lesson outcome + // is reported AFTER the flee skill finishes (via dispatchDefendFlee + // onComplete), not before — flee's success/failure is what proves + // or disproves the lesson, not the act of consulting it. This is + // the closing of the learning loop for emergency combat. const advice = consultAdvice({ plannedSkillId: `attack ${hostile.name}`, snapshot: s }); if (advice.action === "avoid" || advice.action === "override") { - if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: false }); - return dispatchDefendFlee(ctx, hostile, dist, { ignoreCooldown: true }); + return dispatchDefendFlee(ctx, hostile, dist, { + ignoreCooldown: true, + lessonId: advice.lessonId, + }); } ctx.dispatch( () => attackNearestUntilClear(ctx.bot, hostile.name, {