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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+62
-25
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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:"));
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" } };
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user