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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 15:16:42 +03:00
co-authored by Claude Opus 4.7
parent 25e39c7244
commit 4ae63dabe1
18 changed files with 1196 additions and 113 deletions
+119
View File
@@ -0,0 +1,119 @@
// Multi-agent conversation skeleton — inspired by Mindcraft
// mindserver_proxy.js but stripped to the minimum useful contract.
//
// A conversation is a named topic two or more bots subscribe to. While
// open, each tick a participant may append a turn — `{from, position,
// intent, ts}` — and read the last N turns from every peer. The
// transport today is a JSONL file under `state/<host>/conversations/`;
// the Unix socket variant can be bolted on later without changing the
// caller API.
//
// Why file-based: pepa already runs multiple bots from the same repo
// using different host directories under `state/`. A shared JSONL is
// the cheapest cross-process channel that survives restarts and the
// supervisor's hot-reload. No daemon, no port allocation.
//
// Public API (intentionally small):
// openConversation(topic) → handle { append, recent, close }
// listConversations() → ["topic1", "topic2"]
// peekConversation(topic, n) → last n turns, oldest first
import fs from "node:fs";
import path from "node:path";
import { stateDir } from "../config.js";
const CONV_DIR = path.join(stateDir, "conversations");
const MAX_TURNS_KEEP = 200;
function ensureDir() {
try { fs.mkdirSync(CONV_DIR, { recursive: true }); } catch {}
}
function pathFor(topic) {
const safe = String(topic).replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 64);
return path.join(CONV_DIR, `${safe}.jsonl`);
}
function readAll(topic) {
const fp = pathFor(topic);
if (!fs.existsSync(fp)) return [];
const text = fs.readFileSync(fp, "utf8");
const out = [];
for (const line of text.split("\n")) {
if (!line.trim()) continue;
try { out.push(JSON.parse(line)); } catch {}
}
return out;
}
function rotateIfNeeded(topic) {
const all = readAll(topic);
if (all.length <= MAX_TURNS_KEEP) return;
const keep = all.slice(-MAX_TURNS_KEEP);
fs.writeFileSync(pathFor(topic), keep.map((t) => JSON.stringify(t)).join("\n") + "\n");
}
export function openConversation(topic, { speaker } = {}) {
if (!topic) throw new Error("openConversation: topic required");
if (!speaker) throw new Error("openConversation: speaker required");
ensureDir();
const fp = pathFor(topic);
// Seed the file with an `open` event so peers can discover the topic.
if (!fs.existsSync(fp)) {
fs.appendFileSync(fp, JSON.stringify({ ts: Date.now(), from: speaker, kind: "open", topic }) + "\n");
}
const handle = {
topic,
speaker,
append({ position, intent, text } = {}) {
const turn = {
ts: Date.now(),
from: speaker,
kind: "turn",
position: position ?? null,
intent: intent ?? null,
text: text ?? null,
};
fs.appendFileSync(fp, JSON.stringify(turn) + "\n");
rotateIfNeeded(topic);
return turn;
},
recent({ n = 10, excludeSelf = false } = {}) {
const all = readAll(topic);
const turns = excludeSelf ? all.filter((t) => t.from !== speaker) : all;
return turns.slice(-n);
},
peers() {
const seen = new Set();
for (const t of readAll(topic)) if (t.from) seen.add(t.from);
return Array.from(seen);
},
close() {
fs.appendFileSync(fp, JSON.stringify({ ts: Date.now(), from: speaker, kind: "close" }) + "\n");
},
};
return handle;
}
export function listConversations() {
ensureDir();
try {
return fs.readdirSync(CONV_DIR)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => f.replace(/\.jsonl$/, ""));
} catch {
return [];
}
}
export function peekConversation(topic, n = 10) {
const all = readAll(topic);
return all.slice(-n);
}
// Test hook — wipes the directory. Don't call in production.
export function _resetConversations() {
try {
for (const f of fs.readdirSync(CONV_DIR)) fs.unlinkSync(path.join(CONV_DIR, f));
} catch {}
}
+66
View File
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
openConversation,
listConversations,
peekConversation,
_resetConversations,
} from "./conversation.js";
function topic() { return `_t_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
test("two bots in the same topic see each other's turns", () => {
_resetConversations();
const t = topic();
const alice = openConversation(t, { speaker: "alice" });
const bob = openConversation(t, { speaker: "bob" });
alice.append({ position: { x: 1, y: 2, z: 3 }, intent: "chop", text: "I'm chopping oak" });
bob.append({ position: { x: 10, y: 2, z: 3 }, intent: "mine", text: "I'm at the stone wall" });
const seenByAlice = alice.recent({ n: 10 });
assert.equal(seenByAlice.filter((t) => t.kind === "turn").length, 2);
const fromsByBob = bob.recent({ excludeSelf: true })
.filter((t) => t.kind === "turn")
.map((t) => t.from);
assert.deepEqual(fromsByBob, ["alice"]);
});
test("peers() returns every speaker seen in the topic", () => {
_resetConversations();
const t = topic();
openConversation(t, { speaker: "x" }).append({ text: "hi" });
openConversation(t, { speaker: "y" }).append({ text: "hello" });
openConversation(t, { speaker: "z" }).append({ text: "yo" });
const peers = openConversation(t, { speaker: "x" }).peers();
assert.deepEqual(peers.sort(), ["x", "y", "z"]);
});
test("listConversations enumerates active topics", () => {
_resetConversations();
openConversation(topic(), { speaker: "a" });
openConversation(topic(), { speaker: "b" });
const all = listConversations();
assert.ok(all.length >= 2);
});
test("recent() respects n", () => {
_resetConversations();
const t = topic();
const h = openConversation(t, { speaker: "a" });
for (let i = 0; i < 15; i++) h.append({ text: `msg${i}` });
const last5 = h.recent({ n: 5 }).filter((x) => x.kind === "turn");
assert.equal(last5.length, 5);
assert.equal(last5[last5.length - 1].text, "msg14");
});
test("peekConversation works without an open handle", () => {
_resetConversations();
const t = topic();
openConversation(t, { speaker: "lurker" }).append({ text: "hi" });
const peeked = peekConversation(t, 5);
assert.ok(peeked.find((p) => p.text === "hi"));
});
test("openConversation throws without topic or speaker", () => {
assert.throws(() => openConversation(null, { speaker: "x" }));
assert.throws(() => openConversation("t", {}));
});