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
+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) {