From d9f11b7d99b4e3dfe27d970b90df01a33b99f6c5 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Tue, 26 May 2026 19:12:58 +0300 Subject: [PATCH] feat(social): Pi-driven Russian chat replies with persistent per-player history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//chat/.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) --- package.json | 2 +- runtime/bot.js | 41 +++++--- runtime/social/chat-history.js | 105 ++++++++++++++++++++ runtime/social/chat-history.test.js | 69 +++++++++++++ runtime/social/reply-pi.js | 148 ++++++++++++++++++++++++++++ runtime/social/reply-pi.test.js | 48 +++++++++ 6 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 runtime/social/chat-history.js create mode 100644 runtime/social/chat-history.test.js create mode 100644 runtime/social/reply-pi.js create mode 100644 runtime/social/reply-pi.test.js diff --git a/package.json b/package.json index fb6598d..74cca31 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "tui": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "canvas": "^3.2.3", diff --git a/runtime/bot.js b/runtime/bot.js index 86e8990..fcaa20f 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -46,6 +46,8 @@ import { runSkill } from "./skills/index.js"; import { classifyIntent, INTENTS } from "./social/intent.js"; import { generateReply } from "./social/reply.js"; import { createChatMemory } from "./social/memory.js"; +import { appendChat as appendChatHistory } from "./social/chat-history.js"; +import { piReply } from "./social/reply-pi.js"; import { openConversation, peekConversation, listConversations } from "./social/conversation.js"; import { takeScreenshot } from "./viewer.js"; import { createStuckIncidentDetector, attachCritique } from "./stuck-incident.js"; @@ -545,8 +547,10 @@ function handleChat(username, text) { if (!bot) return; const trimmed = String(text ?? "").trim(); if (!trimmed) return; + if (username === bot.username) return; // never reply to ourselves chatMemory.append(username, trimmed); + try { appendChatHistory({ player: username, dir: "in", text: trimmed, snapshot: lastSnapshot }); } catch {} const intent = classifyIntent({ text: trimmed, botName: bot.username }); @@ -583,24 +587,35 @@ function handleChat(username, text) { return; } - // Greetings / status / addressed banter → templated reply, rate-limited. + // Greetings / status / addressed banter → Pi reply with persona + + // per-player history. The old template path stays as a fast + // fallback when Pi is unavailable or times out. const since = Date.now() - lastChatReplyAt; if (since < CHAT_REPLY_COOLDOWN_MS) return; const diaryTail = (() => { try { return readDiaryTail(1); } catch { return null; } })(); - const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail }); - if (result?.send) { - lastChatReplyAt = Date.now(); - botChat(result.send); - return; - } - if (result?.escalate) { - // Templates didn't fit AND the bot was addressed → ask Pi for a - // one-liner. Hard rate-limited so addressed-banter lines can't - // drain the LLM budget. - escalateChatToPi({ speaker: username, text: trimmed, intent }); - } + + (async () => { + try { + const pi = await piReply({ player: username, text: trimmed, snapshot: lastSnapshot, diaryTail }); + if (pi) { + lastChatReplyAt = Date.now(); + botChat(pi); + try { appendChatHistory({ player: username, dir: "out", text: pi, snapshot: lastSnapshot }); } catch {} + return; + } + } catch (e) { + warn("chat", `piReply threw: ${e.message}`); + } + // Fallback: templated reply so the bot still says something. + const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail }); + if (result?.send) { + lastChatReplyAt = Date.now(); + botChat(result.send); + try { appendChatHistory({ player: username, dir: "out", text: result.send, snapshot: lastSnapshot }); } catch {} + } + })(); } // ---- connect --------------------------------------------------------------- diff --git a/runtime/social/chat-history.js b/runtime/social/chat-history.js new file mode 100644 index 0000000..7ee2cd0 --- /dev/null +++ b/runtime/social/chat-history.js @@ -0,0 +1,105 @@ +// Per-player persistent chat history. +// +// Each player gets a JSONL file under state//chat/.jsonl. +// Lines are { ts, dir: "in" | "out", text, snapshot? } and accumulate +// forever (with a 1000-line rolling cap per player, much higher than +// the in-memory chat-memory window so Pi can reference older banter +// across sessions — "помнишь, когда ты притащил жабу в чат?"). +// +// Why per-player and not one global file: the runtime needs to give Pi +// the context of *this* dialog with *this* player without it being +// drowned by chatter from other players. Recall + privacy in one cut. +// +// Public API: +// appendChat({player, dir, text, snapshot?}) — persist a line +// recentForPlayer(player, n) — last n entries (oldest first) +// knownPlayers() — list players we have any history with + +import fs from "node:fs"; +import path from "node:path"; +import { stateDir } from "../config.js"; + +const CHAT_DIR = path.join(stateDir, "chat"); +const MAX_LINES_PER_PLAYER = 1000; + +function ensureDir() { + try { fs.mkdirSync(CHAT_DIR, { recursive: true }); } catch {} +} + +function safePlayer(name) { + return String(name ?? "anon").replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 64); +} + +function pathFor(player) { + return path.join(CHAT_DIR, `${safePlayer(player)}.jsonl`); +} + +function readAll(player) { + const fp = pathFor(player); + if (!fs.existsSync(fp)) return []; + const out = []; + for (const line of fs.readFileSync(fp, "utf8").split("\n")) { + if (!line.trim()) continue; + try { out.push(JSON.parse(line)); } catch {} + } + return out; +} + +function rotateIfNeeded(player) { + const all = readAll(player); + if (all.length <= MAX_LINES_PER_PLAYER) return; + const keep = all.slice(-MAX_LINES_PER_PLAYER); + fs.writeFileSync(pathFor(player), keep.map((e) => JSON.stringify(e)).join("\n") + "\n"); +} + +export function appendChat({ player, dir, text, snapshot, ts = Date.now() }) { + if (!player || !text || (dir !== "in" && dir !== "out")) return; + ensureDir(); + const slim = snapshot + ? { + pos: snapshot.position ? { x: Math.round(snapshot.position.x), y: Math.round(snapshot.position.y), z: Math.round(snapshot.position.z) } : null, + activeSkill: snapshot.activeSkill ?? null, + milestone: snapshot.currentMilestone ?? null, + isDay: snapshot.isDay ?? null, + } + : null; + const entry = { ts, dir, text: String(text).slice(0, 500), ...(slim ? { snap: slim } : {}) }; + fs.appendFileSync(pathFor(player), JSON.stringify(entry) + "\n"); + if (Math.random() < 0.05) rotateIfNeeded(player); // amortise rotation +} + +export function recentForPlayer(player, n = 10) { + const all = readAll(player); + return all.slice(-n); +} + +export function knownPlayers() { + ensureDir(); + try { + return fs.readdirSync(CHAT_DIR) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => f.replace(/\.jsonl$/, "")); + } catch { + return []; + } +} + +// Render chat history as plain prompt-friendly lines. +// Example output: +// [2026-05-26 19:07] halofourteen: Привет пепа что делаешь? +// [2026-05-26 19:07] you: yo +export function renderHistory(player, n = 10, botName = "you") { + const recent = recentForPlayer(player, n); + return recent.map((e) => { + const t = new Date(e.ts).toISOString().slice(0, 16).replace("T", " "); + const who = e.dir === "in" ? player : botName; + return `[${t}] ${who}: ${e.text}`; + }).join("\n"); +} + +// Reset (tests only). +export function _resetChatHistory() { + try { + for (const f of fs.readdirSync(CHAT_DIR)) fs.unlinkSync(path.join(CHAT_DIR, f)); + } catch {} +} diff --git a/runtime/social/chat-history.test.js b/runtime/social/chat-history.test.js new file mode 100644 index 0000000..d9d2e52 --- /dev/null +++ b/runtime/social/chat-history.test.js @@ -0,0 +1,69 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { appendChat, recentForPlayer, knownPlayers, renderHistory, _resetChatHistory } from "./chat-history.js"; + +function tag() { return `_p_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; } + +test("append in + out, recentForPlayer returns chronological order", () => { + _resetChatHistory(); + const p = tag(); + appendChat({ player: p, dir: "in", text: "Привет" }); + appendChat({ player: p, dir: "out", text: "yo" }); + appendChat({ player: p, dir: "in", text: "что делаешь?" }); + const last = recentForPlayer(p, 10); + assert.equal(last.length, 3); + assert.equal(last[0].dir, "in"); + assert.equal(last[1].dir, "out"); + assert.equal(last[2].text, "что делаешь?"); +}); + +test("rejects malformed appends silently", () => { + _resetChatHistory(); + const p = tag(); + appendChat({ player: p, dir: "in", text: "" }); // empty text + appendChat({ player: p, dir: "bogus", text: "x" }); // bad dir + appendChat({ player: null, dir: "in", text: "x" }); // no player + assert.equal(recentForPlayer(p, 10).length, 0); +}); + +test("knownPlayers returns every player we wrote to", () => { + _resetChatHistory(); + const a = tag(), b = tag(); + appendChat({ player: a, dir: "in", text: "hi" }); + appendChat({ player: b, dir: "in", text: "hello" }); + const known = knownPlayers(); + assert.ok(known.includes(a) && known.includes(b)); +}); + +test("renderHistory produces 'player: text' lines", () => { + _resetChatHistory(); + const p = tag(); + appendChat({ player: p, dir: "in", text: "Привет" }); + appendChat({ player: p, dir: "out", text: "yo" }); + const md = renderHistory(p, 10, "pepa_bot"); + assert.match(md, new RegExp(`${p}: Привет`)); + assert.match(md, /pepa_bot: yo/); +}); + +test("snapshot stores compact slim form", () => { + _resetChatHistory(); + const p = tag(); + appendChat({ + player: p, + dir: "out", + text: "копаю", + snapshot: { position: { x: 100.7, y: 64.1, z: -33.4 }, activeSkill: "gather.logs", currentMilestone: "Gather 16 logs", isDay: true }, + }); + const e = recentForPlayer(p, 1)[0]; + assert.deepEqual(e.snap.pos, { x: 101, y: 64, z: -33 }); + assert.equal(e.snap.activeSkill, "gather.logs"); +}); + +test("text truncates at 500 chars", () => { + _resetChatHistory(); + const p = tag(); + const long = "x".repeat(1000); + appendChat({ player: p, dir: "in", text: long }); + const e = recentForPlayer(p, 1)[0]; + assert.equal(e.text.length, 500); +}); diff --git a/runtime/social/reply-pi.js b/runtime/social/reply-pi.js new file mode 100644 index 0000000..941f768 --- /dev/null +++ b/runtime/social/reply-pi.js @@ -0,0 +1,148 @@ +// 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 }; diff --git a/runtime/social/reply-pi.test.js b/runtime/social/reply-pi.test.js new file mode 100644 index 0000000..1cfd426 --- /dev/null +++ b/runtime/social/reply-pi.test.js @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { _internal } from "./reply-pi.js"; + +test("sanitiseReply: strip 'pepa:' prefix", () => { + assert.equal(_internal.sanitiseReply("pepa: здаров"), "здаров"); + assert.equal(_internal.sanitiseReply("pepa_bot: дерево рублю"), "дерево рублю"); +}); + +test("sanitiseReply: strip code fences", () => { + assert.equal(_internal.sanitiseReply("```\nхай\n```"), "хай"); + assert.equal(_internal.sanitiseReply("```ru\nага\n```"), "ага"); +}); + +test("sanitiseReply: strip surrounding quotes", () => { + assert.equal(_internal.sanitiseReply('"здаров"'), "здаров"); + assert.equal(_internal.sanitiseReply("'занят'"), "занят"); +}); + +test("sanitiseReply: only first non-empty line", () => { + assert.equal(_internal.sanitiseReply("здаров\n\nкак сам?"), "здаров"); +}); + +test("sanitiseReply: caps at 200 chars", () => { + const long = "д".repeat(500); + const s = _internal.sanitiseReply(long); + assert.equal(s.length, 200); +}); + +test("sanitiseReply: empty / nullish → null", () => { + assert.equal(_internal.sanitiseReply(""), null); + assert.equal(_internal.sanitiseReply(null), null); + assert.equal(_internal.sanitiseReply(" \n \n "), null); +}); + +test("buildPrompt: contains the player name and the incoming text", () => { + const p = _internal.buildPrompt({ + player: "halofourteen", + text: "что делаешь?", + snapshot: { position: { x: 100, y: 64, z: -10 }, health: 20, food: 18, isDay: true, activeSkill: "gather.logs", currentMilestone: "Gather 16 logs" }, + diaryTail: "вчера спал на дереве", + }); + assert.ok(p.includes("halofourteen")); + assert.ok(p.includes("что делаешь?")); + assert.ok(p.includes("gather.logs")); + assert.ok(p.includes("вчера спал на дереве")); + assert.ok(p.includes("Ты — pepa_bot")); +});