feat(runtime): social layer (Phase 5) #16
@@ -246,6 +246,31 @@ in-process. The package is **not** a default dep — install it explicitly
|
||||
(`npm i prismarine-viewer`) before enabling. If missing, the runtime
|
||||
logs a warning and continues.
|
||||
|
||||
### Social layer (Phase 5)
|
||||
|
||||
Inbound MC chat is classified via `runtime/social/intent.js` into one
|
||||
of `GREETING` / `STATUS_QUESTION` / `ADDRESSED_BANTER` / `COMMAND_LIKE`
|
||||
/ `UNSAFE_REQUEST` / `AMBIENT`. The classifier uses Unicode-aware
|
||||
boundaries so "Привет всем" lands as `GREETING` while
|
||||
"build me a tower" stays `AMBIENT` until the bot is addressed.
|
||||
|
||||
`runtime/social/reply.js` turns an intent into a short reply:
|
||||
- `GREETING` → one of a small picked-randomly set ("yo" / "привет" / …).
|
||||
- `STATUS_QUESTION` → live snapshot summary: active skill, current
|
||||
milestone, hp/food/position, no-progress reason, last diary line.
|
||||
- `COMMAND_LIKE` → dialog-only notice (per Phase 0).
|
||||
- `UNSAFE_REQUEST` → terse "logged for operator review", plus an entry
|
||||
in `state/<host>/escalations.jsonl`.
|
||||
- `ADDRESSED_BANTER` → templates can't reliably answer, so the
|
||||
generator returns `escalate: true`. Today bot.js does NOT spawn Pi
|
||||
from this path (keeps the LLM out of the hot path); a rate-limited
|
||||
escalation lands in Phase 6.
|
||||
|
||||
`runtime/social/memory.js` maintains an LRU per-speaker buffer of
|
||||
recent lines (default 8 per speaker, 16 speakers max) and redacts
|
||||
password / api-key / JWT-shaped tokens before they ever exit the
|
||||
runtime.
|
||||
|
||||
## In-game chat (dialog-only)
|
||||
|
||||
As of the Phase 0 survival-bot pivot, MC chat does **not** drive bot
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"tui": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js",
|
||||
"stop": "bash scripts/stop.sh",
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/curriculum.test.js"
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/curriculum.test.js runtime/social/social.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
+55
-45
@@ -32,6 +32,8 @@ import {
|
||||
listProposals,
|
||||
readProposal,
|
||||
approveProposal,
|
||||
writeEscalation,
|
||||
readDiaryTail,
|
||||
} from "./state-store.js";
|
||||
import { startAutoImprover } from "./auto-improve.js";
|
||||
import { startPlanner, isPlannerBusy, readNextMilestone, planExists } from "./planner.js";
|
||||
@@ -39,6 +41,9 @@ import { computeState, STATES } from "./state.js";
|
||||
import { createNoProgressDetector } from "./no-progress.js";
|
||||
import { maybeStartViewer } from "./viewer.js";
|
||||
import { nextMilestone as nextCurriculumMilestone } from "./curriculum.js";
|
||||
import { classifyIntent, INTENTS } from "./social/intent.js";
|
||||
import { generateReply } from "./social/reply.js";
|
||||
import { createChatMemory } from "./social/memory.js";
|
||||
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const JOINED_FLAG = path.join(stateDir, "joined-before.flag");
|
||||
@@ -333,50 +338,36 @@ function maybeFileProposal(label) {
|
||||
appendDiary(`proposal filed: ${filename} (${summary})`);
|
||||
}
|
||||
|
||||
// ---- chat replies (dialog-only) --------------------------------------------
|
||||
// ---- chat (dialog-only via social/) ----------------------------------------
|
||||
//
|
||||
// As of Phase 0 of the survival-bot PRD, MC chat is dialog-only for everyone,
|
||||
// including OPERATOR_USERNAMES. The bot may reply socially or answer status
|
||||
// questions, but it does NOT dispatch movement/build/mining tasks from chat.
|
||||
// If a player addresses the bot with a command-like verb (come, follow, build,
|
||||
// pause, stop, give), the bot acknowledges that chat is dialog-only and
|
||||
// records the ignored command in the diary.
|
||||
// MC chat is dialog-only (Phase 0 of survival-bot PRD). Phase 5 routes
|
||||
// inbound chat through the social/ layer:
|
||||
// 1. classifyIntent() decides what the message is.
|
||||
// 2. generateReply() produces a templated reply (or signals escalate /
|
||||
// record-ignored / record-escalation).
|
||||
// 3. We record every line in the chat memory (with redaction) so future
|
||||
// Pi calls can quote recent context without leaking secrets.
|
||||
|
||||
let lastChatReplyAt = 0;
|
||||
const CHAT_REPLY_COOLDOWN_MS = 30_000;
|
||||
|
||||
const GREETING_RE = /\b(hi|hello|hey|yo|sup|hola|привет|здаров|здарова|здорова|здравствуй|здравствуйте|салам)\b/i;
|
||||
const STATUS_RE = /\b(status|how are you|what are you doing|whats up|what['’]?s up|чё делаешь|что делаешь|как ты|как дела|статус)\b/i;
|
||||
const COMMAND_LIKE_RE = /\b(come|follow|build|pause|resume|stop|go to|goto|tp|teleport|give|drop|attack|kill|dig|mine|chop|farm|harvest|sleep here|иди сюда|подойди|следуй|остановись|стоп|пауза|строй|копай|дай)\b/i;
|
||||
const chatMemory = createChatMemory();
|
||||
|
||||
function isOperator(username) {
|
||||
if (!username) return false;
|
||||
return config.operators.includes(username.toLowerCase());
|
||||
}
|
||||
|
||||
function buildStatusReply() {
|
||||
const s = lastSnapshot;
|
||||
const parts = [];
|
||||
if (reflexCtx.busy) parts.push(`busy=${reflexCtx.currentActionLabel}`);
|
||||
if (s.health !== undefined) parts.push(`hp=${s.health}/20`);
|
||||
if (s.food !== undefined) parts.push(`food=${s.food}/20`);
|
||||
if (s.position) parts.push(`pos=${s.position.x},${s.position.y},${s.position.z}`);
|
||||
if (s.hostileCount) parts.push(`hostiles=${s.hostileCount}`);
|
||||
return parts.join(" ") || "alive";
|
||||
}
|
||||
|
||||
function handleChat(username, text) {
|
||||
if (!bot) return;
|
||||
const trimmed = text.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
const botname = bot.username.toLowerCase();
|
||||
const addressed = lower.includes(botname);
|
||||
const looksLikeCommand = addressed && COMMAND_LIKE_RE.test(lower);
|
||||
const trimmed = String(text ?? "").trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Command-like chat (from anyone, including operators) is recorded but not
|
||||
// dispatched. We tell the speaker once per cooldown so they aren't left
|
||||
// wondering why nothing happened.
|
||||
if (looksLikeCommand) {
|
||||
chatMemory.append(username, trimmed);
|
||||
|
||||
const intent = classifyIntent({ text: trimmed, botName: bot.username });
|
||||
|
||||
// Command-like chat → record + one-per-cooldown notice.
|
||||
if (intent === INTENTS.COMMAND_LIKE) {
|
||||
const op = isOperator(username) ? "operator" : "player";
|
||||
info("chat", `ignored command-like chat from ${op} ${username}: ${trimmed.slice(0, 80)}`);
|
||||
appendDiary(`ignored command-like chat from ${username}: ${trimmed.slice(0, 120)}`);
|
||||
@@ -388,22 +379,41 @@ function handleChat(username, text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dialog: greeting, status question, or addressed banter.
|
||||
const wantsStatus = addressed && STATUS_RE.test(lower);
|
||||
const isGreeting = GREETING_RE.test(lower);
|
||||
if (!addressed && !isGreeting) return;
|
||||
|
||||
const since = Date.now() - lastChatReplyAt;
|
||||
if (since < CHAT_REPLY_COOLDOWN_MS) return;
|
||||
lastChatReplyAt = Date.now();
|
||||
|
||||
if (wantsStatus) {
|
||||
botChat(`${username}: ${buildStatusReply()}`);
|
||||
// Unsafe → escalation log + brief notice, no action.
|
||||
if (intent === INTENTS.UNSAFE_REQUEST) {
|
||||
try {
|
||||
writeEscalation({
|
||||
from: username,
|
||||
request: trimmed.slice(0, 200),
|
||||
whyUnsure: "matched unsafe pattern",
|
||||
wouldHave: "no action",
|
||||
});
|
||||
} catch (e) {
|
||||
warn("chat", `writeEscalation failed: ${e.message}`);
|
||||
}
|
||||
const since = Date.now() - lastChatReplyAt;
|
||||
if (since >= CHAT_REPLY_COOLDOWN_MS) {
|
||||
lastChatReplyAt = Date.now();
|
||||
botChat(`${username}: not doing that. logged for operator review.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const greetings = ["yo", "hey", "hi", "привет"];
|
||||
const reply = greetings[Math.floor(Math.random() * greetings.length)];
|
||||
botChat(`${username}: ${reply}`);
|
||||
|
||||
// Greetings / status / addressed banter → templated reply, rate-limited.
|
||||
const since = Date.now() - lastChatReplyAt;
|
||||
if (since < CHAT_REPLY_COOLDOWN_MS) return;
|
||||
const diaryTail = (() => {
|
||||
try { return readDiaryTail(1); } catch { return null; }
|
||||
})();
|
||||
const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail });
|
||||
if (result?.send) {
|
||||
lastChatReplyAt = Date.now();
|
||||
botChat(result.send);
|
||||
return;
|
||||
}
|
||||
// Templates didn't fit and the bot was addressed (ADDRESSED_BANTER) —
|
||||
// escalation to Pi is allowed but not done from here; future work will
|
||||
// route through a rate-limited askPi with prompt-cached context.
|
||||
}
|
||||
|
||||
// ---- connect ---------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Rolling chat memory window. Per-speaker buffer of recent lines + a
|
||||
// redaction pass so anything that *looks* like a secret never leaves the
|
||||
// runtime (in a future Pi prompt, for instance).
|
||||
|
||||
const DEFAULT_MAX_LINES_PER_SPEAKER = 8;
|
||||
const DEFAULT_MAX_SPEAKERS = 16;
|
||||
|
||||
// Patterns that catch the obvious shapes of secrets a Minecraft chat
|
||||
// might surface accidentally (server-issued reset tokens, AuthMe
|
||||
// password reminders, anyone pasting an API key). We never replace
|
||||
// in-place — we drop the whole token and substitute a sentinel.
|
||||
const REDACT_PATTERNS = [
|
||||
{ re: /\b(?:password|pass|pwd)\s*[:=]\s*\S+/gi, mask: "[REDACTED:password]" },
|
||||
{ re: /\b(?:api[_-]?key|token|secret)\s*[:=]\s*\S+/gi, mask: "[REDACTED:secret]" },
|
||||
{ re: /\b(?:sk|pk)-[A-Za-z0-9]{16,}\b/g, mask: "[REDACTED:key]" },
|
||||
{ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, mask: "[REDACTED:jwt]" },
|
||||
];
|
||||
|
||||
export function redact(text) {
|
||||
if (!text) return text;
|
||||
let out = String(text);
|
||||
for (const { re, mask } of REDACT_PATTERNS) {
|
||||
out = out.replace(re, mask);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createChatMemory({ maxLinesPerSpeaker = DEFAULT_MAX_LINES_PER_SPEAKER, maxSpeakers = DEFAULT_MAX_SPEAKERS } = {}) {
|
||||
// Map<speaker, Array<{ts, text}>> — insertion order doubles as recency.
|
||||
const lines = new Map();
|
||||
|
||||
function evictOldestSpeakerIfNeeded() {
|
||||
while (lines.size > maxSpeakers) {
|
||||
const first = lines.keys().next().value;
|
||||
lines.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
function append(speaker, text, ts = Date.now()) {
|
||||
if (!speaker || !text) return;
|
||||
const safe = redact(String(text));
|
||||
// Move-to-end semantics by re-inserting on each append, so the
|
||||
// LRU-style eviction in evictOldestSpeakerIfNeeded() reflects
|
||||
// who has been quiet longest.
|
||||
const existing = lines.get(speaker) ?? [];
|
||||
lines.delete(speaker);
|
||||
const next = existing.concat([{ ts, text: safe }]);
|
||||
if (next.length > maxLinesPerSpeaker) next.splice(0, next.length - maxLinesPerSpeaker);
|
||||
lines.set(speaker, next);
|
||||
evictOldestSpeakerIfNeeded();
|
||||
}
|
||||
|
||||
function tail(speaker, n = maxLinesPerSpeaker) {
|
||||
const buf = lines.get(speaker);
|
||||
if (!buf) return [];
|
||||
return buf.slice(-n);
|
||||
}
|
||||
|
||||
function all() {
|
||||
const out = [];
|
||||
for (const [speaker, buf] of lines) {
|
||||
for (const entry of buf) out.push({ speaker, ...entry });
|
||||
}
|
||||
out.sort((a, b) => a.ts - b.ts);
|
||||
return out;
|
||||
}
|
||||
|
||||
function clear(speaker) {
|
||||
if (speaker) lines.delete(speaker);
|
||||
else lines.clear();
|
||||
}
|
||||
|
||||
function size() {
|
||||
return lines.size;
|
||||
}
|
||||
|
||||
return { append, tail, all, clear, size };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -73,6 +73,57 @@ export function appendDiary(text) {
|
||||
fs.appendFileSync(diaryPath(), line);
|
||||
}
|
||||
|
||||
// Tail N most-recent diary lines from today's file. Returns the most-recent
|
||||
// line (or null if the day's diary is empty / missing). Cheap enough to
|
||||
// call from the chat reply path.
|
||||
export function readDiaryTail(n = 1) {
|
||||
try {
|
||||
const raw = fs.readFileSync(diaryPath(), "utf8");
|
||||
const lines = raw.split("\n").filter((l) => l.trim());
|
||||
if (lines.length === 0) return null;
|
||||
return lines.slice(-n).join("\n");
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- escalations -----------------------------------------------------------
|
||||
//
|
||||
// Per-server JSON-lines log of moments the bot chose NOT to act because the
|
||||
// request looked unsafe or out of scope. Read by future operator UIs (TUI
|
||||
// surfaces the count) and by the chat handler when Phase 5 social classifies
|
||||
// an inbound message as UNSAFE_REQUEST.
|
||||
|
||||
const ESCALATIONS_PATH = path.join(stateDir, "escalations.jsonl");
|
||||
|
||||
export function writeEscalation({ from, request, whyUnsure, wouldHave }) {
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
from,
|
||||
request,
|
||||
why_unsure: whyUnsure,
|
||||
would_have: wouldHave,
|
||||
}) + "\n";
|
||||
fs.appendFileSync(ESCALATIONS_PATH, line);
|
||||
}
|
||||
|
||||
export function listEscalations({ limit = 50 } = {}) {
|
||||
try {
|
||||
const raw = fs.readFileSync(ESCALATIONS_PATH, "utf8");
|
||||
const lines = raw.split("\n").filter((l) => l.trim());
|
||||
const slice = lines.slice(-limit);
|
||||
return slice
|
||||
.map((l) => {
|
||||
try { return JSON.parse(l); } catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- proposals -------------------------------------------------------------
|
||||
|
||||
function slugify(s) {
|
||||
|
||||
Reference in New Issue
Block a user