Files
pepa-pi-bot/runtime/skill-registry.js
mayatnikovandClaude Opus 4.8 cd63edfeaf feat(v0.4.1): make the Settlement Contract honest end-to-end
Patches each link of the closed-loop chain (world-truth -> invariant ->
dispatch -> worldDelta -> milestone) so the contract can no longer advance
on a lie or stall on an unreachable milestone, and surfaces the result on
the monitor.

- Unblock M6_lighting: add gather.coal producer (mirrors gather.stone,
  strict expectGain), register it, add the skill-graph node, and make
  curriculum.shelter.torch two-step (gather coal -> craft torch). craft.torch
  needed coal but no skill produced it, so the contract pinned on M6 forever
  and starved M7/M8/M9. One curriculum edit fixes both rails.
- M8 no longer falsely satisfied: build-shelter records locations.shelter
  only on real progress (setLocation moved below the no_progress guard).
- M2_bed made monotonic: sleepInBed writes locations.bed when the bot places
  its own bed, so M2 stays met after the carried bed item is consumed.
- Observability: new ink-free tui/format.js helper renders per-tick
  state / firing rail / active skill / blocked prerequisite / stall reason
  in the monitor header (fields bot.js already computes but never showed).
- Harden the valid-skill-id guardrail: skillRegistryPrompt preserves the
  "NEVER invent" footer when truncating (a growing registry was dropping it),
  and fix the stale "tech-tree > autonomous" reflex chain in the escalation
  prompt.

Tests: 494/494 passing; new build-shelter / invariants / format suites plus
skill-graph, curriculum, skill-registry cases. lint-patch clean. Deferred
audit findings recorded in plans/v0.4.0-vnext.md (local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:33:45 +03:00

77 lines
2.9 KiB
JavaScript

// Single source of truth for "what skill ids are real" — exported separately
// from skills/index.js so coach/advice.js, coach/postmortem.js, coach/reflect.js,
// and coach/fast-advisor.js can all consult the SAME live registry without
// circular imports through runSkill.
//
// Why this exists: in v0.2.x Pi (the LLM coach) routinely fabricated
// skill ids that never existed — "relocate.surface", "choose.safe.surface",
// "survive.shelter", "gather.visible_log". Of 47 Pi-extracted lessons,
// 0 were ever applied because normalisePreferSkill() couldn't map them
// to anything real. The fix is two-pronged: (a) hand Pi the real registry
// in its system prompt so it doesn't have to guess; (b) reject anything
// not in the registry at the consult() boundary.
import { listSkills } from "./skills/index.js";
let _cache = null;
function rebuild() {
const all = listSkills();
const byId = new Map();
const byNamespace = new Map();
for (const s of all) {
byId.set(s.id, s);
const ns = s.id.split(".")[0] || "misc";
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
byNamespace.get(ns).push(s);
}
_cache = { all, byId, byNamespace };
return _cache;
}
function get() { return _cache ?? rebuild(); }
export function listSkillIds() {
return Array.from(get().byId.keys());
}
export function isRegistered(id) {
if (!id || typeof id !== "string") return false;
return get().byId.has(id);
}
export function describeSkill(id) {
return get().byId.get(id) ?? null;
}
// Human-readable block to drop into LLM system prompts. Groups by
// namespace, lists "id — title". Capped at ~2KB to stay well within the
// model's instruction window. When the list overflows the cap we truncate
// the LIST, never the footer — the footer carries the load-bearing
// guardrail ("NEVER invent new ids"), the whole reason this prompt exists.
// A naive tail-slice (the pre-v0.4.1 behaviour) would silently drop that
// guardrail once the registry grew past the cap, re-opening the exact v0.2
// skill-id hallucination this module was built to close.
export function skillRegistryPrompt({ limit = 3000 } = {}) {
const { byNamespace } = get();
const namespaces = Array.from(byNamespace.keys()).sort();
const lines = ["Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill):"];
for (const ns of namespaces) {
const skills = byNamespace.get(ns).sort((a, b) => a.id.localeCompare(b.id));
lines.push(` ${ns}:`);
for (const s of skills) {
lines.push(` - ${s.id}${s.title ?? s.id}`);
}
}
const body = lines.join("\n");
const footer = "If no listed skill fits, set the field to null. NEVER invent new ids.";
const full = `${body}\n\n${footer}`;
if (full.length <= limit) return full;
const sep = "\n...\n\n";
const room = Math.max(0, limit - footer.length - sep.length);
return `${body.slice(0, room)}${sep}${footer}`;
}
// For tests / hot-reload scenarios.
export function _resetForTest() { _cache = null; }