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:
Yuriy Mayatnikov
2026-05-25 22:30:48 +03:00
committed by GitHub
co-authored by mayatnikov Claude Opus 4.7
parent ae7b4d89cb
commit fc62160524
8 changed files with 499 additions and 46 deletions
+70
View File
@@ -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 };
}