feat(runtime): v0.1.0 — adopt Voyager critic + Mindcraft modes/library/lint

Five concrete patterns from Voyager and Mindcraft, applied in our shape
without abandoning the git-as-evolution-substrate that makes pepa
distinct. Plus a first multi-agent surface so two bots from the same
repo can share intent.

1. runtime/critic.js (Voyager critic.txt)
   - Spawns `pi -p` with a JSON-only critic prompt before a proposal is
     written. {reasoning, success, critique}.
   - success=true short-circuits the proposal (bot recovered between
     detector tripping and now), saving Pi tokens on false positives.
   - critique is spliced into the proposal body via attachCritique() so
     the downstream auto-patcher has a sharp spec.
   - Graceful: pi missing / timeout / unparseable JSON → proposal still
     filed without the critic block.

2. scripts/lint-patch.js (Mindcraft coder._lintCode)
   - Pre-flight gate between Pi commit and npm test: node --check, dynamic
     import (catches missing named exports), regex extraction of
     runSkill("id") calls cross-checked against the live registry.
   - Cheaper than npm test, fails fast with a clear reason.

3. runtime/stuck-incident.renderActionTemplate (Voyager action_template.txt)
   - All proposal bodies now follow the same fixed-section layout: Task /
     Last result / Execution error / State / Metrics / Journal /
     Scenarios / Critique / Fix / Edit scope / Forbidden.

