v0.2.0-rc.2: P0 hardening — Pi headless, test state isolation, advice fixes (#21)
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-<pid>/. 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/<host>/reflections/<ts>.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 <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
@@ -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);
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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}`); }
|
||||
});
|
||||
|
||||
|
||||
+38
-4
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/<host>/reflections/<ts>.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": "<one-sentence directive for what to do next>",',
|
||||
' "lessons": [',
|
||||
' { "lesson": "<≤30 words, generalised rule>",',
|
||||
' "category": "combat|pathing|crafting|survival|self-improve",',
|
||||
' "trigger_skill": "<skill id or null>",',
|
||||
' "trigger_hostile": "<mob name or null>",',
|
||||
' "avoid_skill": "<skill to avoid or null>",',
|
||||
' "prefer_skill": "<alternative skill id or null>",',
|
||||
' "confidence": 0.6 }',
|
||||
' ]',
|
||||
'}',
|
||||
"",
|
||||
"If you're clearly in a loop (same activity, no inventory growth, same position), say so honestly.",
|
||||
"If you're stuck in a bad terrain (deep pit, hostile-rich area), recommend choosing a new base.",
|
||||
"Lessons should be SHORT and ACTIONABLE. Don't repeat lessons the dispatcher already learned.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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 {}
|
||||
});
|
||||
+27
-1
@@ -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/<host>/ 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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+13
-4
@@ -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 <hostile> 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, {
|
||||
|
||||
Reference in New Issue
Block a user