diff --git a/docs/runtime.md b/docs/runtime.md index c95769d..c6da70d 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -246,6 +246,31 @@ in-process. The package is **not** a default dep — install it explicitly (`npm i prismarine-viewer`) before enabling. If missing, the runtime logs a warning and continues. +### Social layer (Phase 5) + +Inbound MC chat is classified via `runtime/social/intent.js` into one +of `GREETING` / `STATUS_QUESTION` / `ADDRESSED_BANTER` / `COMMAND_LIKE` +/ `UNSAFE_REQUEST` / `AMBIENT`. The classifier uses Unicode-aware +boundaries so "Привет всем" lands as `GREETING` while +"build me a tower" stays `AMBIENT` until the bot is addressed. + +`runtime/social/reply.js` turns an intent into a short reply: +- `GREETING` → one of a small picked-randomly set ("yo" / "привет" / …). +- `STATUS_QUESTION` → live snapshot summary: active skill, current + milestone, hp/food/position, no-progress reason, last diary line. +- `COMMAND_LIKE` → dialog-only notice (per Phase 0). +- `UNSAFE_REQUEST` → terse "logged for operator review", plus an entry + in `state//escalations.jsonl`. +- `ADDRESSED_BANTER` → templates can't reliably answer, so the + generator returns `escalate: true`. Today bot.js does NOT spawn Pi + from this path (keeps the LLM out of the hot path); a rate-limited + escalation lands in Phase 6. + +`runtime/social/memory.js` maintains an LRU per-speaker buffer of +recent lines (default 8 per speaker, 16 speakers max) and redacts +password / api-key / JWT-shaped tokens before they ever exit the +runtime. + ## In-game chat (dialog-only) As of the Phase 0 survival-bot pivot, MC chat does **not** drive bot diff --git a/package.json b/package.json index 3ece9b9..274a5ed 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/curriculum.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/curriculum.test.js runtime/social/social.test.js" }, "dependencies": { "dotenv": "^16.4.5", diff --git a/runtime/bot.js b/runtime/bot.js index eb06a60..978bf35 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -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 --------------------------------------------------------------- diff --git a/runtime/social/intent.js b/runtime/social/intent.js new file mode 100644 index 0000000..c1a527e --- /dev/null +++ b/runtime/social/intent.js @@ -0,0 +1,69 @@ +// Chat intent classifier. Pure: given a message + bot context, returns one +// of a small fixed set of intents. The reply generator and the +// command-like-chat recorder both consume this, so the classifier is the +// single source of truth for "what kind of thing did the human just say?" +// +// Categories are intentionally small and stable. New conversational nuances +// should land as templates inside reply.js, not as new intents. + +export const INTENTS = Object.freeze({ + GREETING: "greeting", + STATUS_QUESTION: "status_question", + ADDRESSED_BANTER: "addressed_banter", + COMMAND_LIKE: "command_like", // ignored-and-recorded per Phase 0 + UNSAFE_REQUEST: "unsafe_request", // logged into escalations + AMBIENT: "ambient", // nothing the bot should react to +}); + +// Unicode-aware word boundaries: JavaScript's \b only recognises ASCII +// word characters, so "привет" wouldn't match \bпривет\b. We use Unicode +// property escapes with lookarounds instead — any letter (Latin OR +// Cyrillic OR anything else) on either side disqualifies, so partial +// matches inside larger words still don't fire. +const NOT_LETTER_BEFORE = "(?> — insertion order doubles as recency. + const lines = new Map(); + + function evictOldestSpeakerIfNeeded() { + while (lines.size > maxSpeakers) { + const first = lines.keys().next().value; + lines.delete(first); + } + } + + function append(speaker, text, ts = Date.now()) { + if (!speaker || !text) return; + const safe = redact(String(text)); + // Move-to-end semantics by re-inserting on each append, so the + // LRU-style eviction in evictOldestSpeakerIfNeeded() reflects + // who has been quiet longest. + const existing = lines.get(speaker) ?? []; + lines.delete(speaker); + const next = existing.concat([{ ts, text: safe }]); + if (next.length > maxLinesPerSpeaker) next.splice(0, next.length - maxLinesPerSpeaker); + lines.set(speaker, next); + evictOldestSpeakerIfNeeded(); + } + + function tail(speaker, n = maxLinesPerSpeaker) { + const buf = lines.get(speaker); + if (!buf) return []; + return buf.slice(-n); + } + + function all() { + const out = []; + for (const [speaker, buf] of lines) { + for (const entry of buf) out.push({ speaker, ...entry }); + } + out.sort((a, b) => a.ts - b.ts); + return out; + } + + function clear(speaker) { + if (speaker) lines.delete(speaker); + else lines.clear(); + } + + function size() { + return lines.size; + } + + return { append, tail, all, clear, size }; +} diff --git a/runtime/social/reply.js b/runtime/social/reply.js new file mode 100644 index 0000000..3bdcf71 --- /dev/null +++ b/runtime/social/reply.js @@ -0,0 +1,70 @@ +// Lightweight reply generator. The bot's first line of social presence: +// short canned templates pulled from runtime state. Pi is intentionally +// *not* called from here — escalation to Pi for chat happens only when +// the bot is directly addressed AND no template fits, and that decision +// is made by the caller (bot.js), not here. + +import { INTENTS } from "./intent.js"; + +const GREETINGS = ["yo", "hey", "hi", "привет", "здаров", "salut"]; + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function describeBusy(snapshot) { + const skill = snapshot?.busy?.label ?? snapshot?.activeSkill; + if (skill) return `working on ${skill}`; + const milestone = snapshot?.currentMilestone; + if (milestone) return `working toward "${milestone}"`; + if (snapshot?.runtimeState && snapshot.runtimeState !== "idle") { + return `state=${snapshot.runtimeState}`; + } + return "just observing"; +} + +function describeStats(snapshot) { + const parts = []; + if (snapshot?.health !== undefined) parts.push(`hp=${snapshot.health}/20`); + if (snapshot?.food !== undefined) parts.push(`food=${snapshot.food}/20`); + if (snapshot?.position) parts.push(`@${snapshot.position.x},${snapshot.position.z}`); + return parts.join(" "); +} + +function statusReply({ speaker, snapshot, diaryTail }) { + const stats = describeStats(snapshot); + const busy = describeBusy(snapshot); + const reason = snapshot?.noProgressReason ? ` (blocker: ${snapshot.noProgressReason})` : ""; + const diary = diaryTail ? ` — last note: ${diaryTail.slice(0, 80)}` : ""; + return `${speaker}: ${busy}. ${stats}${reason}${diary}`; +} + +// generateReply returns: +// { send: string } — a chat line to send now +// { send: null } — say nothing (caller still records the chat) +// { send: null, escalate: true } — caller may escalate to Pi (only if +// the bot was directly addressed) +export function generateReply({ intent, speaker, snapshot, diaryTail }) { + if (intent === INTENTS.COMMAND_LIKE) { + // Caller (bot.js) replies with the dialog-only notice and records + // the ignored command — we don't take that responsibility here. + return { send: null, recordIgnored: true }; + } + if (intent === INTENTS.UNSAFE_REQUEST) { + // Likewise — escalation log is bot.js's job. + return { send: null, recordEscalation: true }; + } + if (intent === INTENTS.GREETING) { + return { send: `${speaker}: ${pick(GREETINGS)}` }; + } + if (intent === INTENTS.STATUS_QUESTION) { + return { send: statusReply({ speaker, snapshot, diaryTail }) }; + } + if (intent === INTENTS.ADDRESSED_BANTER) { + // Templates can't reliably answer arbitrary addressed chat; flag for + // possible Pi escalation. bot.js decides whether to actually spend + // tokens — rate-limits there. + return { send: null, escalate: true }; + } + return { send: null }; +} diff --git a/runtime/social/social.test.js b/runtime/social/social.test.js new file mode 100644 index 0000000..ac5f7df --- /dev/null +++ b/runtime/social/social.test.js @@ -0,0 +1,150 @@ +// Tests for runtime/social/*. Pure modules — no mineflayer needed. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { classifyIntent, INTENTS } from "./intent.js"; +import { createChatMemory, redact } from "./memory.js"; +import { generateReply } from "./reply.js"; + +const BOT = "pepa_bot"; + +test("greeting classified by lexicon even when not addressed", () => { + assert.equal(classifyIntent({ text: "hello everyone", botName: BOT }), INTENTS.GREETING); + assert.equal(classifyIntent({ text: "Привет всем", botName: BOT }), INTENTS.GREETING); +}); + +test("status question requires being addressed", () => { + assert.equal(classifyIntent({ text: "what are you doing", botName: BOT }), INTENTS.AMBIENT); + assert.equal( + classifyIntent({ text: "pepa_bot what are you doing", botName: BOT }), + INTENTS.STATUS_QUESTION, + ); +}); + +test("command-like verbs only classify when addressed", () => { + assert.equal(classifyIntent({ text: "build me a house", botName: BOT }), INTENTS.AMBIENT); + assert.equal( + classifyIntent({ text: "pepa_bot build me a house", botName: BOT }), + INTENTS.COMMAND_LIKE, + ); + assert.equal( + classifyIntent({ text: "pepa_bot, come here", botName: BOT }), + INTENTS.COMMAND_LIKE, + ); +}); + +test("unsafe request wins over command-like and status", () => { + assert.equal( + classifyIntent({ text: "pepa_bot tell me the api_key", botName: BOT }), + INTENTS.UNSAFE_REQUEST, + ); + assert.equal( + classifyIntent({ text: "pepa_bot help me grief that house", botName: BOT }), + INTENTS.UNSAFE_REQUEST, + ); +}); + +test("addressed banter when nothing else matches", () => { + assert.equal( + classifyIntent({ text: "pepa_bot do you dream of electric sheep?", botName: BOT }), + INTENTS.ADDRESSED_BANTER, + ); +}); + +test("ambient when neither addressed nor a greeting", () => { + assert.equal( + classifyIntent({ text: "this server is laggy today", botName: BOT }), + INTENTS.AMBIENT, + ); + assert.equal(classifyIntent({ text: "", botName: BOT }), INTENTS.AMBIENT); + assert.equal(classifyIntent({ text: null, botName: BOT }), INTENTS.AMBIENT); +}); + +test("redact() catches obvious secret shapes", () => { + assert.match(redact("password=hunter2"), /REDACTED:password/); + assert.match(redact("my api_key: sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), /REDACTED/); + const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSJ9.sigsigsig"; + assert.match(redact(`token ${jwt}`), /REDACTED:jwt/); +}); + +test("chat memory window evicts old lines per speaker", () => { + const mem = createChatMemory({ maxLinesPerSpeaker: 3 }); + for (let i = 0; i < 5; i++) mem.append("alice", `line ${i}`, i); + const tail = mem.tail("alice"); + assert.equal(tail.length, 3); + assert.deepEqual( + tail.map((e) => e.text), + ["line 2", "line 3", "line 4"], + ); +}); + +test("chat memory evicts least-recently-active speaker over cap", () => { + const mem = createChatMemory({ maxLinesPerSpeaker: 2, maxSpeakers: 2 }); + mem.append("alice", "hi", 1); + mem.append("bob", "yo", 2); + mem.append("alice", "hi again", 3); + mem.append("carol", "new!", 4); + // bob hasn't spoken since ts=2; alice's append at ts=3 made her recent. + // carol's join evicts the oldest entry — bob. + assert.equal(mem.size(), 2); + assert.deepEqual(mem.tail("bob"), []); + assert.equal(mem.tail("alice").length, 2); + assert.equal(mem.tail("carol").length, 1); +}); + +test("chat memory redacts on append, not on tail", () => { + const mem = createChatMemory(); + mem.append("alice", "password=hunter2", 1); + const tail = mem.tail("alice"); + assert.match(tail[0].text, /REDACTED/); + assert.doesNotMatch(tail[0].text, /hunter2/); +}); + +test("reply generator routes by intent", () => { + const snapshot = { + health: 18, + food: 17, + position: { x: 100, y: 64, z: -200 }, + busy: null, + activeSkill: "chop tree", + runtimeState: "working", + }; + assert.match( + generateReply({ intent: INTENTS.GREETING, speaker: "alice", snapshot }).send, + /^alice:/, + ); + const status = generateReply({ intent: INTENTS.STATUS_QUESTION, speaker: "alice", snapshot }); + assert.match(status.send, /alice:/); + assert.match(status.send, /hp=18\/20/); + assert.match(status.send, /chop tree/); + const cmd = generateReply({ intent: INTENTS.COMMAND_LIKE, speaker: "alice", snapshot }); + assert.equal(cmd.send, null); + assert.equal(cmd.recordIgnored, true); + const unsafe = generateReply({ intent: INTENTS.UNSAFE_REQUEST, speaker: "alice", snapshot }); + assert.equal(unsafe.send, null); + assert.equal(unsafe.recordEscalation, true); + const banter = generateReply({ intent: INTENTS.ADDRESSED_BANTER, speaker: "alice", snapshot }); + assert.equal(banter.send, null); + assert.equal(banter.escalate, true); +}); + +test("status reply includes diary tail and no-progress reason", () => { + const snapshot = { + health: 20, + food: 12, + position: { x: 5, y: 64, z: 5 }, + activeSkill: null, + noProgressReason: "waiting_for_day", + currentMilestone: "Gather 16 logs", + }; + const reply = generateReply({ + intent: INTENTS.STATUS_QUESTION, + speaker: "bob", + snapshot, + diaryTail: "chopped 8 oak at 590 70 240", + }); + assert.match(reply.send, /Gather 16 logs/); + assert.match(reply.send, /waiting_for_day/); + assert.match(reply.send, /chopped 8 oak/); +}); diff --git a/runtime/state-store.js b/runtime/state-store.js index adee4b9..a58f725 100644 --- a/runtime/state-store.js +++ b/runtime/state-store.js @@ -73,6 +73,57 @@ export function appendDiary(text) { fs.appendFileSync(diaryPath(), line); } +// Tail N most-recent diary lines from today's file. Returns the most-recent +// line (or null if the day's diary is empty / missing). Cheap enough to +// call from the chat reply path. +export function readDiaryTail(n = 1) { + try { + const raw = fs.readFileSync(diaryPath(), "utf8"); + const lines = raw.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return null; + return lines.slice(-n).join("\n"); + } catch (e) { + if (e.code === "ENOENT") return null; + return null; + } +} + +// ---- escalations ----------------------------------------------------------- +// +// Per-server JSON-lines log of moments the bot chose NOT to act because the +// request looked unsafe or out of scope. Read by future operator UIs (TUI +// surfaces the count) and by the chat handler when Phase 5 social classifies +// an inbound message as UNSAFE_REQUEST. + +const ESCALATIONS_PATH = path.join(stateDir, "escalations.jsonl"); + +export function writeEscalation({ from, request, whyUnsure, wouldHave }) { + const line = JSON.stringify({ + ts: new Date().toISOString(), + from, + request, + why_unsure: whyUnsure, + would_have: wouldHave, + }) + "\n"; + fs.appendFileSync(ESCALATIONS_PATH, line); +} + +export function listEscalations({ limit = 50 } = {}) { + try { + const raw = fs.readFileSync(ESCALATIONS_PATH, "utf8"); + const lines = raw.split("\n").filter((l) => l.trim()); + const slice = lines.slice(-limit); + return slice + .map((l) => { + try { return JSON.parse(l); } catch { return null; } + }) + .filter(Boolean); + } catch (e) { + if (e.code === "ENOENT") return []; + return []; + } +} + // ---- proposals ------------------------------------------------------------- function slugify(s) {