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>
70 lines
2.9 KiB
JavaScript
70 lines
2.9 KiB
JavaScript
// 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;
|
||
}
|