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>
120 lines
3.6 KiB
JavaScript
120 lines
3.6 KiB
JavaScript
// 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 {}
|
|
}
|