Files
fc62160524 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>
2026-05-25 22:30:48 +03:00

79 lines
2.5 KiB
JavaScript

// 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 };
}