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:
+28
-13
@@ -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 ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user