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:
@@ -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