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>
This commit is contained in:
2026-05-27 17:39:43 +03:00
co-authored by Claude Opus 4.7
parent 865aae1213
commit fcfa2277ba
13 changed files with 1113 additions and 20 deletions
+14 -6
View File
@@ -10,7 +10,8 @@
// recall → behavioural change. Without this, the DB is just a log.
import { isAvailable as knowledgeAvailable, topAdvice, markApplied } from "../knowledge/index.js";
import { info } from "../log.js";
import { isRegistered } from "../skill-registry.js";
import { info, warn } from "../log.js";
// Skills we will not blindly swap into — they require their own
// preconditions (e.g. survive.flee needs a known threat direction).
@@ -47,15 +48,19 @@ const MODE_TO_SKILL = Object.freeze({
});
function normalisePreferSkill(raw) {
if (!raw || typeof raw !== "string") return raw;
if (SAFE_OVERRIDES.has(raw)) return raw;
if (!raw || typeof raw !== "string") return null;
if (SAFE_OVERRIDES.has(raw) && isRegistered(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 (SAFE_OVERRIDES.has(dot) && isRegistered(dot)) return dot;
if (MODE_TO_SKILL[dot]) return MODE_TO_SKILL[dot];
return raw;
// Anything else (Pi hallucinated names like "relocate.surface",
// "choose.safe.surface", "survive.shelter", "gather.visible_log") —
// hard reject. We'd rather fall through to 'avoid' / 'proceed' than
// dispatch a nonexistent skill.
return null;
}
/**
@@ -81,7 +86,7 @@ export function consult({ plannedSkillId, snapshot } = {}) {
// avoid_skill matches?
if (advice.avoid && advice.avoid === plannedSkillId) {
const normalisedPrefer = normalisePreferSkill(advice.prefer);
if (normalisedPrefer && SAFE_OVERRIDES.has(normalisedPrefer)) {
if (normalisedPrefer && SAFE_OVERRIDES.has(normalisedPrefer) && isRegistered(normalisedPrefer)) {
if (normalisedPrefer !== advice.prefer) {
info("coach", `advice: normalised prefer "${advice.prefer}" → "${normalisedPrefer}"`);
}
@@ -93,6 +98,9 @@ export function consult({ plannedSkillId, snapshot } = {}) {
lesson: advice.lesson,
};
}
if (advice.prefer && !normalisedPrefer) {
warn("coach", `advice: rejected hallucinated prefer_skill "${advice.prefer}" (lesson #${advice.lessonId})`);
}
info("coach", `advice: avoid ${plannedSkillId} (lesson #${advice.lessonId})`);
return { action: "avoid", lessonId: advice.lessonId, lesson: advice.lesson };
}
+9 -3
View File
@@ -117,10 +117,16 @@ test("normalisePreferSkill: passes through known dot-form skills unchanged", ()
assert.equal(normalisePreferSkill("explore.far"), "explore.far");
});
test("normalisePreferSkill: unknown values returned as-is", () => {
assert.equal(normalisePreferSkill("some.unknown.skill"), "some.unknown.skill");
test("normalisePreferSkill: unknown values rejected (returns null)", () => {
// v0.3.0-rc.1: anything not in the live registry and not a known mode
// name is rejected outright. We'd rather fall through to 'avoid' than
// dispatch a hallucinated skill id.
assert.equal(normalisePreferSkill("some.unknown.skill"), null);
assert.equal(normalisePreferSkill("relocate.surface"), null);
assert.equal(normalisePreferSkill("choose.safe.surface"), null);
assert.equal(normalisePreferSkill("survive.shelter"), null);
assert.equal(normalisePreferSkill(null), null);
assert.equal(normalisePreferSkill(""), "");
assert.equal(normalisePreferSkill(""), null);
});
test("consult: Pi-style mode-name prefer is normalised to override target", async () => {
+167
View File
@@ -0,0 +1,167 @@
// Fast tactical advisor — second LLM tier, parallel to Pi.
//
// Pi (the CLI coach) is great for deep post-mortems and 30-min reflection,
// but it's slow (5-15s) and rate-limited. When the reflex detects the bot
// is wedged, stuck, or just took an environment shock (forced teleport,
// HP plunge, hostile spawn), we want a sub-2-second "what do I do?"
// answer from a cheap, hosted model. That's this module.
//
// In rc.1 this is a scaffold: complete() + advise() + rate-limiting +
// integration tests, but no auto-trigger from the reflex yet. rc.3 wires
// the trigger paths (awareness layer) into here.
//
// The advisor MUST return a JSON shape whose `prefer_skill` field is a
// real, registered skill id — anything else is rejected. The system
// prompt embeds the live registry so the model has the source of truth.
import { complete, isAvailable as llmAvailable } from "../llm/provider.js";
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { info, warn } from "../log.js";
const HOURLY_BUDGET = 6;
const COOLDOWN_MS = 30_000;
let _callTimes = [];
let _lastCallAt = 0;
export function isAvailable() {
return llmAvailable();
}
export function _resetForTest() {
_callTimes = [];
_lastCallAt = 0;
}
/**
* advise({ snapshot, reason, recentSkillIds, lessonsTail }) →
* { ok: true, action: 'switch_skill'|'continue'|'wait', skillId?, rationale, raw, latencyMs }
* | { ok: false, code, detail, latencyMs }
*
* `reason` is a free-text trigger ("wedged_60s", "forced_move",
* "hp_plunge", "stuck_3_dispatches"). It goes verbatim into the prompt
* so the model can tailor its advice.
*/
export async function advise({
snapshot,
reason = "unknown",
recentSkillIds = [],
lessonsTail = [],
force = false,
} = {}) {
if (!isAvailable()) {
return { ok: false, code: "not_configured", detail: "set PEPA_FAST_LLM_API_KEY", latencyMs: 0 };
}
const now = Date.now();
_callTimes = _callTimes.filter((t) => t > now - 3600_000);
if (!force && _callTimes.length >= HOURLY_BUDGET) {
return { ok: false, code: "budget_exhausted", detail: `${_callTimes.length}/${HOURLY_BUDGET} per hour`, latencyMs: 0 };
}
if (!force && now - _lastCallAt < COOLDOWN_MS) {
return { ok: false, code: "cooldown", detail: `${Math.round((COOLDOWN_MS - (now - _lastCallAt)) / 1000)}s`, latencyMs: 0 };
}
const system = buildSystemPrompt();
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail });
_callTimes.push(now);
_lastCallAt = now;
const res = await complete({ system, user, json: true });
if (!res.ok) {
warn("advisor", `complete failed: ${res.code} (${res.detail})`);
return { ok: false, code: res.code, detail: res.detail, latencyMs: res.latencyMs };
}
const parsed = res.text;
if (!parsed || typeof parsed !== "object") {
return { ok: false, code: "bad_shape", detail: "no object in reply", latencyMs: res.latencyMs };
}
const action = String(parsed.action ?? "").toLowerCase();
const skillId = parsed.skill_id ?? parsed.prefer_skill ?? null;
const rationale = parsed.rationale ?? parsed.reason ?? "";
if (action === "switch_skill") {
if (!skillId || !isRegistered(skillId)) {
warn("advisor", `rejected hallucinated skill "${skillId}"`);
return {
ok: false,
code: "hallucinated_skill",
detail: skillId ?? "(null)",
rationale,
raw: parsed,
latencyMs: res.latencyMs,
};
}
info("advisor", `switch_skill → ${skillId} (${rationale.slice(0, 80)})`);
return {
ok: true,
action: "switch_skill",
skillId,
rationale,
raw: parsed,
latencyMs: res.latencyMs,
};
}
if (action === "continue" || action === "wait") {
info("advisor", `${action} (${rationale.slice(0, 80)})`);
return { ok: true, action, rationale, raw: parsed, latencyMs: res.latencyMs };
}
return { ok: false, code: "bad_action", detail: action || "missing", raw: parsed, latencyMs: res.latencyMs };
}
function buildSystemPrompt() {
return [
"You are the tactical advisor for pepa, an autonomous Minecraft survival bot.",
"You are called when the bot's reflex layer detects something wrong (wedged, stuck,",
"forced move, HP plunge). Your job: produce a single fast decision.",
"",
"Reply STRICTLY with a JSON object:",
'{',
' "action": "switch_skill" | "continue" | "wait",',
' "skill_id": "<registered skill id or null>",',
' "rationale": "<≤25 words explaining why>"',
'}',
"",
"Rules:",
'- "switch_skill" REQUIRES skill_id to be one of the registered ids below.',
'- "continue" means current skill is fine, just give it more time.',
'- "wait" means stop dispatching for ~10s (e.g. waiting for night to pass).',
'- If unsure, return "continue".',
"",
skillRegistryPrompt({ limit: 1800 }),
].join("\n");
}
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail }) {
const pos = snapshot?.position;
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
const lessons = (lessonsTail ?? []).slice(0, 4).map((l) => ` - ${l.text ?? l}`).join("\n");
return [
`Trigger: ${reason}`,
`Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`,
`HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`,
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
`Recent dispatches: ${recent}`,
`Inventory keys: ${inv}`,
`Nearby threats: ${formatThreats(snapshot?.threats)}`,
`No-progress reason: ${snapshot?.noProgressReason ?? "(none)"}`,
"",
lessons ? `Relevant lessons:\n${lessons}\n` : "",
"What should the bot do RIGHT NOW? Return the JSON decision.",
].filter(Boolean).join("\n");
}
function formatThreats(threats) {
if (!Array.isArray(threats) || threats.length === 0) return "(none)";
return threats.slice(0, 3).map((t) => `${t.name ?? "?"}@${Math.round(t.distance ?? 0)}m`).join(", ");
}
// Test exports
export const __testing = { buildSystemPrompt, buildUserPrompt, formatThreats, HOURLY_BUDGET, COOLDOWN_MS };
+163
View File
@@ -0,0 +1,163 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { advise, isAvailable, _resetForTest, __testing } from "./fast-advisor.js";
const API_KEY = "PEPA_FAST_LLM_API_KEY";
const MODEL = "PEPA_FAST_LLM_MODEL";
const BASE = "PEPA_FAST_LLM_BASE_URL";
function withEnv(env, fn) {
const prev = {};
for (const k of Object.keys(env)) {
prev[k] = process.env[k];
if (env[k] === undefined) delete process.env[k];
else process.env[k] = env[k];
}
return Promise.resolve(fn()).finally(() => {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
}
function stubFetch(reply) {
const calls = [];
const orig = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return {
ok: true,
json: async () => ({
choices: [{ message: { content: typeof reply === "string" ? reply : JSON.stringify(reply) } }],
}),
};
};
return { calls, restore() { globalThis.fetch = orig; } };
}
test("advise: not_configured without API key", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
_resetForTest();
const res = await advise({ reason: "stuck" });
assert.equal(res.ok, false);
assert.equal(res.code, "not_configured");
});
});
test("advise: accepts a registered skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "survive.flee", rationale: "creeper close" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m", [BASE]: "https://x/v1" }, async () => {
_resetForTest();
const res = await advise({
snapshot: { health: 10, food: 18, isDay: true, position: { x: 1, y: 64, z: 1 } },
reason: "wedged_60s",
recentSkillIds: ["explore.far", "explore.far", "explore.far"],
force: true,
});
assert.equal(res.ok, true);
assert.equal(res.action, "switch_skill");
assert.equal(res.skillId, "survive.flee");
assert.match(res.rationale, /creeper/);
// system prompt should mention the live registry
const sent = JSON.parse(f.calls[0].opts.body);
assert.match(sent.messages[0].content, /Valid skill ids/);
assert.match(sent.messages[0].content, /survive\.flee/);
assert.match(sent.messages[1].content, /wedged_60s/);
});
} finally { f.restore(); }
});
test("advise: rejects hallucinated skill id with code=hallucinated_skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "relocate.surface", rationale: "fresh spot" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "loop", force: true });
assert.equal(res.ok, false);
assert.equal(res.code, "hallucinated_skill");
assert.equal(res.detail, "relocate.surface");
});
} finally { f.restore(); }
});
test("advise: accepts 'continue' and 'wait' without skill_id", async () => {
const f = stubFetch({ action: "continue", rationale: "skill is making slow progress" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "tick", force: true });
assert.equal(res.ok, true);
assert.equal(res.action, "continue");
});
} finally { f.restore(); }
});
test("advise: rate-limit cooldown blocks rapid calls", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const r1 = await advise({ reason: "x" });
assert.equal(r1.ok, true);
const r2 = await advise({ reason: "y" });
assert.equal(r2.ok, false);
assert.equal(r2.code, "cooldown");
});
} finally { f.restore(); }
});
test("advise: hourly budget enforced with force=true override", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
for (let i = 0; i < __testing.HOURLY_BUDGET; i++) {
await advise({ reason: `t${i}`, force: true });
}
const over = await advise({ reason: "over" });
assert.equal(over.ok, false);
assert.equal(over.code, "budget_exhausted");
});
} finally { f.restore(); }
});
test("buildSystemPrompt: contains registry block and JSON schema", () => {
const sys = __testing.buildSystemPrompt();
assert.match(sys, /switch_skill/);
assert.match(sys, /Valid skill ids/);
assert.match(sys, /survive\.flee/);
});
test("buildUserPrompt: includes trigger and recent skills", () => {
const u = __testing.buildUserPrompt({
snapshot: { health: 4, food: 3, isDay: false, position: { x: 10, y: 65, z: 10 }, activeSkill: "explore.far" },
reason: "hp_plunge",
recentSkillIds: ["explore.far", "explore.far"],
lessonsTail: [{ text: "do not fight at night" }],
});
assert.match(u, /hp_plunge/);
assert.match(u, /HP: 4/);
assert.match(u, /Recent dispatches: explore\.far → explore\.far/);
assert.match(u, /do not fight at night/);
});
test("formatThreats: empty / formatted", () => {
assert.equal(__testing.formatThreats(undefined), "(none)");
assert.equal(__testing.formatThreats([]), "(none)");
assert.equal(
__testing.formatThreats([{ name: "zombie", distance: 4.3 }, { name: "creeper", distance: 7 }]),
"zombie@4m, creeper@7m",
);
});
test("isAvailable mirrors provider availability", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
assert.equal(isAvailable(), false);
});
await withEnv({ [API_KEY]: "k" }, async () => {
assert.equal(isAvailable(), true);
});
});
+36 -5
View File
@@ -26,6 +26,7 @@ import {
poiNearby,
recordPOI,
} from "../knowledge/index.js";
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { info, warn } from "../log.js";
const COACH_INTERVAL_MS = 5 * 60 * 1000; // 5 min between coach passes
@@ -278,22 +279,38 @@ export async function drainOnce({ askPi, stateDir, force = false } = {}) {
}
let lessonsCount = 0;
let rejectedPreferCount = 0;
for (const item of asArray(parsed.lessons ?? parsed)) {
if (!item || !item.lesson) continue;
// Skill ids referenced by Pi must be in the live registry.
// Mode names (e.g. "night_shelter") are tolerated at write time and
// translated at consult time by advice.js#normalisePreferSkill.
let preferSkill = item.prefer_skill ?? null;
if (preferSkill && !isRegistered(preferSkill) && !isLikelyModeName(preferSkill)) {
rejectedPreferCount += 1;
preferSkill = null;
}
let avoidSkill = item.avoid_skill ?? null;
if (avoidSkill && !isRegistered(avoidSkill) && !isLikelyModeName(avoidSkill)) {
avoidSkill = null;
}
recordLesson({
text: item.lesson,
category: item.category ?? "survival",
triggerSkill: item.trigger_skill ?? null,
triggerHostile: item.trigger_hostile ?? null,
triggerSituation: item.trigger_situation ?? null,
avoidSkill: item.avoid_skill ?? null,
preferSkill: item.prefer_skill ?? null,
avoidSkill,
preferSkill,
confidence: clamp(Number(item.confidence) || 0.6, 0.1, 0.95),
source: "pi-coach",
sourceRef: item.source_ref ?? null,
});
lessonsCount += 1;
}
if (rejectedPreferCount > 0) {
warn("coach", `dropped prefer_skill from ${rejectedPreferCount} lessons (not in registry)`);
}
// Write one postmortem per death; if Pi grouped them, share the same lesson.
const groupLesson = parsed.lessons?.[0]?.lesson ?? parsed.lesson ?? null;
@@ -313,6 +330,17 @@ export async function drainOnce({ askPi, stateDir, force = false } = {}) {
return { ok: true, analysed: pending.length, lessons: lessonsCount };
}
// Mode names from runtime/modes.js (advice.js#MODE_TO_SKILL) — we accept
// these at write time because advice.js maps them to real skills at consult.
const KNOWN_MODE_NAMES = new Set([
"self_preservation", "night_shelter", "hunger", "shelter",
"flee", "sleep", "eat", "tunnel_out", "tunnel-out", "explore", "wander",
]);
function isLikelyModeName(s) {
if (!s || typeof s !== "string") return false;
return KNOWN_MODE_NAMES.has(s.toLowerCase().trim());
}
function buildPrompt(deaths) {
const summary = deaths.map((d) => {
const ctx = safeParse(d.context_blob);
@@ -334,6 +362,8 @@ function buildPrompt(deaths) {
"The bot is trying to gather wood, craft tools, build a small village, and survive nights.",
"It's currently dying repeatedly. Your job: extract 1-3 short, generalised lessons it can apply on respawn.",
"",
skillRegistryPrompt({ limit: 1800 }),
"",
"DEATHS:",
summary,
"",
@@ -343,12 +373,13 @@ function buildPrompt(deaths) {
' { "lesson": "...", "category": "combat|pathing|crafting|survival|social",',
' "trigger_skill": "<skill id or null>",',
' "trigger_hostile": "<mob name or null>",',
' "avoid_skill": "<skill to NOT dispatch or null>",',
' "prefer_skill": "<alternative skill or null>",',
' "avoid_skill": "<registered skill id to NOT dispatch, or null>",',
' "prefer_skill": "<registered skill id to use instead, or null>",',
' "confidence": 0.7 }',
' ] }',
"",
"Keep each lesson under 30 words. Be specific (e.g., \"attack creeper with fists\" rather than \"don't fight\").",
"CRITICAL: avoid_skill and prefer_skill MUST be one of the registered ids above, or null.",
].join("\n");
}
@@ -390,4 +421,4 @@ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function safeParse(s) { try { return JSON.parse(s); } catch { return null; } }
// Test-only exports
export const __testing = { captureDeath, buildPrompt, extractJson, inferCause };
export const __testing = { captureDeath, buildPrompt, extractJson, inferCause, isLikelyModeName, KNOWN_MODE_NAMES };
+32 -4
View File
@@ -16,8 +16,20 @@ 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 { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { info, warn } from "../log.js";
// Mode-name allow-list, mirrors postmortem.js (advice.js maps them to
// real skills at consult-time). Anything else is hallucination → dropped.
const KNOWN_MODE_NAMES = new Set([
"self_preservation", "night_shelter", "hunger", "shelter",
"flee", "sleep", "eat", "tunnel_out", "tunnel-out", "explore", "wander",
]);
function isLikelyModeName(s) {
if (!s || typeof s !== "string") return false;
return KNOWN_MODE_NAMES.has(s.toLowerCase().trim());
}
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
const HOURLY_BUDGET = 2;
const HISTORY_TAIL_LINES = 80;
@@ -78,20 +90,33 @@ export async function runOnce({ stateDir, askPi, getSnapshot, force = false } =
}
const path = writeReflection(stateDir, parsed, reply);
let rejectedPrefer = 0;
for (const l of asArray(parsed.lessons)) {
if (!l?.lesson) continue;
let preferSkill = l.prefer_skill ?? null;
if (preferSkill && !isRegistered(preferSkill) && !isLikelyModeName(preferSkill)) {
rejectedPrefer += 1;
preferSkill = null;
}
let avoidSkill = l.avoid_skill ?? null;
if (avoidSkill && !isRegistered(avoidSkill) && !isLikelyModeName(avoidSkill)) {
avoidSkill = null;
}
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,
avoidSkill,
preferSkill,
confidence: clamp(Number(l.confidence) || 0.5, 0.1, 0.9),
source: "pi-reflect",
sourceRef: path,
});
}
if (rejectedPrefer > 0) {
warn("reflect", `dropped prefer_skill from ${rejectedPrefer} reflection lessons (not in registry)`);
}
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 ?? [] };
}
@@ -136,6 +161,8 @@ function buildPrompt({ snap, journal, scenarios, diary, plan }) {
"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?",
"",
skillRegistryPrompt({ limit: 1800 }),
"",
"## 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"}`,
@@ -176,8 +203,8 @@ function buildPrompt({ snap, journal, scenarios, diary, plan }) {
' "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>",',
' "avoid_skill": "<registered skill id to avoid or null>",',
' "prefer_skill": "<registered skill id to use instead or null>",',
' "confidence": 0.6 }',
' ]',
'}',
@@ -185,6 +212,7 @@ function buildPrompt({ snap, journal, scenarios, diary, plan }) {
"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.",
"CRITICAL: avoid_skill and prefer_skill MUST be one of the registered ids listed at the top of this prompt, or null. Do NOT invent new ids.",
].join("\n");
}