feat(runtime): social layer — intent / templates / chat memory (Phase 5)

Phase 5 of plans/autonomous-survival-bot-prd.md. Make the bot feel
present in chat without ever becoming a command executor.

New: runtime/social/
- intent.js: classifyIntent({text, botName}) returns one of GREETING /
  STATUS_QUESTION / ADDRESSED_BANTER / COMMAND_LIKE / UNSAFE_REQUEST /
  AMBIENT. Unicode-aware word boundaries so cyrillic + latin both work
  ("Привет всем" → GREETING, "build me a tower" → AMBIENT unless
  addressed).
- reply.js: generateReply({intent, speaker, snapshot, diaryTail}) →
  short templated response, or {send: null, escalate: true} for the
  caller to decide whether to spend Pi tokens.
- memory.js: createChatMemory() — per-speaker LRU buffer of recent
  lines; redacts password / api_key / JWT-shaped tokens at append
  time, so the buffer can be safely fed back into any future prompt.
- social.test.js: 12 tests (intent edges, memory eviction, redaction,
  reply routing). npm test now 40/40.

state-store.js additions:
- readDiaryTail(n) — reads the last N lines of today's diary; used by
  status replies.
- writeEscalation({from, request, whyUnsure, wouldHave}) /
  listEscalations() — JSONL log under state/<host>/escalations.jsonl
  for UNSAFE_REQUEST classifications and future operator review.

bot.js: handleChat() now routes through social/intent + social/reply
(replacing the Phase-0 inline regexes), records every line into
chatMemory, and writes an escalation when classifyIntent returns
UNSAFE_REQUEST. Command-like notice + dialog-only behaviour from
Phase 0 are preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 22:30:15 +03:00
co-authored by Claude Opus 4.7
parent ae7b4d89cb
commit 7a6c874e35
8 changed files with 499 additions and 46 deletions
+55 -45
View File
@@ -32,6 +32,8 @@ import {
listProposals,
readProposal,
approveProposal,
writeEscalation,
readDiaryTail,
} from "./state-store.js";
import { startAutoImprover } from "./auto-improve.js";
import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./planner.js";
@@ -39,6 +41,9 @@ import { computeState, STATES } from "./state.js";
import { createNoProgressDetector } from "./no-progress.js";
import { maybeStartViewer } from "./viewer.js";
import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
import { classifyIntent, INTENTS } from "./social/intent.js";
import { generateReply } from "./social/reply.js";
import { createChatMemory } from "./social/memory.js";
fs.mkdirSync(stateDir, { recursive: true });
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
@@ -333,50 +338,36 @@ function maybeFileProposal(label) {
appendDiary(`proposal filed: ${filename} (${summary})`);
}
// ---- chat replies (dialog-only) --------------------------------------------
// ---- chat (dialog-only via social/) ----------------------------------------
//
// As of Phase 0 of the survival-bot PRD, MC chat is dialog-only for everyone,
// including OPERATOR_USERNAMES. The bot may reply socially or answer status
// questions, but it does NOT dispatch movement/build/mining tasks from chat.
// If a player addresses the bot with a command-like verb (come, follow, build,
// pause, stop, give), the bot acknowledges that chat is dialog-only and
// records the ignored command in the diary.
// MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes
// inbound chat through the social/ layer:
// 1. classifyIntent() decides what the message is.
// 2. generateReply() produces a templated reply (or signals escalate /
// record-ignored / record-escalation).
// 3. We record every line in the chat memory (with redaction) so future
// Pi calls can quote recent context without leaking secrets.
let lastChatReplyAt = 0;
const CHAT_REPLY_COOLDOWN_MS = 30_000;
const GREETING_RE = /\b(hi|hello|hey|yo|sup|hola|привет|здаров|здарова|здорова|здравствуй|здравствуйте|салам)\b/i;
const STATUS_RE = /\b(status|how are you|what are you doing|whats up|what[']?s up|чё делаешь|что делаешь|как ты|как дела|статус)\b/i;
const COMMAND_LIKE_RE = /\b(come|follow|build|pause|resume|stop|go to|goto|tp|teleport|give|drop|attack|kill|dig|mine|chop|farm|harvest|sleep here|иди сюда|подойди|следуй|остановись|стоп|пауза|строй|копай|дай)\b/i;
const chatMemory = createChatMemory();
function isOperator(username) {
if (!username) return false;
return config.operators.includes(username.toLowerCase());
}
function buildStatusReply() {
const s = lastSnapshot;
const parts = [];
if (reflexCtx.busy) parts.push(`busy=${reflexCtx.currentActionLabel}`);
if (s.health !== undefined) parts.push(`hp=${s.health}/20`);
if (s.food !== undefined) parts.push(`food=${s.food}/20`);
if (s.position) parts.push(`pos=${s.position.x},${s.position.y},${s.position.z}`);
if (s.hostileCount) parts.push(`hostiles=${s.hostileCount}`);
return parts.join(" ") || "alive";
}
function handleChat(username, text) {
if (!bot) return;
const trimmed = text.trim();
const lower = trimmed.toLowerCase();
const botname = bot.username.toLowerCase();
const addressed = lower.includes(botname);
const looksLikeCommand = addressed && COMMAND_LIKE_RE.test(lower);
const trimmed = String(text ?? "").trim();
if (!trimmed) return;
// Command-like chat (from anyone, including operators) is recorded but not
// dispatched. We tell the speaker once per cooldown so they aren't left
// wondering why nothing happened.
if (looksLikeCommand) {
chatMemory.append(username, trimmed);
const intent = classifyIntent({ text: trimmed, botName: bot.username });
// Command-like chat → record + one-per-cooldown notice.
if (intent === INTENTS.COMMAND_LIKE) {
const op = isOperator(username) ? "operator" : "player";
info("chat", `ignored command-like chat from ${op} ${username}: ${trimmed.slice(0, 80)}`);
appendDiary(`ignored command-like chat from ${username}: ${trimmed.slice(0, 120)}`);
@@ -388,22 +379,41 @@ function handleChat(username, text) {
return;
}
// Dialog: greeting, status question, or addressed banter.
const wantsStatus = addressed && STATUS_RE.test(lower);
const isGreeting = GREETING_RE.test(lower);
if (!addressed && !isGreeting) return;
const since = Date.now() - lastChatReplyAt;
if (since < CHAT_REPLY_COOLDOWN_MS) return;
lastChatReplyAt = Date.now();
if (wantsStatus) {
botChat(`${username}: ${buildStatusReply()}`);
// Unsafe → escalation log + brief notice, no action.
if (intent === INTENTS.UNSAFE_REQUEST) {
try {
writeEscalation({
from: username,
request: trimmed.slice(0, 200),
whyUnsure: "matched unsafe pattern",
wouldHave: "no action",
});
} catch (e) {
warn("chat", `writeEscalation failed: ${e.message}`);
}
const since = Date.now() - lastChatReplyAt;
if (since >= CHAT_REPLY_COOLDOWN_MS) {
lastChatReplyAt = Date.now();
botChat(`${username}: not doing that. logged for operator review.`);
}
return;
}
const greetings = ["yo", "hey", "hi", "привет"];
const reply = greetings[Math.floor(Math.random() * greetings.length)];
botChat(`${username}: ${reply}`);
// Greetings / status / addressed banter → templated reply, rate-limited.
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;
}
// Templates didn't fit and the bot was addressed (ADDRESSED_BANTER) —
// escalation to Pi is allowed but not done from here; future work will
// route through a rate-limited askPi with prompt-cached context.
}
// ---- connect ---------------------------------------------------------------