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>
This commit is contained in:
2026-05-26 19:12:58 +03:00
co-authored by Claude Opus 4.7
parent 49ce0f9bb5
commit d9f11b7d99
6 changed files with 399 additions and 14 deletions
+28 -13
View File
@@ -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 ---------------------------------------------------------------