Files
pepa-pi-bot/runtime/skill-library.js
T
mayatnikovandClaude Opus 4.7 4ae63dabe1 feat(runtime): v0.1.0 — adopt Voyager critic + Mindcraft modes/library/lint
Five concrete patterns from Voyager and Mindcraft, applied in our shape
without abandoning the git-as-evolution-substrate that makes pepa
distinct. Plus a first multi-agent surface so two bots from the same
repo can share intent.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:16:42 +03:00

113 lines
3.6 KiB
JavaScript

// 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; }