Files
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

106 lines
3.4 KiB
JavaScript

// Per-player persistent chat history.
//
// Each player gets a JSONL file under state/<host>/chat/<player>.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 {}
}