feat(runtime): social layer (Phase 5) #16

Merged
halofourteen merged 1 commits from feat/survival-pivot-phase-5 into main 2026-05-25 22:30:48 +03:00
8 changed files with 499 additions and 46 deletions
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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 ---------------------------------------------------------------
+69
View File
@@ -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;
}
+78
View File
@@ -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 };
}
+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 };
}
+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/);
});
+51
View File
@@ -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) {