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>
79 lines
2.5 KiB
JavaScript
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 };
|
|
}
|