4. runtime/skill-library.js (Mindcraft skill_library.getRelevantSkillDocs)
   - Word-overlap ranking (Mindcraft's offline fallback) — zero deps,
     deterministic. auto-patch.js injects top-3 similar skills into the
     Pi prompt as "look at these patterns".

5. runtime/modes.js (Mindcraft modes.js)
   - Declarative {name, interrupts, on, active, update(ctx)} chain that
     runs BEFORE the curriculum each tick.
   - Ships self_preservation (low HP → eat/flee), hunger (food<14 → eat),
     night_shelter (night + bed in hand → sleep). Cleaner than ad-hoc
     lastFleeAttempt cooldowns in reflex.js.

6. runtime/social/conversation.js + cmd:conv-say/conv-recent/conv-list
   - File-JSONL topic channel so two bots from the same repo (different
     usernames, different host dirs under state/) can append turns and
     read peers. Skeleton — multi-agent collaboration on top later.

Differentiator preserved: every Pi-written skill still lands on main via
auto-patch.js (real git branch + smoke gate + cherry-pick). Voyager
keeps skills in a Chroma JSON, Mindcraft keeps them in RAM — pepa keeps
them as versioned source code reviewable in `git log`.

package.json: 0.0.1 → 0.1.0. 174/174 tests pass. README + AGENTS updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 15:16:42 +03:00
co-authored by Claude Opus 4.7
parent 25e39c7244
commit 4ae63dabe1
18 changed files with 1196 additions and 113 deletions
+24
View File
@@ -10,6 +10,30 @@
> reflex/action in `runtime/` over an extension in `extensions/` — the > reflex/action in `runtime/` over an extension in `extensions/` — the
> hybrid runtime is where new work lands going forward. > hybrid runtime is where new work lands going forward.
> **v0.1.0 — self-improvement loop hardened (2026-05-26).** New runtime
> subsystems landed in the hybrid runtime and shape how new skills get
> proposed and accepted:
> - `runtime/critic.js` — Voyager-style pre-flight critic (Pi judges
> "did the bot really fail?" before a proposal is written;
> `success=true` short-circuits the proposal).
> - `runtime/modes.js` — Mindcraft-style priority chain
> (`self_preservation > hunger > night_shelter`) that runs **before**
> the curriculum and can interrupt it.
> - `runtime/skill-library.js` — top-k similar skills retrieved by word
> overlap and injected into `auto-patch.js` Pi prompt so new skills
> crib patterns from working ones.
> - `runtime/perception.js` — numeric-id `findBlocks` wrapper. Don't write
> `bot.findBlock({matching: (b) => ...b.name...})` — under ViaBackwards
> `.name` is wrong inside the callback (see mineflayer #2347).
> - `runtime/social/conversation.js` — file-JSONL multi-agent topics with
> IPC commands `cmd:conv-say|conv-recent|conv-list`.
> - `scripts/lint-patch.js` — pre-flight gate that catches parse errors,
> missing named imports, and `runSkill("unknown.id")` before `npm test`.
> - Proposals are rendered through `renderActionTemplate()` —
> Voyager `action_template.txt` schema (Task / Last result / Execution
> error / State / Metrics / Journal / Scenario memory / Critique / Fix
> / Edit scope / Forbidden). Stable layout = Pi scans faster.
> **Product pivot (2026-05-25, Phase 0).** The bot is no longer a remote > **Product pivot (2026-05-25, Phase 0).** The bot is no longer a remote
> control for operators or players. It is becoming a self-sufficient survival > control for operators or players. It is becoming a self-sufficient survival
> resident of the configured Minecraft server. **Minecraft chat is > resident of the configured Minecraft server. **Minecraft chat is
+12 -3
View File
@@ -1,8 +1,10 @@
# pepa-pi-bot # pepa-pi-bot
> A universal, autonomous, self-extending Minecraft player. Built on [Mineflayer](https://github.com/PrismarineJS/mineflayer) with a hybrid runtime: a fast script-driven reflex loop for the everyday, and headless [Pi](https://pi.dev) escalation for the hard bits. Works against **any** Minecraft Java server — vanilla, Paper, Spigot, Fabric, Forge, online-mode or cracked, modded or vanilla. > A universal, autonomous, **self-extending** Minecraft player. Built on [Mineflayer](https://github.com/PrismarineJS/mineflayer) with a hybrid runtime: a fast script-driven reflex loop for the everyday, headless [Pi](https://pi.dev) escalation for the hard bits, and a **git-as-evolution-substrate** loop where the bot writes its own new skills and cherry-picks them onto `main` after passing a real `npm test` smoke gate. Works against **any** Minecraft Java server — vanilla, Paper, Spigot, Fabric, Forge, online-mode or cracked, modded or vanilla.
The bot is **not a finished application**. It is a seed: a Mineflayer body, a tiny reflex brain, and a hand-off to whatever Minecraft server you point it at. The bot is expected to grow its own toolset over time — writing new reflexes, installing skills, adapting its behaviour as it plays. The bot is **not a finished application**. It is a seed: a Mineflayer body, a tiny reflex brain, persistent memory (`world-journal`, `scenario-memory`), a Voyager-style critic + Mindcraft-style modes/skill-library, and a self-improvement loop. The bot is expected to grow its own toolset over time — writing new reflexes, installing skills, adapting its behaviour as it plays.
**Related work**: conceptually close to [Voyager](https://github.com/MineDojo/Voyager) (NVIDIA, GPT-4) and [Mindcraft](https://github.com/mindcraft-bots/mindcraft) (multi-agent LLM framework). The differentiator is that pepa stores its growing skill library as **versioned source code on `main`**, not as JSON in RAM — every Pi-written skill goes through `git checkout -b → npm test → cherry-pick`, making the loop auditable and rollback-safe.
The name `pepa-pi-bot` is just the project's name (`pepa` from the original test server, `pi` from the original runtime). The bot itself is server-agnostic. The name `pepa-pi-bot` is just the project's name (`pepa` from the original test server, `pi` from the original runtime). The bot itself is server-agnostic.
@@ -31,8 +33,15 @@ Most Minecraft AI bots ship as monolithic projects: hard-coded actions, fixed pr
┌──────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────┐
│ runtime/bot.js — long-running Node daemon │ │ runtime/bot.js — long-running Node daemon │
│ ├── Mineflayer client (MC TCP, AuthMe, chat, events) │ │ ├── Mineflayer client (MC TCP, AuthMe, chat, events) │
│ ├── Reflex loop (defend > eat > sleep > idle) │ ├── Modes chain (self_preservation > hunger > shelter)
│ │ priority interrupts before curriculum dispatch │
│ ├── Reflex loop (defend > eat > sleep > curriculum) │
│ │ pure script — no LLM in the hot path │ │ │ pure script — no LLM in the hot path │
│ ├── perception.js — numeric-id findBlocks (VB-safe) │
│ ├── world-journal + scenario-memory (persistent JSONL) │
│ ├── stuck-incident → critic (Pi) → proposal │
│ ├── auto-improve → auto-patch → npm test → cherry-pick │
│ ├── social/conversation — file-JSONL multi-agent topics │
│ └── pi-bridge — spawn `pi -p` only on demand │ │ └── pi-bridge — spawn `pi -p` only on demand │
└──────────────────────┬───────────────────────────────────┘ └──────────────────────┬───────────────────────────────────┘
│ TCP 25565 (any host/port) │ TCP 25565 (any host/port)
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "pepa-pi-bot", "name": "pepa-pi-bot",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.", "description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.",
"license": "MIT", "license": "MIT",
@@ -16,7 +16,7 @@
"tui": "tsx tui/tui.tsx", "tui": "tsx tui/tui.tsx",
"propose:apply": "node scripts/propose-apply.js", "propose:apply": "node scripts/propose-apply.js",
"stop": "bash scripts/stop.sh", "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/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 scripts/edit-scope.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/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 scripts/edit-scope.test.js scripts/lint-patch.test.js"
}, },
"dependencies": { "dependencies": {
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
+62 -25
View File
@@ -46,7 +46,9 @@ import { runSkill } from "./skills/index.js";
import { classifyIntent, INTENTS } from "./social/intent.js"; import { classifyIntent, INTENTS } from "./social/intent.js";
import { generateReply } from "./social/reply.js"; import { generateReply } from "./social/reply.js";
import { createChatMemory } from "./social/memory.js"; import { createChatMemory } from "./social/memory.js";
import { createStuckIncidentDetector } from "./stuck-incident.js"; import { openConversation, peekConversation, listConversations } from "./social/conversation.js";
import { createStuckIncidentDetector, attachCritique } from "./stuck-incident.js";
import { requestCritique } from "./critic.js";
import { createSkillMetrics } from "./skill-metrics.js"; import { createSkillMetrics } from "./skill-metrics.js";
import { createWorldJournal } from "./world-journal.js"; import { createWorldJournal } from "./world-journal.js";
import { createScenarioMemory, situationHash } from "./scenario-memory.js"; import { createScenarioMemory, situationHash } from "./scenario-memory.js";
@@ -415,6 +417,37 @@ function maybeFileProposal(label) {
appendDiary(`proposal filed: ${filename} (${summary})`); appendDiary(`proposal filed: ${filename} (${summary})`);
} }
// Async pre-flight critic — wrapper around writeProposal that asks Pi
// "did the bot actually fail?" first. Runs detached so reflex keeps
// ticking while critic burns 160s. If critic.success=true we drop the
// proposal entirely; otherwise the critique is spliced into the body.
async function filePostCritique(incident, channel) {
const critique = await requestCritique({
snapshot: lastSnapshot,
lastResult,
scenarioTail: scenarioMemory.recentTailFor({ n: 12 }),
milestone: lastSnapshot?.curriculum?.milestone?.title,
kind: incident.kind,
});
if (critique?.success) {
info(channel, `critic says already-recovered (${(critique.reasoning || "").slice(0, 100)}) — skipping proposal`);
return;
}
try {
const body = attachCritique(incident.body, critique);
const { filename } = writeProposal({
kind: incident.kind,
summary: incident.summary,
body,
editScope: incident.editScope,
});
warn(channel, `filed ${filename}: ${incident.summary}`);
appendDiary(`${channel}-proposal filed: ${filename} (${incident.summary})`);
} catch (e) {
warn(channel, `writeProposal failed: ${e.message}`);
}
}
// ---- chat (dialog-only via social/) ---------------------------------------- // ---- chat (dialog-only via social/) ----------------------------------------
// //
// MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes // MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes
@@ -784,18 +817,7 @@ function tick() {
now, now,
}); });
if (stuck?.fire) { if (stuck?.fire) {
try { void filePostCritique(stuck, "stuck");
const { filename } = writeProposal({
kind: stuck.kind,
summary: stuck.summary,
body: stuck.body,
editScope: stuck.editScope,
});
warn("stuck", `filed ${filename}: ${stuck.summary}`);
appendDiary(`stuck-proposal filed: ${filename} (${stuck.summary})`);
} catch (e) {
warn("stuck", `writeProposal failed: ${e.message}`);
}
} }
// Second fast-track trigger: explicit wedged loop (escape-pit ran N // Second fast-track trigger: explicit wedged loop (escape-pit ran N
@@ -810,18 +832,7 @@ function tick() {
now, now,
}); });
if (wedged?.fire) { if (wedged?.fire) {
try { void filePostCritique(wedged, "wedged");
const { filename } = writeProposal({
kind: wedged.kind,
summary: wedged.summary,
body: wedged.body,
editScope: wedged.editScope,
});
warn("wedged", `filed ${filename}: ${wedged.summary}`);
appendDiary(`wedged-proposal filed: ${filename}`);
} catch (e) {
warn("wedged", `writeProposal failed: ${e.message}`);
}
} }
ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot); ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot);
@@ -941,6 +952,32 @@ function handleCommand(msg, send) {
tryDispatch(); tryDispatch();
break; break;
} }
case COMMAND_TYPES.CONV_SAY: {
const { topic: topic_, text, intent, position } = msg.payload ?? {};
if (!topic_ || !text) { send(EVENT_TYPES.ERROR, { source: "conv", text: "topic and text required" }); return; }
try {
const h = openConversation(topic_, { speaker: cfg.username });
const turn = h.append({ text, intent, position: position ?? lastSnapshot?.position });
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: `say to ${topic_}`, details: turn });
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
break;
}
case COMMAND_TYPES.CONV_RECENT: {
const { topic: topic_, n } = msg.payload ?? {};
if (!topic_) { send(EVENT_TYPES.ERROR, { source: "conv", text: "topic required" }); return; }
try {
const turns = peekConversation(topic_, n ?? 10);
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: `recent ${topic_}`, details: { topic: topic_, turns } });
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
break;
}
case COMMAND_TYPES.CONV_LIST: {
try {
const topics = listConversations();
send(EVENT_TYPES.LOG, { ts: new Date().toISOString(), level: "info", source: "conv", text: "list", details: { topics } });
} catch (e) { send(EVENT_TYPES.ERROR, { source: "conv", text: e.message }); }
break;
}
default: default:
warn("ipc", `unknown command type: ${msg.type}`); warn("ipc", `unknown command type: ${msg.type}`);
} }
+166
View File
@@ -0,0 +1,166 @@
// Critic pass — adapted from Voyager's critic.txt.
//
// Before we file an auto-improvement proposal, ask Pi to look at the
// state + recent attempts and answer: "did the bot actually fail, and if
// so what should the patcher focus on?". Returns a JSON {reasoning,
// success, critique}. The proposal body then embeds the critique so the
// downstream auto-patch run has a sharp spec instead of raw metrics.
//
// Why a separate Pi call rather than baking it into the patch prompt:
// * the patcher is biased toward writing code; the critic is biased
// toward judging behaviour. Different prompt, different output.
// * we cache the critique on the proposal, so the patcher can re-read
// it without re-spending Pi tokens.
// * if the critic says success=true, we DO NOT file the proposal at
// all — the bot may have already recovered between when the stuck
// detector tripped and now, and a false positive proposal just
// burns Pi tokens.
//
// Failure modes are graceful: if Pi is missing, times out, or the JSON
// can't be parsed, we return null and the caller files the proposal
// without a critique section. Better to be slightly noisier than to
// drop a real stuck incident.
import { spawn } from "node:child_process";
import { info, warn } from "./log.js";
const PI_BIN = process.env.PI_BIN || "pi";
const DEFAULT_TIMEOUT_MS = 60_000;
const SYSTEM_PROMPT = [
"You are the critic for an autonomous Minecraft bot.",
"",
"You will receive a snapshot of the bot's state, the last skill result,",
"recent scenario memory, and the milestone it is trying to reach. Decide",
"whether the bot actually failed or merely paused, and if it failed,",
"give a short, surgical critique a code-patching agent can act on.",
"",
"Respond with ONE JSON object — no prose, no markdown fence — matching:",
'{ "reasoning": string, "success": boolean, "critique": string }',
"",
"Rules:",
"- `success: true` ONLY if the bot's current state already satisfies the",
" milestone. (e.g. milestone = chop 1 log AND inventory shows ≥1 log).",
"- `critique` ≤ 300 chars, imperative voice, must name the specific code",
" area or skill to change (e.g. \"gather.logs blacklists the target",
" on the first silent_dig_failure — clear blacklist after movement\").",
"- Do not invent file paths. If you don't know which file, name the skill",
" id instead and let the patcher resolve.",
"- No trailing commas, no single quotes — must parse with JSON.parse.",
"",
"Examples:",
'INPUT: {"milestone":"chop 1 log","inventory":{"dirt":1},"lastResult":"gather.logs → no_target","scenarioTail":["gather.logs FAIL no_target ×5"]}',
'OUTPUT: {"reasoning":"Bot has no logs and gather.logs returns no_target repeatedly while standing on dark_oak_leaves. findBlock callback matcher is broken under ViaBackwards.","success":false,"critique":"Switch gather.logs from bot.findBlock callback matcher to numeric-id matching via runtime/perception.js (see chopNearestTree)."}',
"",
'INPUT: {"milestone":"chop 1 log","inventory":{"oak_log":2},"lastResult":"gather.logs → done","scenarioTail":["gather.logs OK done"]}',
'OUTPUT: {"reasoning":"Inventory already has 2 oak_log, exceeding the 1-log goal.","success":true,"critique":""}',
].join("\n");
function buildUserBlock({ snapshot, lastResult, scenarioTail, milestone, kind }) {
const slim = {
kind,
milestone: milestone ?? null,
position: snapshot?.position ?? null,
health: snapshot?.health ?? null,
food: snapshot?.food ?? null,
isDay: snapshot?.isDay ?? null,
inventory: snapshot?.inventory ?? {},
closestHostile: snapshot?.closestHostile ?? null,
lastResult: lastResult
? {
label: lastResult.label,
ok: !!lastResult.ok,
code: lastResult.code ?? null,
detail: typeof lastResult.detail === "string"
? lastResult.detail.slice(0, 200)
: lastResult.detail,
}
: null,
scenarioTail: Array.isArray(scenarioTail)
? scenarioTail.slice(-10).map((e) => `${e.skillId} ${e.ok ? "OK" : "FAIL"} ${e.code ?? ""}`)
: [],
};
return `INPUT:\n${JSON.stringify(slim)}\nOUTPUT:`;
}
// Strip a ```json fence or a leading "OUTPUT:" if Pi adds one anyway.
function extractJsonObject(text) {
if (!text) return null;
let s = String(text).trim();
s = s.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
s = s.replace(/^OUTPUT:\s*/i, "");
// Find the first balanced {...}
const first = s.indexOf("{");
if (first < 0) return null;
let depth = 0;
for (let i = first; i < s.length; i++) {
const c = s[i];
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) {
const candidate = s.slice(first, i + 1);
try { return JSON.parse(candidate); } catch { return null; }
}
}
}
return null;
}
export async function requestCritique({ snapshot, lastResult, scenarioTail, milestone, kind, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserBlock({ snapshot, lastResult, scenarioTail, milestone, kind })}`;
return new Promise((resolve) => {
const startedAt = Date.now();
let child;
try {
child = spawn(PI_BIN, ["-p", prompt], {
env: { ...process.env, CI: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
warn("critic", `spawn failed: ${e.message}`);
resolve(null);
return;
}
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (c) => { stdout += c; });
child.stderr.on("data", (c) => { stderr += c; });
const timer = setTimeout(() => {
warn("critic", `pi timeout after ${timeoutMs}ms — killing`);
try { child.kill("SIGTERM"); } catch {}
}, timeoutMs);
child.on("error", (e) => {
clearTimeout(timer);
warn("critic", `pi error: ${e.message}`);
resolve(null);
});
child.on("exit", (code) => {
clearTimeout(timer);
const dur = Date.now() - startedAt;
info("critic", `pi exited code=${code} after ${dur}ms (stdout=${stdout.length}B)`);
if (code !== 0) {
warn("critic", `pi non-zero: stderr=${stderr.slice(0, 200)}`);
resolve(null);
return;
}
const parsed = extractJsonObject(stdout);
if (!parsed || typeof parsed.success !== "boolean") {
warn("critic", `unparseable output: ${stdout.slice(0, 200)}`);
resolve(null);
return;
}
resolve({
reasoning: String(parsed.reasoning ?? "").slice(0, 500),
success: !!parsed.success,
critique: String(parsed.critique ?? "").slice(0, 500),
durationMs: dur,
});
});
});
}
// Pure helper exported for tests.
export const _internal = { extractJsonObject, buildUserBlock };
+50
View File
@@ -0,0 +1,50 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { _internal } from "./critic.js";
test("extractJsonObject: bare object", () => {
const got = _internal.extractJsonObject('{"reasoning":"x","success":true,"critique":""}');
assert.equal(got.success, true);
assert.equal(got.reasoning, "x");
});
test("extractJsonObject: fenced markdown", () => {
const got = _internal.extractJsonObject('```json\n{"reasoning":"a","success":false,"critique":"b"}\n```');
assert.equal(got.success, false);
assert.equal(got.critique, "b");
});
test("extractJsonObject: leading OUTPUT: prefix", () => {
const got = _internal.extractJsonObject('OUTPUT:\n{"reasoning":"r","success":true,"critique":""}');
assert.equal(got.reasoning, "r");
});
test("extractJsonObject: junk before object is tolerated", () => {
const got = _internal.extractJsonObject('Some chatter from Pi.\n{"reasoning":"a","success":true,"critique":""}\nMore chatter.');
assert.equal(got.success, true);
});
test("extractJsonObject: nested braces parse correctly", () => {
const got = _internal.extractJsonObject('{"reasoning":"nest {a:1}","success":true,"critique":""}');
assert.equal(got.success, true);
});
test("extractJsonObject: garbage returns null, not throw", () => {
assert.equal(_internal.extractJsonObject("not json at all"), null);
assert.equal(_internal.extractJsonObject(""), null);
assert.equal(_internal.extractJsonObject(null), null);
});
test("buildUserBlock includes milestone + slim snapshot", () => {
const s = _internal.buildUserBlock({
snapshot: { position: { x: 1, y: 2, z: 3 }, health: 10, food: 17, inventory: { dirt: 1 } },
lastResult: { label: "gather.logs", ok: false, code: "no_target", detail: "no reachable log" },
scenarioTail: [{ skillId: "gather.logs", ok: false, code: "no_target" }],
milestone: "chop 1 log",
kind: "stuck-no_food_source",
});
assert.ok(s.includes("chop 1 log"));
assert.ok(s.includes("no_target"));
assert.ok(s.startsWith("INPUT:"));
assert.ok(s.endsWith("OUTPUT:"));
});
+3
View File
@@ -28,6 +28,9 @@ export const COMMAND_TYPES = Object.freeze({
PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal
PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/ PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/
RUN_SKILL: "cmd:run-skill", // { skillId, args? } dispatch a skill once (operator ground-truth probes) RUN_SKILL: "cmd:run-skill", // { skillId, args? } dispatch a skill once (operator ground-truth probes)
CONV_SAY: "cmd:conv-say", // { topic, text, intent?, position? } append turn to a multi-agent topic
CONV_RECENT: "cmd:conv-recent", // { topic, n? } read last n turns
CONV_LIST: "cmd:conv-list", // list active conversation topics
}); });
export function encodeFrame(obj) { export function encodeFrame(obj) {
+126
View File
@@ -0,0 +1,126 @@
// Modes priority chain — adapted from Mindcraft modes.js.
//
// A mode is `{ name, interrupts, on, active, update(ctx) }`. Each
// reflex tick the scheduler walks the modes in order BEFORE dispatching
// the curriculum-suggested skill. The first mode whose `update()`
// returns `{ action, interrupts }` wins: if interrupts.includes("all")
// the curriculum skill for this tick is cancelled, and the mode's
// returned action is dispatched instead.
//
// Why: today's `reflex.js` mixes panic responses (flee, eat, sleep)
// with the long-tail curriculum logic and uses ad-hoc cooldowns
// (`lastFleeAttempt`, `lastEatAt`). The Mindcraft shape is cleaner:
// declarative interrupts, explicit on/active state, and the same loop
// covers self_preservation (drowning/lava/low-HP), hunger, and night
// shelter. We start small — only the three modes we actually need —
// and let new modes register via `registerMode()` so new skills can
// hook in without editing this file.
const modes = [];
export function registerMode(mode) {
if (!mode || typeof mode.name !== "string") throw new Error("mode: missing name");
if (typeof mode.update !== "function") throw new Error(`mode ${mode.name}: missing update`);
const existing = modes.findIndex((m) => m.name === mode.name);
const filled = {
on: mode.on ?? true,
active: false,
interrupts: mode.interrupts ?? [],
...mode,
};
if (existing >= 0) modes[existing] = filled;
else modes.push(filled);
}
export function listModes() {
return modes.map((m) => ({ name: m.name, on: m.on, active: m.active, interrupts: m.interrupts }));
}
export function setModeEnabled(name, on) {
const m = modes.find((x) => x.name === name);
if (m) m.on = !!on;
}
// Reset for tests — keeps the priority list, just drops registrations.
export function _resetModes() { modes.length = 0; }
// Run every enabled mode in order until one returns a non-null result.
// Sync — modes are observational over a snapshot, no await. Anything
// long-running belongs in the skill the mode dispatches.
export function tickModes(ctx) {
for (const m of modes) {
if (!m.on) continue;
let res = null;
try {
res = m.update(ctx);
} catch (e) {
res = null;
}
if (res?.action) {
m.active = true;
return {
mode: m.name,
action: res.action,
interrupts: res.interrupts ?? m.interrupts,
detail: res.detail ?? null,
};
}
m.active = false;
}
return null;
}
// --- Standard modes registered at module load. Callers can override
// any of these by calling registerMode() with the same name. -----------
registerMode({
name: "self_preservation",
description: "Low HP, lava, drowning — drop the curriculum, flee or eat",
interrupts: ["all"],
update(ctx) {
const snap = ctx?.snapshot;
if (!snap) return null;
const hp = snap.health ?? 20;
const food = snap.food ?? 20;
// HP critically low + we have food → eat NOW
if (hp < 6 && food > 0 && snap.hasFood) {
return { action: { skillId: "eat" }, detail: { reason: "hp<6", hp } };
}
// Hostile within reach and HP low → flee
const ch = snap.closestHostile;
if (ch && typeof ch.distance === "number" && ch.distance < 6 && hp < 10) {
return { action: { skillId: "explore.far" }, detail: { reason: "hp<10 near-hostile", hp, dist: ch.distance } };
}
return null;
},
});
registerMode({
name: "hunger",
description: "Eat proactively when food bar dips below 14",
interrupts: ["curriculum"],
update(ctx) {
const snap = ctx?.snapshot;
if (!snap) return null;
if ((snap.food ?? 20) < 14 && snap.hasFood) {
return { action: { skillId: "eat" }, detail: { reason: "food<14", food: snap.food } };
}
return null;
},
});
registerMode({
name: "night_shelter",
description: "After dusk, sleep in or place a bed so player night-skip works",
interrupts: ["curriculum"],
update(ctx) {
const snap = ctx?.snapshot;
if (!snap) return null;
// Only at night and only if we actually carry / can place a bed
if (snap.isDay) return null;
const inv = snap.inventory || {};
const hasBed = Object.keys(inv).some((n) => /_bed$/.test(n));
if (!hasBed) return null;
return { action: { skillId: "sleep" }, detail: { reason: "night with bed in hand" } };
},
});
+95
View File
@@ -0,0 +1,95 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { registerMode, tickModes, listModes, setModeEnabled, _resetModes } from "./modes.js";
test("tickModes: first mode that fires wins", async () => {
_resetModes();
registerMode({
name: "high",
interrupts: ["all"],
update: () => ({ action: { skillId: "from-high" } }),
});
registerMode({
name: "low",
update: () => ({ action: { skillId: "from-low" } }),
});
const out = tickModes({});
assert.equal(out.mode, "high");
assert.equal(out.action.skillId, "from-high");
});
test("tickModes: disabled mode is skipped", async () => {
_resetModes();
registerMode({ name: "skipme", update: () => ({ action: { skillId: "x" } }) });
registerMode({ name: "use", update: () => ({ action: { skillId: "y" } }) });
setModeEnabled("skipme", false);
const out = tickModes({});
assert.equal(out.mode, "use");
});
test("tickModes: returns null when no mode fires", async () => {
_resetModes();
registerMode({ name: "silent", update: () => null });
const out = tickModes({});
assert.equal(out, null);
});
test("tickModes: thrown update doesn't break the chain", async () => {
_resetModes();
registerMode({
name: "throws",
update: () => { throw new Error("boom"); },
});
registerMode({
name: "next",
update: () => ({ action: { skillId: "rescued" } }),
});
const out = tickModes({});
assert.equal(out.mode, "next");
});
test("registerMode: same name replaces, doesn't duplicate", async () => {
_resetModes();
registerMode({ name: "x", update: () => ({ action: { skillId: "v1" } }) });
registerMode({ name: "x", update: () => ({ action: { skillId: "v2" } }) });
const out = tickModes({});
assert.equal(out.action.skillId, "v2");
assert.equal(listModes().length, 1);
});
test("standard modes load on import", async () => {
const mod = await import(`./modes.js?cb=${Date.now()}`);
const names = mod.listModes().map((m) => m.name);
assert.ok(names.includes("self_preservation"));
assert.ok(names.includes("hunger"));
assert.ok(names.includes("night_shelter"));
});
test("self_preservation: low-HP + food + hasFood → eat", async () => {
_resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 1}`);
const out = mod.tickModes({ snapshot: { health: 4, food: 10, hasFood: true } });
assert.equal(out.mode, "self_preservation");
assert.equal(out.action.skillId, "eat");
});
test("hunger: food below 14 with food → eat", async () => {
_resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 2}`);
const out = mod.tickModes({ snapshot: { health: 20, food: 12, hasFood: true } });
assert.equal(out.action.skillId, "eat");
});
test("night_shelter: day → null (skip)", async () => {
_resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 3}`);
const out = mod.tickModes({ snapshot: { isDay: true, food: 20, hasFood: false, inventory: { red_bed: 1 } } });
assert.equal(out, null);
});
test("night_shelter: night + bed in hand → sleep", async () => {
_resetModes();
const mod = await import(`./modes.js?cb=${Date.now() + 4}`);
const out = mod.tickModes({ snapshot: { isDay: false, food: 20, hasFood: false, inventory: { red_bed: 1 } } });
assert.equal(out.action.skillId, "sleep");
});
+18
View File
@@ -26,6 +26,7 @@ import {
} from "./actions.js"; } from "./actions.js";
import { runSkill, getSkill } from "./skills/index.js"; import { runSkill, getSkill } from "./skills/index.js";
import { situationHash } from "./scenario-memory.js"; import { situationHash } from "./scenario-memory.js";
import { tickModes } from "./modes.js";
// Each "wander hint" triggered by a skill returning no_target should take // Each "wander hint" triggered by a skill returning no_target should take
// the bot meaningfully further than 16 blocks — otherwise the curriculum // the bot meaningfully further than 16 blocks — otherwise the curriculum
@@ -295,6 +296,23 @@ export function runTick(ctx) {
if (ctx.busy) { if (ctx.busy) {
return { reflex: "busy", action: "skipped", label: ctx.currentActionLabel ?? "(?)" }; return { reflex: "busy", action: "skipped", label: ctx.currentActionLabel ?? "(?)" };
} }
// Modes (Mindcraft-style priority chain) run BEFORE the legacy reflex
// chain. Any mode with interrupts:["all"] wins outright; ones that only
// interrupt the curriculum just steer us toward a particular skill via
// runSkill. The reflex chain stays as the fallback for things the modes
// don't cover yet.
const modeHit = tickModes(ctx);
if (modeHit?.action?.skillId) {
const fn = () => runSkill(modeHit.action.skillId, ctx, modeHit.action.args ?? {});
ctx.lastReflex = { name: `mode:${modeHit.mode}`, label: modeHit.action.skillId, ts: Date.now() };
return {
reflex: `mode:${modeHit.mode}`,
action: "dispatch",
label: modeHit.action.skillId,
fn,
detail: modeHit.detail,
};
}
for (const reflex of REFLEXES) { for (const reflex of REFLEXES) {
let outcome; let outcome;
try { try {
+112
View File
@@ -0,0 +1,112 @@
// Skill-library retrieval — adapted from Mindcraft skill_library.js.
//
// Each registered skill exposes a doc string (id, title, top-of-file
// jsdoc comment when present). When auto-patch.js builds the prompt for
// Pi, we rank docs by overlap with the proposal text and include the
// top-k similar skills so Pi can crib patterns from working code.
//
// We deliberately use word-overlap (Mindcraft's fallback) instead of
// embeddings — zero deps, no network, deterministic. If we later want
// embeddings the API stays the same.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { listSkills } from "./skills/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Pull the leading jsdoc-style comment block out of a skill source file.
// Mineflayer-style skill files start with a `// ... // ...` comment header
// that already documents intent; we treat that as the doc string.
function extractHeaderComment(src) {
if (!src) return "";
const lines = src.split("\n");
const out = [];
for (const line of lines) {
const t = line.trim();
if (t.startsWith("//")) {
out.push(t.replace(/^\/\/\s?/, ""));
} else if (out.length > 0) {
break;
} else if (t === "") {
continue;
} else {
break;
}
}
return out.join(" ").slice(0, 800);
}
function skillFilePath(id) {
const slug = id.replace(/\./g, "-");
const candidates = [
path.join(__dirname, "skills", `${slug}.js`),
path.join(__dirname, "skills", `${slug.replace(/-/g, "_")}.js`),
];
for (const p of candidates) if (fs.existsSync(p)) return p;
return null;
}
let cache = null;
function loadDocs() {
if (cache) return cache;
const entries = [];
for (const sk of listSkills()) {
const fp = skillFilePath(sk.id);
let header = "";
if (fp) {
try { header = extractHeaderComment(fs.readFileSync(fp, "utf8")); } catch {}
}
entries.push({ id: sk.id, title: sk.title, doc: `${sk.id}${sk.title}\n${header}` });
}
cache = entries;
return cache;
}
// Word-overlap scoring identical in spirit to Mindcraft's
// wordOverlapScore: lower-case, split on non-word, count overlap.
const STOP = new Set(["the", "a", "an", "to", "of", "and", "or", "for", "in", "on", "with", "is", "are", "was", "be", "if", "we", "you", "i", "it", "that", "this", "by", "from", "at", "as", "but", "not"]);
function tokenise(s) {
return new Set(
String(s || "")
.toLowerCase()
.split(/[^a-z0-9_]+/)
.filter((w) => w.length > 2 && !STOP.has(w)),
);
}
export function wordOverlapScore(a, b) {
const A = tokenise(a);
const B = tokenise(b);
if (A.size === 0 || B.size === 0) return 0;
let inter = 0;
for (const w of A) if (B.has(w)) inter++;
return inter / Math.sqrt(A.size * B.size);
}
export function relevantSkillDocs(query, { k = 3, alwaysShow = [] } = {}) {
const docs = loadDocs();
const scored = docs.map((e) => ({ ...e, score: wordOverlapScore(query, e.doc) }));
scored.sort((a, b) => b.score - a.score);
const picked = new Map();
for (const id of alwaysShow) {
const hit = docs.find((d) => d.id === id);
if (hit) picked.set(hit.id, hit);
}
for (const s of scored.slice(0, k)) picked.set(s.id, s);
return Array.from(picked.values());
}
// Render the relevant-docs block for embedding into a Pi prompt.
export function renderRelevantDocs(query, opts) {
const picked = relevantSkillDocs(query, opts);
if (picked.length === 0) return "_(no skill docs registered)_";
return picked
.map((e) => `### \`${e.id}\`${e.title}\n${e.doc}`)
.join("\n\n");
}
// Reset for tests.
export function _resetCache() { cache = null; }
+44
View File
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { wordOverlapScore, relevantSkillDocs, renderRelevantDocs, _resetCache } from "./skill-library.js";
test("wordOverlapScore: identical strings → ~1", () => {
const s = wordOverlapScore("chop nearest tree", "chop nearest tree");
assert.ok(s > 0.9);
});
test("wordOverlapScore: disjoint strings → 0", () => {
const s = wordOverlapScore("alpha beta gamma", "zeta eta theta");
assert.equal(s, 0);
});
test("wordOverlapScore: stopwords don't dominate", () => {
const s = wordOverlapScore("the and of for in", "the and of for in");
assert.equal(s, 0);
});
test("wordOverlapScore: tokens shorter than 3 chars ignored", () => {
const s = wordOverlapScore("a b c", "a b c");
assert.equal(s, 0);
});
test("relevantSkillDocs: ranks logs-related query toward gather.logs", () => {
_resetCache();
const picked = relevantSkillDocs("bot cannot chop a tree, gather.logs returns no_target");
const ids = picked.map((e) => e.id);
assert.ok(ids.includes("gather.logs"), `expected gather.logs in top-k, got ${ids.join(",")}`);
});
test("relevantSkillDocs: alwaysShow guarantees inclusion", () => {
_resetCache();
const picked = relevantSkillDocs("totally unrelated string", { k: 1, alwaysShow: ["explore.far"] });
const ids = picked.map((e) => e.id);
assert.ok(ids.includes("explore.far"));
});
test("renderRelevantDocs: produces non-empty markdown for known query", () => {
_resetCache();
const md = renderRelevantDocs("gather logs from nearby tree", { k: 2 });
assert.ok(md.includes("###"));
assert.ok(md.includes("gather.logs") || md.includes("chop"));
});
+119
View File
@@ -0,0 +1,119 @@
// Multi-agent conversation skeleton — inspired by Mindcraft
// mindserver_proxy.js but stripped to the minimum useful contract.
//
// A conversation is a named topic two or more bots subscribe to. While
// open, each tick a participant may append a turn — `{from, position,
// intent, ts}` — and read the last N turns from every peer. The
// transport today is a JSONL file under `state/<host>/conversations/`;
// the Unix socket variant can be bolted on later without changing the
// caller API.
//
// Why file-based: pepa already runs multiple bots from the same repo
// using different host directories under `state/`. A shared JSONL is
// the cheapest cross-process channel that survives restarts and the
// supervisor's hot-reload. No daemon, no port allocation.
//
// Public API (intentionally small):
// openConversation(topic) → handle { append, recent, close }
// listConversations() → ["topic1", "topic2"]
// peekConversation(topic, n) → last n turns, oldest first
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "../config.js";
const CONV_DIR = path.join(stateDir, "conversations");
const MAX_TURNS_KEEP = 200;
function ensureDir() {
try { fs.mkdirSync(CONV_DIR, { recursive: true }); } catch {}
}
function pathFor(topic) {
const safe = String(topic).replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 64);
return path.join(CONV_DIR, `${safe}.jsonl`);
}
function readAll(topic) {
const fp = pathFor(topic);
if (!fs.existsSync(fp)) return [];
const text = fs.readFileSync(fp, "utf8");
const out = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try { out.push(JSON.parse(line)); } catch {}
}
return out;
}
function rotateIfNeeded(topic) {
const all = readAll(topic);
if (all.length <= MAX_TURNS_KEEP) return;
const keep = all.slice(-MAX_TURNS_KEEP);
fs.writeFileSync(pathFor(topic), keep.map((t) => JSON.stringify(t)).join("\n") + "\n");
}
export function openConversation(topic, { speaker } = {}) {
if (!topic) throw new Error("openConversation: topic required");
if (!speaker) throw new Error("openConversation: speaker required");
ensureDir();
const fp = pathFor(topic);
// Seed the file with an `open` event so peers can discover the topic.
if (!fs.existsSync(fp)) {
fs.appendFileSync(fp, JSON.stringify({ ts: Date.now(), from: speaker, kind: "open", topic }) + "\n");
}
const handle = {
topic,
speaker,
append({ position, intent, text } = {}) {
const turn = {
ts: Date.now(),
from: speaker,
kind: "turn",
position: position ?? null,
intent: intent ?? null,
text: text ?? null,
};
fs.appendFileSync(fp, JSON.stringify(turn) + "\n");
rotateIfNeeded(topic);
return turn;
},
recent({ n = 10, excludeSelf = false } = {}) {
const all = readAll(topic);
const turns = excludeSelf ? all.filter((t) => t.from !== speaker) : all;
return turns.slice(-n);
},
peers() {
const seen = new Set();
for (const t of readAll(topic)) if (t.from) seen.add(t.from);
return Array.from(seen);
},
close() {
fs.appendFileSync(fp, JSON.stringify({ ts: Date.now(), from: speaker, kind: "close" }) + "\n");
},
};
return handle;
}
export function listConversations() {
ensureDir();
try {
return fs.readdirSync(CONV_DIR)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => f.replace(/\.jsonl$/, ""));
} catch {
return [];
}
}
export function peekConversation(topic, n = 10) {
const all = readAll(topic);
return all.slice(-n);
}
// Test hook — wipes the directory. Don't call in production.
export function _resetConversations() {
try {
for (const f of fs.readdirSync(CONV_DIR)) fs.unlinkSync(path.join(CONV_DIR, f));
} catch {}
}
+66
View File
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
openConversation,
listConversations,
peekConversation,
_resetConversations,
} from "./conversation.js";
function topic() { return `_t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
test("two bots in the same topic see each other's turns", () => {
_resetConversations();
const t = topic();
const alice = openConversation(t, { speaker: "alice" });
const bob = openConversation(t, { speaker: "bob" });
alice.append({ position: { x: 1, y: 2, z: 3 }, intent: "chop", text: "I'm chopping oak" });
bob.append({ position: { x: 10, y: 2, z: 3 }, intent: "mine", text: "I'm at the stone wall" });
const seenByAlice = alice.recent({ n: 10 });
assert.equal(seenByAlice.filter((t) => t.kind === "turn").length, 2);
const fromsByBob = bob.recent({ excludeSelf: true })
.filter((t) => t.kind === "turn")
.map((t) => t.from);
assert.deepEqual(fromsByBob, ["alice"]);
});
test("peers() returns every speaker seen in the topic", () => {
_resetConversations();
const t = topic();
openConversation(t, { speaker: "x" }).append({ text: "hi" });
openConversation(t, { speaker: "y" }).append({ text: "hello" });
openConversation(t, { speaker: "z" }).append({ text: "yo" });
const peers = openConversation(t, { speaker: "x" }).peers();
assert.deepEqual(peers.sort(), ["x", "y", "z"]);
});
test("listConversations enumerates active topics", () => {
_resetConversations();
openConversation(topic(), { speaker: "a" });
openConversation(topic(), { speaker: "b" });
const all = listConversations();
assert.ok(all.length >= 2);
});
test("recent() respects n", () => {
_resetConversations();
const t = topic();
const h = openConversation(t, { speaker: "a" });
for (let i = 0; i < 15; i++) h.append({ text: `msg${i}` });
const last5 = h.recent({ n: 5 }).filter((x) => x.kind === "turn");
assert.equal(last5.length, 5);
assert.equal(last5[last5.length - 1].text, "msg14");
});
test("peekConversation works without an open handle", () => {
_resetConversations();
const t = topic();
openConversation(t, { speaker: "lurker" }).append({ text: "hi" });
const peeked = peekConversation(t, 5);
assert.ok(peeked.find((p) => p.text === "hi"));
});
test("openConversation throws without topic or speaker", () => {
assert.throws(() => openConversation(null, { speaker: "x" }));
assert.throws(() => openConversation("t", {}));
});
+134 -82
View File
@@ -4,6 +4,13 @@
// the reflex loop hasn't crashed, but a single reason code (e.g. // the reflex loop hasn't crashed, but a single reason code (e.g.
// no_food_source, planner_empty) keeps coming back tick after tick. // no_food_source, planner_empty) keeps coming back tick after tick.
// //
// Before a proposal is filed, an optional critic pass (runtime/critic.js,
// adapted from Voyager) gets one Pi roundtrip to judge whether the bot
// actually failed. critic.success=true short-circuits the proposal (the
// bot has already recovered between the detector tripping and now);
// critic.success=false embeds the critique in the proposal body so the
// downstream auto-patcher has a sharp spec instead of raw metrics.
//
// When the same reason persists past STUCK_THRESHOLD_MS we build a // When the same reason persists past STUCK_THRESHOLD_MS we build a
// proposal body summarising the situation, including: // proposal body summarising the situation, including:
// - the no-progress reason // - the no-progress reason
@@ -112,52 +119,22 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS,
).join("\n") ).join("\n")
: "_(no scenario memory recorded yet)_"; : "_(no scenario memory recorded yet)_";
const body = [ const body = renderActionTemplate({
`# Stuck on \`${reason}\``, title: `Stuck on \`${reason}\``,
"", lede: `The runtime has reported the same no-progress reason for >${Math.round(thresholdMs / 60000)} min without a productive action.`,
`The runtime has reported the same no-progress reason for >${Math.round(thresholdMs / 60000)} min without a productive action.`, task: milestone?.title ?? "(no active milestone)",
"", suggestedSkill: suggested,
"## Current state", lastResult,
"", executionError: lastResult?.detail ?? null,
"```json", state: slim,
JSON.stringify(slim, null, 2), metrics: metricsLine,
"```", journal: journalLine,
"", scenarioTail: scenarioLines,
"## Last action result", editScope,
"", fixGuidance: suggested
lastResult
? `\`${lastResult.label}\`${lastResult.code ?? (lastResult.ok ? "ok" : "fail")}${lastResult.detail ? ` (${JSON.stringify(lastResult.detail).slice(0, 200)})` : ""}`
: "_(none recorded)_",
"",
"## Skill metrics so far (this process lifetime)",
"",
metricsLine,
"",
"## World journal (what we have discovered so far)",
"",
journalLine,
"",
"## Recent scenario memory (last attempts, what worked / failed in similar situations)",
"",
scenarioLines,
"",
"## Suggested fix",
"",
suggested
? `Improve \`${suggested}\` so the bot can clear the \`${reason}\` blocker, OR teach a NEW skill that handles this kind of situation if no single edit fixes it. Touch only the listed files (the test files under runtime/**/*.test.js are auto-allowed). Use the scenario-memory entries above to avoid re-introducing patterns that already failed.` ? `Improve \`${suggested}\` so the bot can clear the \`${reason}\` blocker, OR teach a NEW skill that handles this kind of situation if no single edit fixes it. Touch only the listed files (the test files under runtime/**/*.test.js are auto-allowed). Use the scenario-memory entries above to avoid re-introducing patterns that already failed.`
: `The curriculum has no suggested skill for this state. Either teach the curriculum a new milestone OR add a recovery skill that turns this reason code into a productive action. The scenario memory above shows what's been tried.`, : `The curriculum has no suggested skill for this state. Either teach the curriculum a new milestone OR add a recovery skill that turns this reason code into a productive action. The scenario memory above shows what's been tried.`,
"", });
"## Edit scope (auto-patch must obey this)",
"",
editScope.map((p) => `- ${p}`).join("\n"),
"",
"## Forbidden",
"",
"- Don't touch `.env`, `state/`, `extensions/`, `tui/` unless the scope above includes them.",
"- Don't add new npm dependencies.",
"- Don't change git history (no `--amend`, no `git reset --hard`).",
"",
].join("\n");
return { return {
fire: true, fire: true,
@@ -191,44 +168,20 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS,
).join("\n") ).join("\n")
: "_(no scenario memory)_"; : "_(no scenario memory)_";
const body = [ const body = renderActionTemplate({
`# Wedged — escape-pit cannot extract the bot`, title: "Wedged — escape-pit cannot extract the bot",
"", lede: `The bot has produced ${WEDGED_FIRE_AT}+ "wedged-jump / escape-pit / blind" completions in a row. In-world it stands still; the existing escape primitives are not enough.`,
`The bot has produced ${WEDGED_FIRE_AT}+ "wedged-jump / escape-pit / blind" completions in a row.`, task: "free the bot from its current 1×1 wedge",
"In-world it stands still; the existing escape primitives are not enough.", suggestedSkill: "recovery.tunnel-out",
"", lastResult,
"## Current state", executionError: lastResult?.detail ?? null,
"```json", state: slim,
JSON.stringify(slim, null, 2), metrics: metricsLine,
"```", journal: journalLine,
"", scenarioTail: scenarioLines,
"## Last action result", editScope: ["runtime/actions.js", "runtime/skills/", "runtime/reflex.js"],
lastResult fixGuidance: "Either improve `escapePit()` in `runtime/actions.js` (e.g. dig forward + down + side, not only up) OR add a NEW skill `recovery.tunnel-out` that breaks the bot out of a 1×1 hole by digging a 3-block tunnel in the most-free cardinal. Add tests under `runtime/skills/`.",
? `\`${lastResult.label}\`${lastResult.code ?? (lastResult.ok ? "ok" : "fail")} ${lastResult.detail ? `(${JSON.stringify(lastResult.detail).slice(0, 200)})` : ""}` });
: "_(none)_",
"",
"## Skill metrics",
metricsLine,
"",
"## World journal byKind",
journalLine,
"",
"## Recent scenario memory (last attempts)",
scenarioLines,
"",
"## Suggested fix",
"",
"Either improve `escapePit()` in `runtime/actions.js` (e.g. dig forward + down + side, not only up) OR add a NEW skill `recovery.tunnel-out` that breaks the bot out of a 1×1 hole by digging a 3-block tunnel in the most-free cardinal. Add tests under `runtime/skills/`.",
"",
"## Edit scope",
"- runtime/actions.js",
"- runtime/skills/",
"- runtime/reflex.js",
"",
"## Forbidden",
"- Don't touch `.env`, `state/`, `extensions/`, `tui/`, `package.json`.",
"- Don't add new npm dependencies.",
].join("\n");
return { return {
fire: true, fire: true,
@@ -241,3 +194,102 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS,
return { check, checkWedged, noteResult, reset }; return { check, checkWedged, noteResult, reset };
} }
// Render a proposal body in the Voyager action_template.txt schema —
// Task / Last action / Execution error / Current state / Metrics /
// World journal / Scenario memory / Edit scope / Suggested fix /
// Forbidden. The fixed section order trains Pi to scan a familiar
// layout instead of re-parsing ad-hoc Markdown each time.
export function renderActionTemplate({
title,
lede,
task,
suggestedSkill,
lastResult,
executionError,
state,
metrics,
journal,
scenarioTail,
editScope,
fixGuidance,
}) {
const lastResultLine = lastResult
? `\`${lastResult.label}\`${lastResult.code ?? (lastResult.ok ? "ok" : "fail")}${lastResult.detail ? ` (${JSON.stringify(lastResult.detail).slice(0, 200)})` : ""}`
: "_(none recorded)_";
const errLine = executionError
? (typeof executionError === "string" ? executionError : JSON.stringify(executionError)).slice(0, 300)
: "_(none)_";
return [
`# ${title}`,
"",
lede,
"",
"## Task",
"",
`- **goal**: ${task}`,
`- **suggested skill**: ${suggestedSkill ? `\`${suggestedSkill}\`` : "_(none — propose one)_"}`,
"",
"## Last action result",
"",
lastResultLine,
"",
"## Execution error",
"",
errLine,
"",
"## Current state",
"",
"```json",
JSON.stringify(state, null, 2),
"```",
"",
"## Skill metrics (this process lifetime)",
"",
metrics,
"",
"## World journal (what we have discovered so far)",
"",
journal,
"",
"## Scenario memory (last attempts in similar situations)",
"",
scenarioTail,
"",
"## Suggested fix",
"",
fixGuidance,
"",
"## Edit scope (auto-patch must obey this)",
"",
(editScope || []).map((p) => `- ${p}`).join("\n"),
"",
"## Forbidden",
"",
"- Don't touch `.env`, `state/`, `extensions/`, `tui/` unless the scope above includes them.",
"- Don't add new npm dependencies.",
"- Don't change git history (no `--amend`, no `git reset --hard`).",
"",
].join("\n");
}
// Splice a Voyager-style critic block into a proposal body. Inserted just
// before the "## Suggested fix" header so Pi sees the critic's surgical
// hint before its own guidance.
export function attachCritique(body, critique) {
if (!critique) return body;
const block = [
"## Critic (Pi pre-flight judgement)",
"",
`- **reasoning**: ${critique.reasoning || "(none)"}`,
`- **success-already**: ${critique.success}`,
`- **critique**: ${critique.critique || "(none)"}`,
critique.durationMs != null ? `- _critic took ${critique.durationMs}ms_` : null,
"",
].filter(Boolean).join("\n");
const marker = "## Suggested fix";
const idx = body.indexOf(marker);
if (idx < 0) return `${body}\n\n${block}`;
return `${body.slice(0, idx)}${block}\n${body.slice(idx)}`;
}
+31 -1
View File
@@ -22,6 +22,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { parseEditScope, validateChangedFiles, effectiveScope } from "./edit-scope.js"; import { parseEditScope, validateChangedFiles, effectiveScope } from "./edit-scope.js";
import { lintPatch } from "./lint-patch.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -121,6 +122,18 @@ if (checkout.status !== 0) exit(2, `cannot create branch ${branch}: ${checkout.s
acquireLock(); acquireLock();
log("info", `acquired ${LOCK_FILE}`); log("info", `acquired ${LOCK_FILE}`);
// Pick top-k similar existing skills so Pi can crib patterns instead of
// reinventing them (Mindcraft skill_library.getRelevantSkillDocs). Lazy
// import — skill registry pulls in mineflayer transitively which is
// expensive, and we don't need it on early-exit paths.
let relevantDocsBlock = "_(skill library unavailable)_";
try {
const { renderRelevantDocs } = await import("../runtime/skill-library.js");
relevantDocsBlock = renderRelevantDocs(proposalText, { k: 3 });
} catch (e) {
log("warn", `skill-library render failed: ${e.message}`);
}
const scopeBullet = scope.map((p) => ` - \`${p}\``).join("\n"); const scopeBullet = scope.map((p) => ` - \`${p}\``).join("\n");
const prompt = [ const prompt = [
"You are patching the pepa-pi-bot repo to address an automatically-detected failure.", "You are patching the pepa-pi-bot repo to address an automatically-detected failure.",
@@ -131,6 +144,10 @@ const prompt = [
"", "",
proposalText, proposalText,
"", "",
"## Relevant existing skills (top-3 by word overlap — use these as patterns)",
"",
relevantDocsBlock,
"",
"## Hard rules (non-negotiable)", "## Hard rules (non-negotiable)",
"", "",
"1. Touch ONLY files matching the edit scope below. Any other path will be rejected after you commit and the patch will be discarded:", "1. Touch ONLY files matching the edit scope below. Any other path will be rejected after you commit and the patch will be discarded:",
@@ -167,7 +184,7 @@ const timer = setTimeout(() => {
pi.kill("SIGTERM"); pi.kill("SIGTERM");
}, PI_TIMEOUT_MS); }, PI_TIMEOUT_MS);
pi.on("exit", (code) => { pi.on("exit", async (code) => {
clearTimeout(timer); clearTimeout(timer);
log("info", `pi exited code=${code}; stdout=${piStdout.length}B stderr=${piStderr.length}B`); log("info", `pi exited code=${code}; stdout=${piStdout.length}B stderr=${piStderr.length}B`);
@@ -200,6 +217,19 @@ pi.on("exit", (code) => {
exit(2, "patch touched off-limits files"); exit(2, "patch touched off-limits files");
} }
// Pre-flight lint gate (Mindcraft coder._lintCode pattern, scripts/lint-patch.js).
// Cheaper than npm test — catches parse errors, missing named imports,
// and runSkill(id) where id isn't in the registry. Seconds, not 30s.
log("info", "running lint pre-flight gate");
const lint = await lintPatch({ repoRoot: REPO_ROOT, changedFiles: filesChanged });
if (!lint.ok) {
log("error", `lint FAILED — discarding:\n${lint.errors.join("\n")}`);
git(["checkout", "main"]);
git(["branch", "-D", branch]);
exit(2, "patch failed lint");
}
log("info", "lint gate passed");
// Smoke gate: run `npm test` on the patched branch BEFORE cherry-picking. // Smoke gate: run `npm test` on the patched branch BEFORE cherry-picking.
// Anything that turns the suite red gets thrown away — even if Pi thinks // Anything that turns the suite red gets thrown away — even if Pi thinks
// the change is correct. // the change is correct.
+94
View File
@@ -0,0 +1,94 @@
// Pre-flight lint for auto-patch — adapted from Mindcraft's coder._lintCode.
//
// Runs AFTER Pi commits to the auto/* branch but BEFORE `npm test`. Cheap
// checks that catch the most common "Pi hallucinated an API" failures:
//
// 1. node --check each changed runtime/*.js — parse errors caught
// without spinning up the supervisor.
// 2. dynamic import — surfaces "Named export X not found" before tests
// that don't directly import the file would have caught it.
// 3. runSkill("X.y", ...) calls — the id must exist in the live skill
// registry. Pi sometimes invents skill ids that look plausible.
//
// Returns { ok: true } or { ok: false, errors: string[] }. The auto-patch
// caller decides whether to discard the patch. We deliberately exit with
// a list (not fail-fast) so a single discard reason is enough for Pi to
// understand on the next attempt.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export function parseCheck(absPath) {
const res = spawnSync(process.execPath, ["--check", absPath], { encoding: "utf8" });
return res.status === 0
? { ok: true }
: { ok: false, error: `parse: ${res.stderr.split("\n").slice(0, 2).join(" ")}` };
}
export function importCheck(absPath) {
const code = `import("${absPath.replace(/"/g, '\\"')}").then(()=>process.exit(0)).catch(e=>{console.error(e.message);process.exit(1)})`;
const res = spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8", timeout: 15_000 });
return res.status === 0
? { ok: true }
: { ok: false, error: `import: ${(res.stderr || res.stdout || "").split("\n")[0].slice(0, 200)}` };
}
// Extract runSkill("...") / getSkill("...") string-literal arguments.
// Multi-line tolerated; backticks tolerated; templating not (Pi must
// pass a literal id at lint time, otherwise we can't verify).
const SKILL_CALL_RE = /(?:runSkill|getSkill)\s*\(\s*["'`]([a-zA-Z0-9_.-]+)["'`]/g;
export function extractSkillCalls(code) {
const seen = new Set();
let m;
SKILL_CALL_RE.lastIndex = 0;
while ((m = SKILL_CALL_RE.exec(code)) !== null) seen.add(m[1]);
return Array.from(seen);
}
export async function loadRegisteredSkillIds(repoRoot) {
const skillsIndex = path.join(repoRoot, "runtime", "skills", "index.js");
const mod = await import(skillsIndex);
if (typeof mod.listSkills === "function") return new Set(mod.listSkills().map((s) => s.id));
return new Set();
}
export async function lintPatch({ repoRoot, changedFiles }) {
const errors = [];
const runtimeFiles = (changedFiles || []).filter((f) => /^runtime\/.*\.js$/.test(f) && !f.endsWith(".test.js"));
for (const rel of runtimeFiles) {
const abs = path.join(repoRoot, rel);
if (!fs.existsSync(abs)) continue;
const pc = parseCheck(abs);
if (!pc.ok) errors.push(`${rel}: ${pc.error}`);
}
// import-check only after parse-check is clean so we report the first
// failure clearly. import-check spins a fresh node, so we limit it to
// the actually-touched runtime files.
if (errors.length === 0) {
for (const rel of runtimeFiles) {
const abs = path.join(repoRoot, rel);
if (!fs.existsSync(abs)) continue;
const ic = importCheck(abs);
if (!ic.ok) errors.push(`${rel}: ${ic.error}`);
}
}
// runSkill id check — only meaningful if imports work.
if (errors.length === 0) {
let known = new Set();
try { known = await loadRegisteredSkillIds(repoRoot); }
catch (e) { return { ok: false, errors: [`skills index load failed: ${e.message}`] }; }
for (const rel of runtimeFiles) {
const abs = path.join(repoRoot, rel);
if (!fs.existsSync(abs)) continue;
const code = fs.readFileSync(abs, "utf8");
for (const id of extractSkillCalls(code)) {
if (!known.has(id) && !id.startsWith("diag.") && !id.startsWith("test.")) {
errors.push(`${rel}: references unknown skill id "${id}" — not in runtime/skills/index.js`);
}
}
}
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}
+38
View File
@@ -0,0 +1,38 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { extractSkillCalls } from "./lint-patch.js";
test("extractSkillCalls: runSkill double-quoted", () => {
const code = `await runSkill("gather.logs", ctx, args);`;
assert.deepEqual(extractSkillCalls(code), ["gather.logs"]);
});
test("extractSkillCalls: getSkill backtick", () => {
const code = "const s = getSkill(`village.deposit-surplus`);";
assert.deepEqual(extractSkillCalls(code), ["village.deposit-surplus"]);
});
test("extractSkillCalls: multiple unique ids dedupe", () => {
const code = `
await runSkill("gather.logs", ctx);
const s = getSkill('gather.stone');
await runSkill("gather.logs", ctx); // duplicate
`;
assert.deepEqual(extractSkillCalls(code).sort(), ["gather.logs", "gather.stone"]);
});
test("extractSkillCalls: ignores template literals it can't verify", () => {
const code = "await runSkill(`${dynamicId}`, ctx);";
// Pattern requires literal — dynamic ids are not extracted (and not lint-checked).
const out = extractSkillCalls(code);
assert.equal(out.length, 0);
});
test("extractSkillCalls: tolerates whitespace + newlines", () => {
const code = `await runSkill(
"explore.far",
ctx,
args,
);`;
assert.deepEqual(extractSkillCalls(code), ["explore.far"]);
});