feat(runtime): social layer — intent / templates / chat memory (Phase 5) (#16)
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: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #16.
This commit is contained in:
@@ -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 = "(?<![\\p{L}\\p{N}])";
|
||||
const NOT_LETTER_AFTER = "(?![\\p{L}\\p{N}])";
|
||||
|
||||
function wordRe(words) {
|
||||
return new RegExp(`${NOT_LETTER_BEFORE}(?:${words.join("|")})${NOT_LETTER_AFTER}`, "iu");
|
||||
}
|
||||
|
||||
const GREETING_RE = wordRe([
|
||||
"hi", "hello", "hey", "yo", "sup", "hola",
|
||||
"привет", "здаров", "здарова", "здорова", "здравствуй", "здравствуйте", "салам",
|
||||
]);
|
||||
const STATUS_RE = wordRe([
|
||||
"status", "how are you", "how['’]?s it going", "what are you doing",
|
||||
"whats up", "what['’]?s up",
|
||||
"чё делаешь", "что делаешь", "как ты", "как дела", "статус", "чем занят",
|
||||
]);
|
||||
const COMMAND_LIKE_RE = wordRe([
|
||||
"come", "follow", "build", "pause", "resume", "stop", "go to", "goto", "tp",
|
||||
"teleport", "give", "drop", "attack", "kill", "dig", "mine", "chop", "farm",
|
||||
"harvest", "sleep here",
|
||||
"иди сюда", "подойди", "следуй", "остановись", "стоп", "пауза", "строй",
|
||||
"копай", "дай",
|
||||
]);
|
||||
const UNSAFE_RE = wordRe([
|
||||
"grief", "kill .*player", "destroy .*house", "burn", "tnt", "lava bucket",
|
||||
"exploit", "dupe", "crash the server", "leak", "password", "api[_ ]?key",
|
||||
]);
|
||||
|
||||
export function classifyIntent({ text, botName }) {
|
||||
if (!text) return INTENTS.AMBIENT;
|
||||
const trimmed = String(text).trim();
|
||||
if (!trimmed) return INTENTS.AMBIENT;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const addressed = !!botName && lower.includes(String(botName).toLowerCase());
|
||||
|
||||
if (UNSAFE_RE.test(lower)) return INTENTS.UNSAFE_REQUEST;
|
||||
|
||||
if (addressed && COMMAND_LIKE_RE.test(lower)) return INTENTS.COMMAND_LIKE;
|
||||
|
||||
if (addressed && STATUS_RE.test(lower)) return INTENTS.STATUS_QUESTION;
|
||||
|
||||
if (GREETING_RE.test(lower)) return INTENTS.GREETING;
|
||||
|
||||
if (addressed) return INTENTS.ADDRESSED_BANTER;
|
||||
|
||||
return INTENTS.AMBIENT;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Rolling chat memory window. Per-speaker buffer of recent lines + a
|
||||
// redaction pass so anything that *looks* like a secret never leaves the
|
||||
// runtime (in a future Pi prompt, for instance).
|
||||
|
||||
const DEFAULT_MAX_LINES_PER_SPEAKER = 8;
|
||||
const DEFAULT_MAX_SPEAKERS = 16;
|
||||
|
||||
// Patterns that catch the obvious shapes of secrets a Minecraft chat
|
||||
// might surface accidentally (server-issued reset tokens, AuthMe
|
||||
// password reminders, anyone pasting an API key). We never replace
|
||||
// in-place — we drop the whole token and substitute a sentinel.
|
||||
const REDACT_PATTERNS = [
|
||||
{ re: /\b(?:password|pass|pwd)\s*[:=]\s*\S+/gi, mask: "[REDACTED:password]" },
|
||||
{ re: /\b(?:api[_-]?key|token|secret)\s*[:=]\s*\S+/gi, mask: "[REDACTED:secret]" },
|
||||
{ re: /\b(?:sk|pk)-[A-Za-z0-9]{16,}\b/g, mask: "[REDACTED:key]" },
|
||||
{ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, mask: "[REDACTED:jwt]" },
|
||||
];
|
||||
|
||||
export function redact(text) {
|
||||
if (!text) return text;
|
||||
let out = String(text);
|
||||
for (const { re, mask } of REDACT_PATTERNS) {
|
||||
out = out.replace(re, mask);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createChatMemory({ maxLinesPerSpeaker = DEFAULT_MAX_LINES_PER_SPEAKER, maxSpeakers = DEFAULT_MAX_SPEAKERS } = {}) {
|
||||
// Map<speaker, Array<{ts, text}>> — 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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user