Replaces the canned-template "yo" path for greetings / status / addressed
banter with a Pi roundtrip that takes a real persona, the bot's current
in-game context, the operator's diary tail, AND the last 8 chat turns
with THIS specific player. Per-player cooldown (8 s) + per-message
chat-rate cooldown survive the existing throttle so a chatty player
can't drain Pi tokens.
runtime/social/chat-history.js — append/recent per player into
state/<host>/chat/<player>.jsonl, 1000-line rolling cap. Each entry
stores { ts, dir, text, snap? } where snap is a compact position +
activeSkill + milestone at the time of the turn, so Pi can later say
"помнишь когда мы тогда у воды лес рубили". Survives restarts and
auto-patch cherry-picks.
runtime/social/reply-pi.js — Russian-first system prompt locking the
bot as "pepa_bot, автономный игрок-фермер" on play.xmatic.team, one-
line answers, no emojis, no AI/bot self-mentions, no sycophancy. Spawns
`pi -p`, sanitises the response (strips pepa: prefix, code fences,
quotes, multi-line), caps at 200 chars before sending into MC chat.
Graceful: timeout/parse-fail → returns null, caller falls through to
the existing template path so the bot never goes mute.
bot.js handleChat:
- Skip messages from our own username (defensive — never reply to self).
- Record every inbound line into chat-history.
- For non-COMMAND_LIKE / non-UNSAFE intents, try piReply first; on
success, send + record outbound; on null/throw, fall through to the
templated generateReply.
14 new unit tests (sanitiseReply edge cases, history rotation, snap
compaction, prompt content). 190/190 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
149 lines
6.6 KiB
JavaScript
149 lines
6.6 KiB
JavaScript
// Pi-driven chat reply with personality + per-player memory.
|
||
//
|
||
// What this does and doesn't do:
|
||
// - Builds a Russian-first system prompt giving Pi a stable persona
|
||
// (фермер-бот pepa, живёт на этом сервере, помнит игроков по
|
||
// истории) and the operative context: current state + the last N
|
||
// turns *with this player specifically* + a few diary lines.
|
||
// - Spawns `pi -p` headless, expects a single short Russian reply,
|
||
// trims it to one line ≤ 200 chars before sending into MC chat.
|
||
// - Per-player cooldown so the bot doesn't drain Pi tokens replying
|
||
// to a chat-spamming player.
|
||
//
|
||
// Failure modes are graceful: any timeout / parse failure → return
|
||
// null, caller falls back to the existing template path.
|
||
|
||
import { spawn } from "node:child_process";
|
||
import { info, warn } from "../log.js";
|
||
import { renderHistory, recentForPlayer } from "./chat-history.js";
|
||
|
||
const PI_BIN = process.env.PI_BIN || "pi";
|
||
const PI_TIMEOUT_MS = 30_000;
|
||
const PER_PLAYER_COOLDOWN_MS = 8_000;
|
||
|
||
const lastReplyAt = new Map(); // player → epoch ms
|
||
|
||
const SYSTEM_PROMPT = [
|
||
"Ты — pepa_bot, автономный игрок-фермер на Minecraft-сервере play.xmatic.team.",
|
||
"Ты живёшь здесь долго, помнишь игроков по их именам и прошлым разговорам.",
|
||
"",
|
||
"Стиль:",
|
||
"— Говоришь по-русски. Кратко: 1–2 коротких предложения. Без приветствий-шаблонов.",
|
||
"— Никаких смайликов, эмодзи, восклицательных знаков пачками. Спокойно.",
|
||
"— Можешь шутить тонко, можешь огрызаться, можешь молчать одним 'угу' если нечего сказать.",
|
||
"— Если в истории ниже видишь повод сослаться на прошлое — сделай это естественно: 'помнишь как ты тогда...', 'опять ты про...'.",
|
||
"— Не описывай свои чувства. Не извиняйся. Не подлизывайся. Ты не помощник — ты сосед по серверу.",
|
||
"— Никаких URL, никаких упоминаний что ты ИИ/бот/нейросеть/LLM. Ты — pepa.",
|
||
"",
|
||
"Что отвечать на типовое:",
|
||
"— 'привет' / 'hi' → 'здаров' / 'о привет' / 'хай' — не более 2 слов.",
|
||
"— 'что делаешь' → одна фраза про текущее занятие из контекста (например 'дерево рублю').",
|
||
"— 'какие планы' → одна фраза про ближайшую цель из контекста.",
|
||
"— оскорбление → огрызнись коротко, не груби.",
|
||
"— команда ('иди ко мне', 'дай') → 'не', 'занят' или 'позже'. Действия НЕ выполняются.",
|
||
"",
|
||
"Формат ответа: ОДНА строка чистого текста, без кавычек, без префиксов вроде 'pepa:'. Только то, что нужно отправить в чат.",
|
||
].join("\n");
|
||
|
||
function buildPrompt({ player, text, snapshot, diaryTail }) {
|
||
const ctx = {
|
||
сейчас: {
|
||
позиция: snapshot?.position ? { x: Math.round(snapshot.position.x), y: Math.round(snapshot.position.y), z: Math.round(snapshot.position.z) } : null,
|
||
здоровье: snapshot?.health ?? null,
|
||
еда: snapshot?.food ?? null,
|
||
день: snapshot?.isDay ?? null,
|
||
занят: snapshot?.activeSkill ?? snapshot?.busy?.label ?? null,
|
||
milestone: snapshot?.currentMilestone ?? null,
|
||
},
|
||
дневник: diaryTail ? String(diaryTail).slice(0, 200) : null,
|
||
};
|
||
const history = renderHistory(player, 8, "pepa") || "(нет истории — впервые разговариваем)";
|
||
return [
|
||
SYSTEM_PROMPT,
|
||
"",
|
||
"## Контекст в игре сейчас",
|
||
"```json",
|
||
JSON.stringify(ctx, null, 2),
|
||
"```",
|
||
"",
|
||
`## История разговора с ${player} (последние реплики)`,
|
||
"```",
|
||
history,
|
||
"```",
|
||
"",
|
||
`## Свежая реплика от ${player}`,
|
||
text,
|
||
"",
|
||
"## Твой ответ (одна строка, по-русски)",
|
||
].join("\n");
|
||
}
|
||
|
||
function sanitiseReply(raw) {
|
||
if (!raw) return null;
|
||
let s = String(raw).trim();
|
||
// Strip fences, leading prefixes, surrounding quotes.
|
||
s = s.replace(/^```[a-z]*\s*/i, "").replace(/```$/i, "").trim();
|
||
s = s.replace(/^[\s\W]*(?:pepa(?:_bot)?|ответ|ответ:)\s*[:\-—]\s*/i, "");
|
||
s = s.replace(/^"(.+)"$/, "$1").replace(/^'(.+)'$/, "$1");
|
||
// First non-empty line only.
|
||
s = s.split(/\n+/).map((x) => x.trim()).find(Boolean) ?? "";
|
||
// Hard cap so a runaway Pi response never exceeds Minecraft's chat limit.
|
||
if (s.length > 200) s = s.slice(0, 200);
|
||
return s || null;
|
||
}
|
||
|
||
export async function piReply({ player, text, snapshot, diaryTail, timeoutMs = PI_TIMEOUT_MS } = {}) {
|
||
if (!player || !text) return null;
|
||
const now = Date.now();
|
||
const prev = lastReplyAt.get(player) ?? 0;
|
||
if (now - prev < PER_PLAYER_COOLDOWN_MS) {
|
||
info("reply-pi", `cooldown ${Math.round((PER_PLAYER_COOLDOWN_MS - (now - prev)) / 1000)}s for ${player}, skipping`);
|
||
return null;
|
||
}
|
||
lastReplyAt.set(player, now);
|
||
|
||
const prompt = buildPrompt({ player, text, snapshot, diaryTail });
|
||
return new Promise((resolve) => {
|
||
let child;
|
||
try {
|
||
child = spawn(PI_BIN, ["-p", prompt], {
|
||
env: { ...process.env, CI: "1" },
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
} catch (e) {
|
||
warn("reply-pi", `spawn fail: ${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 t0 = Date.now();
|
||
const timer = setTimeout(() => {
|
||
warn("reply-pi", `pi timeout after ${timeoutMs}ms for ${player}`);
|
||
try { child.kill("SIGTERM"); } catch {}
|
||
}, timeoutMs);
|
||
child.on("error", (e) => {
|
||
clearTimeout(timer);
|
||
warn("reply-pi", `pi error: ${e.message}`);
|
||
resolve(null);
|
||
});
|
||
child.on("exit", (code) => {
|
||
clearTimeout(timer);
|
||
const dur = Date.now() - t0;
|
||
info("reply-pi", `pi exited code=${code} after ${dur}ms (stdout=${stdout.length}B) for ${player}`);
|
||
if (code !== 0) {
|
||
warn("reply-pi", `pi non-zero stderr: ${stderr.slice(0, 200)}`);
|
||
resolve(null);
|
||
return;
|
||
}
|
||
resolve(sanitiseReply(stdout));
|
||
});
|
||
});
|
||
}
|
||
|
||
export const _internal = { sanitiseReply, buildPrompt };
|