From 4ae63dabe1b63b728357154bed43fd72fd919a40 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 15:16:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20v0.1.0=20=E2=80=94=20adopt=20V?= =?UTF-8?q?oyager=20critic=20+=20Mindcraft=20modes/library/lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 24 ++++ README.md | 15 +- package.json | 4 +- runtime/bot.js | 87 +++++++---- runtime/critic.js | 166 +++++++++++++++++++++ runtime/critic.test.js | 50 +++++++ runtime/ipc-protocol.js | 3 + runtime/modes.js | 126 ++++++++++++++++ runtime/modes.test.js | 95 ++++++++++++ runtime/reflex.js | 18 +++ runtime/skill-library.js | 112 +++++++++++++++ runtime/skill-library.test.js | 44 ++++++ runtime/social/conversation.js | 119 +++++++++++++++ runtime/social/conversation.test.js | 66 +++++++++ runtime/stuck-incident.js | 216 +++++++++++++++++----------- scripts/auto-patch.js | 32 ++++- scripts/lint-patch.js | 94 ++++++++++++ scripts/lint-patch.test.js | 38 +++++ 18 files changed, 1196 insertions(+), 113 deletions(-) create mode 100644 runtime/critic.js create mode 100644 runtime/critic.test.js create mode 100644 runtime/modes.js create mode 100644 runtime/modes.test.js create mode 100644 runtime/skill-library.js create mode 100644 runtime/skill-library.test.js create mode 100644 runtime/social/conversation.js create mode 100644 runtime/social/conversation.test.js create mode 100644 scripts/lint-patch.js create mode 100644 scripts/lint-patch.test.js diff --git a/AGENTS.md b/AGENTS.md index 60d6a0d..c0cf556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,30 @@ > reflex/action in `runtime/` over an extension in `extensions/` — the > 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 > control for operators or players. It is becoming a self-sufficient survival > resident of the configured Minecraft server. **Minecraft chat is diff --git a/README.md b/README.md index 93f606d..ca898c2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # 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. @@ -31,8 +33,15 @@ Most Minecraft AI bots ship as monolithic projects: hard-coded actions, fixed pr ┌──────────────────────────────────────────────────────────┐ │ runtime/bot.js — long-running Node daemon │ │ ├── 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 │ +│ ├── 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 │ └──────────────────────┬───────────────────────────────────┘ │ TCP 25565 (any host/port) diff --git a/package.json b/package.json index 0e004d7..7a9ef82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pepa-pi-bot", - "version": "0.0.1", + "version": "0.1.0", "private": true, "description": "An autonomous, self-extending Minecraft player powered by Pi and Mineflayer.", "license": "MIT", @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "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": { "dotenv": "^16.4.5", diff --git a/runtime/bot.js b/runtime/bot.js index 0429326..ad88e24 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -46,7 +46,9 @@ import { runSkill } from "./skills/index.js"; import { classifyIntent, INTENTS } from "./social/intent.js"; import { generateReply } from "./social/reply.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 { createWorldJournal } from "./world-journal.js"; import { createScenarioMemory, situationHash } from "./scenario-memory.js"; @@ -415,6 +417,37 @@ function maybeFileProposal(label) { 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 1–60s. 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/) ---------------------------------------- // // MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes @@ -784,18 +817,7 @@ function tick() { now, }); if (stuck?.fire) { - try { - 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}`); - } + void filePostCritique(stuck, "stuck"); } // Second fast-track trigger: explicit wedged loop (escape-pit ran N @@ -810,18 +832,7 @@ function tick() { now, }); if (wedged?.fire) { - try { - 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}`); - } + void filePostCritique(wedged, "wedged"); } ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot); @@ -941,6 +952,32 @@ function handleCommand(msg, send) { tryDispatch(); 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: warn("ipc", `unknown command type: ${msg.type}`); } diff --git a/runtime/critic.js b/runtime/critic.js new file mode 100644 index 0000000..c219e1b --- /dev/null +++ b/runtime/critic.js @@ -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 }; diff --git a/runtime/critic.test.js b/runtime/critic.test.js new file mode 100644 index 0000000..785539f --- /dev/null +++ b/runtime/critic.test.js @@ -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:")); +}); diff --git a/runtime/ipc-protocol.js b/runtime/ipc-protocol.js index 6f5c48d..8d6e29d 100644 --- a/runtime/ipc-protocol.js +++ b/runtime/ipc-protocol.js @@ -28,6 +28,9 @@ export const COMMAND_TYPES = Object.freeze({ PROPOSAL_LATEST: "cmd:proposal-latest", // request latest pending proposal PROPOSAL_APPROVE: "cmd:proposal-approve", // { filename } move to approved/ 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) { diff --git a/runtime/modes.js b/runtime/modes.js new file mode 100644 index 0000000..d7eebf3 --- /dev/null +++ b/runtime/modes.js @@ -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" } }; + }, +}); diff --git a/runtime/modes.test.js b/runtime/modes.test.js new file mode 100644 index 0000000..3f1c8b1 --- /dev/null +++ b/runtime/modes.test.js @@ -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"); +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index bd91387..8c6d7e0 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -26,6 +26,7 @@ import { } from "./actions.js"; import { runSkill, getSkill } from "./skills/index.js"; import { situationHash } from "./scenario-memory.js"; +import { tickModes } from "./modes.js"; // Each "wander hint" triggered by a skill returning no_target should take // the bot meaningfully further than 16 blocks — otherwise the curriculum @@ -295,6 +296,23 @@ export function runTick(ctx) { if (ctx.busy) { 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) { let outcome; try { diff --git a/runtime/skill-library.js b/runtime/skill-library.js new file mode 100644 index 0000000..1991f2b --- /dev/null +++ b/runtime/skill-library.js @@ -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; } diff --git a/runtime/skill-library.test.js b/runtime/skill-library.test.js new file mode 100644 index 0000000..d78baa7 --- /dev/null +++ b/runtime/skill-library.test.js @@ -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")); +}); diff --git a/runtime/social/conversation.js b/runtime/social/conversation.js new file mode 100644 index 0000000..4f45d9a --- /dev/null +++ b/runtime/social/conversation.js @@ -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//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 {} +} diff --git a/runtime/social/conversation.test.js b/runtime/social/conversation.test.js new file mode 100644 index 0000000..1e7bb10 --- /dev/null +++ b/runtime/social/conversation.test.js @@ -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", {})); +}); diff --git a/runtime/stuck-incident.js b/runtime/stuck-incident.js index 072ef20..cfad21c 100644 --- a/runtime/stuck-incident.js +++ b/runtime/stuck-incident.js @@ -4,6 +4,13 @@ // the reflex loop hasn't crashed, but a single reason code (e.g. // 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 // proposal body summarising the situation, including: // - the no-progress reason @@ -112,52 +119,22 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, ).join("\n") : "_(no scenario memory recorded yet)_"; - const body = [ - `# Stuck on \`${reason}\``, - "", - `The runtime has reported the same no-progress reason for >${Math.round(thresholdMs / 60000)} min without a productive action.`, - "", - "## Current state", - "", - "```json", - JSON.stringify(slim, null, 2), - "```", - "", - "## Last action result", - "", - 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 + const body = renderActionTemplate({ + title: `Stuck on \`${reason}\``, + lede: `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, + lastResult, + executionError: lastResult?.detail ?? null, + state: slim, + metrics: metricsLine, + journal: journalLine, + scenarioTail: scenarioLines, + editScope, + fixGuidance: 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.` : `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 { fire: true, @@ -191,44 +168,20 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, ).join("\n") : "_(no scenario memory)_"; - const body = [ - `# Wedged — escape-pit cannot extract the bot`, - "", - `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.", - "", - "## Current state", - "```json", - JSON.stringify(slim, null, 2), - "```", - "", - "## Last action result", - lastResult - ? `\`${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"); + const body = renderActionTemplate({ + 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.`, + task: "free the bot from its current 1×1 wedge", + suggestedSkill: "recovery.tunnel-out", + lastResult, + executionError: lastResult?.detail ?? null, + state: slim, + metrics: metricsLine, + journal: journalLine, + scenarioTail: scenarioLines, + editScope: ["runtime/actions.js", "runtime/skills/", "runtime/reflex.js"], + 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/`.", + }); return { fire: true, @@ -241,3 +194,102 @@ export function createStuckIncidentDetector({ thresholdMs = STUCK_THRESHOLD_MS, 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)}`; +} + diff --git a/scripts/auto-patch.js b/scripts/auto-patch.js index c874381..f8ad973 100644 --- a/scripts/auto-patch.js +++ b/scripts/auto-patch.js @@ -22,6 +22,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseEditScope, validateChangedFiles, effectiveScope } from "./edit-scope.js"; +import { lintPatch } from "./lint-patch.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -121,6 +122,18 @@ if (checkout.status !== 0) exit(2, `cannot create branch ${branch}: ${checkout.s acquireLock(); 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 prompt = [ "You are patching the pepa-pi-bot repo to address an automatically-detected failure.", @@ -131,6 +144,10 @@ const prompt = [ "", proposalText, "", + "## Relevant existing skills (top-3 by word overlap — use these as patterns)", + "", + relevantDocsBlock, + "", "## 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:", @@ -167,7 +184,7 @@ const timer = setTimeout(() => { pi.kill("SIGTERM"); }, PI_TIMEOUT_MS); -pi.on("exit", (code) => { +pi.on("exit", async (code) => { clearTimeout(timer); 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"); } + // 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. // Anything that turns the suite red gets thrown away — even if Pi thinks // the change is correct. diff --git a/scripts/lint-patch.js b/scripts/lint-patch.js new file mode 100644 index 0000000..09b4d04 --- /dev/null +++ b/scripts/lint-patch.js @@ -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 }; +} diff --git a/scripts/lint-patch.test.js b/scripts/lint-patch.test.js new file mode 100644 index 0000000..223afe8 --- /dev/null +++ b/scripts/lint-patch.test.js @@ -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"]); +});