Files
mayatnikovandClaude Opus 4.7 4ae63dabe1 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>
2026-05-26 15:16:42 +03:00

167 lines
6.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };