Files
pepa-pi-bot/runtime/social/reply-pi.js
T
mayatnikovandClaude Opus 4.7 d9f11b7d99 feat(social): Pi-driven Russian chat replies with persistent per-player history
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>
2026-05-26 19:12:58 +03:00

149 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };