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>
113 lines
4.2 KiB
JavaScript
113 lines
4.2 KiB
JavaScript
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 {}
|
|
});
|