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:
+14
-6
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user