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>
71 lines
2.8 KiB
JavaScript
71 lines
2.8 KiB
JavaScript
// 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 };
|
|
}
|