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;
|
||||
}
|
||||
Reference in New Issue
Block a user