feat(runtime): social layer — intent / templates / chat memory (Phase 5)

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>
This commit is contained in:
2026-05-25 22:30:15 +03:00
co-authored by Claude Opus 4.7
parent ae7b4d89cb
commit 7a6c874e35
8 changed files with 499 additions and 46 deletions
+150
View File
@@ -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/);
});