Add live presence and escalation loop
This commit is contained in:
@@ -158,7 +158,7 @@ These are mirrored in `AGENTS.md` and re-stated at the top of any system prompt
|
|||||||
|
|
||||||
🌳 **Phase 0 — Body** done. Bridge online, AuthMe handled, `hello` sent. See `skills/server-onboarding.md`.
|
🌳 **Phase 0 — Body** done. Bridge online, AuthMe handled, `hello` sent. See `skills/server-onboarding.md`.
|
||||||
|
|
||||||
🌱 **Phase 1 — Presence** is the current target: bot stays online, reacts to chat, survives disconnects within bounds.
|
🌳 **Phase 1 — Presence** implemented: the bridge stays online with bounded reconnects, keeps a rolling chat buffer, exposes status/recent-chat/escalation tools, and can prompt the Pi loop to reply sparingly. Phase 5 self-extension is documented and in progress; Phase 6 escalation logging is implemented.
|
||||||
|
|
||||||
Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Day-to-day judgement principles live under "Operating principles" in [`AGENTS.md`](./AGENTS.md).
|
Full plan: [`docs/roadmap.md`](./docs/roadmap.md). Day-to-day judgement principles live under "Operating principles" in [`AGENTS.md`](./AGENTS.md).
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -10,7 +10,7 @@ The agent has a working Mineflayer bridge, joins the configured server, handles
|
|||||||
|
|
||||||
Captured in: `skills/server-onboarding.md`.
|
Captured in: `skills/server-onboarding.md`.
|
||||||
|
|
||||||
## Phase 1 — Presence 🌿
|
## Phase 1 — Presence 🌳
|
||||||
|
|
||||||
The bot is **on the server, all the time** (except for a clean human-issued disconnect), and is **conversational**:
|
The bot is **on the server, all the time** (except for a clean human-issued disconnect), and is **conversational**:
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Two-way ops channel without sitting in Pi TUI:
|
|||||||
|
|
||||||
`.env` placeholders for `TELEGRAM_BOT_TOKEN` and `TELEGRAM_OPERATOR_CHAT_ID` already exist.
|
`.env` placeholders for `TELEGRAM_BOT_TOKEN` and `TELEGRAM_OPERATOR_CHAT_ID` already exist.
|
||||||
|
|
||||||
## Phase 5 — Self-extension as default 🌱
|
## Phase 5 — Self-extension as default 🌿
|
||||||
|
|
||||||
By this phase the patterns above should produce a reflex:
|
By this phase the patterns above should produce a reflex:
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ By this phase the patterns above should produce a reflex:
|
|||||||
|
|
||||||
The first successful "I'll try to learn" cycle that ships a useful skill marks Phase 5 as 🌳.
|
The first successful "I'll try to learn" cycle that ships a useful skill marks Phase 5 as 🌳.
|
||||||
|
|
||||||
## Phase 6 — Escalation log 🌱
|
## Phase 6 — Escalation log 🌳
|
||||||
|
|
||||||
When a request smells destructive, ambiguous, or off-policy (e.g. break a player's blocks, leave a structure, give someone an item from inventory, leave the server entirely), the bot doesn't unilaterally do it and doesn't flatly refuse. Instead:
|
When a request smells destructive, ambiguous, or off-policy (e.g. break a player's blocks, leave a structure, give someone an item from inventory, leave the server entirely), the bot doesn't unilaterally do it and doesn't flatly refuse. Instead:
|
||||||
|
|
||||||
|
|||||||
+437
-29
@@ -1,6 +1,6 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
import type { Bot } from "mineflayer";
|
import type { Bot } from "mineflayer";
|
||||||
@@ -19,6 +19,8 @@ type AuthObservation =
|
|||||||
| "handled-register"
|
| "handled-register"
|
||||||
| "handled-login";
|
| "handled-login";
|
||||||
|
|
||||||
|
type ConnectionState = "idle" | "connecting" | "connected" | "disconnected" | "reconnect-paused";
|
||||||
|
|
||||||
interface BridgeConfig {
|
interface BridgeConfig {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -28,9 +30,33 @@ interface BridgeConfig {
|
|||||||
authmePassword: string;
|
authmePassword: string;
|
||||||
chatRateLimitPerMinute: number;
|
chatRateLimitPerMinute: number;
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
|
legacyStateDir: string;
|
||||||
redactions: string[];
|
redactions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChatEntry {
|
||||||
|
ts: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
kind: "chat" | "system" | "whisper" | "actionBar" | "raw";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EscalationInput {
|
||||||
|
from: string;
|
||||||
|
request: string;
|
||||||
|
why_unsure: string;
|
||||||
|
would_have: string;
|
||||||
|
ack_text?: string;
|
||||||
|
acknowledge_in_chat?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_CHAT_LIMIT = 30;
|
||||||
|
const RECONNECT_DELAY_MS = 3_000;
|
||||||
|
const RECONNECT_WINDOW_MS = 10 * 60_000;
|
||||||
|
const MAX_RECONNECTS_PER_WINDOW = 3;
|
||||||
|
const ADDRESSED_REVIEW_COOLDOWN_MS = 20_000;
|
||||||
|
const AMBIENT_REVIEW_COOLDOWN_MS = 120_000;
|
||||||
|
|
||||||
const CHAT_PARAMS = {
|
const CHAT_PARAMS = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -43,6 +69,39 @@ const CHAT_PARAMS = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const RECENT_CHAT_PARAMS = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: RECENT_CHAT_LIMIT,
|
||||||
|
description: "Maximum recent chat lines to return. Defaults to 30.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ESCALATION_PARAMS = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
from: { type: "string", description: "Requester nickname or source label." },
|
||||||
|
request: { type: "string", description: "Verbatim request text from chat, redacted before writing." },
|
||||||
|
why_unsure: { type: "string", description: "Why this request is destructive, ambiguous, off-policy, or out of current phase scope." },
|
||||||
|
would_have: { type: "string", description: "What the bot would have done if this were approved/supported." },
|
||||||
|
ack_text: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional brief acknowledgement to send in Minecraft chat. Defaults to 'Logged for operator.'.",
|
||||||
|
},
|
||||||
|
acknowledge_in_chat: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Whether to send an acknowledgement in Minecraft chat when connected. Defaults to true.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["from", "request", "why_unsure", "would_have"],
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const EMPTY_PARAMS = {
|
const EMPTY_PARAMS = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {},
|
properties: {},
|
||||||
@@ -105,7 +164,8 @@ function loadConfig(cwd: string): BridgeConfig {
|
|||||||
version,
|
version,
|
||||||
authmePassword: parsed.MC_AUTHME_PASSWORD?.trim() || "",
|
authmePassword: parsed.MC_AUTHME_PASSWORD?.trim() || "",
|
||||||
chatRateLimitPerMinute,
|
chatRateLimitPerMinute,
|
||||||
stateDir: resolve(cwd, "state", sanitizePathSegment(`${host}_${port}`)),
|
stateDir: resolve(cwd, "state", sanitizePathSegment(host)),
|
||||||
|
legacyStateDir: resolve(cwd, "state", sanitizePathSegment(`${host}_${port}`)),
|
||||||
redactions,
|
redactions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,14 +193,45 @@ function truncate(text: string, maxLength = 800): string {
|
|||||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
|
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeJsonlField(value: string, config: BridgeConfig): string {
|
||||||
|
return redact(String(value ?? ""), config).replace(/[\r\n]+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParent(path: string) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntegerFile(path: string): number {
|
||||||
|
if (!existsSync(path)) return 0;
|
||||||
|
const parsed = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countJsonlLines(path: string): number {
|
||||||
|
if (!existsSync(path)) return 0;
|
||||||
|
return readFileSync(path, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim().length > 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
export default function mineflayerBridge(pi: ExtensionAPI) {
|
export default function mineflayerBridge(pi: ExtensionAPI) {
|
||||||
let cwd = process.cwd();
|
let cwd = process.cwd();
|
||||||
let config: BridgeConfig | undefined;
|
let config: BridgeConfig | undefined;
|
||||||
let bot: Bot | undefined;
|
let bot: Bot | undefined;
|
||||||
let connecting = false;
|
let connectionState: ConnectionState = "idle";
|
||||||
let authObservation: AuthObservation = "not-started";
|
let authObservation: AuthObservation = "not-started";
|
||||||
let authTimer: NodeJS.Timeout | undefined;
|
let authTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let outgoingChatTimestamps: number[] = [];
|
let outgoingChatTimestamps: number[] = [];
|
||||||
|
let reconnectAttemptTimestamps: number[] = [];
|
||||||
|
let recentChat: ChatEntry[] = [];
|
||||||
|
let manualDisconnectRequested = false;
|
||||||
|
let shuttingDown = false;
|
||||||
|
let reconnectPausedReason: string | undefined;
|
||||||
|
let lastDisconnectReason: string | undefined;
|
||||||
|
let lastAddressedReviewAt = 0;
|
||||||
|
let lastAmbientReviewAt = 0;
|
||||||
|
let agentBusy = false;
|
||||||
|
|
||||||
function log(event: string, detail?: unknown) {
|
function log(event: string, detail?: unknown) {
|
||||||
const suffix = detail === undefined ? "" : `: ${truncate(redact(stringifyUnknown(detail), config))}`;
|
const suffix = detail === undefined ? "" : `: ${truncate(redact(stringifyUnknown(detail), config))}`;
|
||||||
@@ -152,18 +243,52 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setConnectionState(state: ConnectionState) {
|
||||||
|
connectionState = state;
|
||||||
|
}
|
||||||
|
|
||||||
function flagPath(current: BridgeConfig): string {
|
function flagPath(current: BridgeConfig): string {
|
||||||
return resolve(current.stateDir, "joined-before.flag");
|
return resolve(current.stateDir, "joined-before.flag");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function legacyFlagPath(current: BridgeConfig): string {
|
||||||
|
return resolve(current.legacyStateDir, "joined-before.flag");
|
||||||
|
}
|
||||||
|
|
||||||
function markJoinedBefore(current: BridgeConfig) {
|
function markJoinedBefore(current: BridgeConfig) {
|
||||||
const path = flagPath(current);
|
const path = flagPath(current);
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
ensureParent(path);
|
||||||
writeFileSync(path, "joined-before\n", "utf8");
|
writeFileSync(path, "joined-before\n", "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasJoinedBefore(current: BridgeConfig): boolean {
|
function hasJoinedBefore(current: BridgeConfig): boolean {
|
||||||
return existsSync(flagPath(current));
|
return existsSync(flagPath(current)) || existsSync(legacyFlagPath(current));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escalationsPath(current: BridgeConfig): string {
|
||||||
|
return resolve(current.stateDir, "escalations.jsonl");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escalationSeenPath(current: BridgeConfig): string {
|
||||||
|
return resolve(current.stateDir, "escalations.seen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicStatePath(fileName: string): string {
|
||||||
|
return `state/<server-key>/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceEscalationCount() {
|
||||||
|
const current = ensureConfig();
|
||||||
|
const path = escalationsPath(current);
|
||||||
|
const count = countJsonlLines(path);
|
||||||
|
const seenPath = escalationSeenPath(current);
|
||||||
|
const seen = readIntegerFile(seenPath);
|
||||||
|
const pending = Math.max(0, count - seen);
|
||||||
|
if (pending > 0) {
|
||||||
|
log("escalations", `${pending} pending escalation(s) since last session`);
|
||||||
|
ensureParent(seenPath);
|
||||||
|
writeFileSync(seenPath, `${count}\n`, "utf8");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertNoEnvLeak(text: string, current: BridgeConfig) {
|
function assertNoEnvLeak(text: string, current: BridgeConfig) {
|
||||||
@@ -188,6 +313,10 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
return bot;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConnected(): boolean {
|
||||||
|
return Boolean(bot?.entity) && connectionState === "connected";
|
||||||
|
}
|
||||||
|
|
||||||
function sendChat(text: string, options: { internalAuthCommand?: boolean } = {}) {
|
function sendChat(text: string, options: { internalAuthCommand?: boolean } = {}) {
|
||||||
const current = ensureConfig();
|
const current = ensureConfig();
|
||||||
if (!options.internalAuthCommand) assertNoEnvLeak(text, current);
|
if (!options.internalAuthCommand) assertNoEnvLeak(text, current);
|
||||||
@@ -200,6 +329,11 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
authTimer = undefined;
|
authTimer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopReconnectTimer() {
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function startAuthDetection() {
|
function startAuthDetection() {
|
||||||
stopAuthTimer();
|
stopAuthTimer();
|
||||||
authObservation = "pending";
|
authObservation = "pending";
|
||||||
@@ -211,6 +345,79 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
}, 5_000);
|
}, 5_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pushRecentChat(entry: ChatEntry) {
|
||||||
|
const current = ensureConfig();
|
||||||
|
const redactedEntry: ChatEntry = {
|
||||||
|
...entry,
|
||||||
|
from: safeJsonlField(entry.from, current) || "unknown",
|
||||||
|
text: safeJsonlField(entry.text, current),
|
||||||
|
};
|
||||||
|
if (!redactedEntry.text) return;
|
||||||
|
|
||||||
|
const previous = recentChat[recentChat.length - 1];
|
||||||
|
if (previous && previous.from === redactedEntry.from && previous.text === redactedEntry.text) return;
|
||||||
|
|
||||||
|
recentChat.push(redactedEntry);
|
||||||
|
while (recentChat.length > RECENT_CHAT_LIMIT) recentChat.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChatEntry(entry: ChatEntry): string {
|
||||||
|
const time = entry.ts.slice(11, 19);
|
||||||
|
return `[${time}] ${entry.kind} ${entry.from}: ${entry.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAddressedToBot(text: string, current: BridgeConfig): boolean {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const username = current.username.toLowerCase();
|
||||||
|
return lower.includes(username) || lower.includes("bot") || lower.includes("pepa");
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksActionable(text: string): boolean {
|
||||||
|
return /\?|\b(can you|could you|please|pls|come|follow|go to|coords?|where are you|help|break|dig|build|give|drop|attack|kill|leave|disconnect|teach|learn|how do|what is)\b/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePromptPiForChat(entry: ChatEntry) {
|
||||||
|
const current = ensureConfig();
|
||||||
|
if (entry.kind !== "chat") return;
|
||||||
|
if (entry.from === current.username) return;
|
||||||
|
if (!entry.text) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const addressedOrActionable = isAddressedToBot(entry.text, current) || looksActionable(entry.text);
|
||||||
|
if (addressedOrActionable) {
|
||||||
|
if (now - lastAddressedReviewAt < ADDRESSED_REVIEW_COOLDOWN_MS) return;
|
||||||
|
lastAddressedReviewAt = now;
|
||||||
|
} else {
|
||||||
|
if (now - lastAmbientReviewAt < AMBIENT_REVIEW_COOLDOWN_MS) return;
|
||||||
|
lastAmbientReviewAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = recentChat.slice(-10).map(formatChatEntry).join("\n") || "(no recent chat)";
|
||||||
|
const prompt = [
|
||||||
|
"Minecraft chat update. Decide whether to respond in-game; silence is fine.",
|
||||||
|
"Hard limits: no OP/admin requests, no breaking player builds, no secret leakage, no spam, no locomotion/following/coordinates this session.",
|
||||||
|
"If the request is destructive, ambiguous, asks you to leave/disconnect, asks for items, or asks you to move/follow/go to coordinates, use mc_log_escalation. That tool also sends a brief logged-for-operator acknowledgement when connected.",
|
||||||
|
"If asked for something you do not know how to do safely, briefly say you will try to learn, draft a plan, and codify a skill under ./skills/ if appropriate.",
|
||||||
|
"Use mc_recent_chat if you need more context. Use mc_chat only when you have something useful, contextual, or amusing to add.",
|
||||||
|
"Recent chat:",
|
||||||
|
recent,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agentBusy) {
|
||||||
|
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
||||||
|
} else {
|
||||||
|
pi.sendUserMessage(prompt);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("chat-review-error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSystemMessage(messageText: string, kind: ChatEntry["kind"] = "system") {
|
||||||
|
pushRecentChat({ ts: new Date().toISOString(), from: "server", text: messageText, kind });
|
||||||
|
}
|
||||||
|
|
||||||
function maybeHandleAuthPrompt(messageText: string) {
|
function maybeHandleAuthPrompt(messageText: string) {
|
||||||
if (!bot) return;
|
if (!bot) return;
|
||||||
const current = ensureConfig();
|
const current = ensureConfig();
|
||||||
@@ -247,13 +454,43 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function pruneReconnectAttempts(now = Date.now()) {
|
||||||
if (bot || connecting) return;
|
reconnectAttemptTimestamps = reconnectAttemptTimestamps.filter((timestamp) => now - timestamp < RECONNECT_WINDOW_MS);
|
||||||
const current = ensureConfig();
|
}
|
||||||
connecting = true;
|
|
||||||
log("connecting", "opening Mineflayer connection to configured server");
|
|
||||||
|
|
||||||
const nextBot = mineflayer.createBot({
|
function scheduleReconnect(reason: string) {
|
||||||
|
if (manualDisconnectRequested || shuttingDown) return;
|
||||||
|
if (reconnectTimer || connectionState === "connecting" || bot) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
pruneReconnectAttempts(now);
|
||||||
|
if (reconnectAttemptTimestamps.length >= MAX_RECONNECTS_PER_WINDOW) {
|
||||||
|
reconnectPausedReason = `reconnect ceiling reached after ${MAX_RECONNECTS_PER_WINDOW} attempts in 10 minutes`;
|
||||||
|
setConnectionState("reconnect-paused");
|
||||||
|
log("reconnect-paused", reconnectPausedReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttemptTimestamps.push(now);
|
||||||
|
setConnectionState("disconnected");
|
||||||
|
log("reconnect-scheduled", `${reason}; reconnecting in ${RECONNECT_DELAY_MS / 1000}s`);
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
connect("reconnect");
|
||||||
|
}, RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(reason: "startup" | "reconnect" = "startup") {
|
||||||
|
if (bot || connectionState === "connecting") return;
|
||||||
|
if (reconnectPausedReason) return;
|
||||||
|
const current = ensureConfig();
|
||||||
|
manualDisconnectRequested = false;
|
||||||
|
setConnectionState("connecting");
|
||||||
|
log("connecting", reason === "reconnect" ? "reconnecting to configured server" : "opening Mineflayer connection to configured server");
|
||||||
|
|
||||||
|
let nextBot: Bot;
|
||||||
|
try {
|
||||||
|
nextBot = mineflayer.createBot({
|
||||||
host: current.host,
|
host: current.host,
|
||||||
port: current.port,
|
port: current.port,
|
||||||
username: current.username,
|
username: current.username,
|
||||||
@@ -261,46 +498,86 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
version: current.version,
|
version: current.version,
|
||||||
logErrors: false,
|
logErrors: false,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionState("disconnected");
|
||||||
|
log("connect-error", error);
|
||||||
|
scheduleReconnect("connect failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bot = nextBot;
|
bot = nextBot;
|
||||||
nextBot.once("login", () => {
|
nextBot.once("login", () => {
|
||||||
connecting = false;
|
setConnectionState("connected");
|
||||||
});
|
});
|
||||||
nextBot.on("spawn", () => {
|
nextBot.on("spawn", () => {
|
||||||
connecting = false;
|
setConnectionState("connected");
|
||||||
|
reconnectPausedReason = undefined;
|
||||||
log("spawn");
|
log("spawn");
|
||||||
startAuthDetection();
|
startAuthDetection();
|
||||||
});
|
});
|
||||||
|
nextBot.on("chat", (username, message) => {
|
||||||
|
const entry: ChatEntry = { ts: new Date().toISOString(), from: username, text: message, kind: "chat" };
|
||||||
|
pushRecentChat(entry);
|
||||||
|
maybePromptPiForChat(entry);
|
||||||
|
});
|
||||||
|
nextBot.on("whisper", (username, message) => {
|
||||||
|
pushRecentChat({ ts: new Date().toISOString(), from: username, text: message, kind: "whisper" });
|
||||||
|
});
|
||||||
|
nextBot.on("actionBar", (jsonMsg) => {
|
||||||
|
pushRecentChat({ ts: new Date().toISOString(), from: "server", text: jsonMsg.toString(), kind: "actionBar" });
|
||||||
|
});
|
||||||
|
nextBot.on("messagestr", (message, position) => {
|
||||||
|
const text = String(message);
|
||||||
|
maybeHandleAuthPrompt(text);
|
||||||
|
if (position !== "chat") recordSystemMessage(text, "system");
|
||||||
|
});
|
||||||
nextBot.on("message", (message) => {
|
nextBot.on("message", (message) => {
|
||||||
maybeHandleAuthPrompt(message.toString());
|
maybeHandleAuthPrompt(message.toString());
|
||||||
});
|
});
|
||||||
nextBot.on("kicked", (reason) => {
|
nextBot.on("kicked", (reason) => {
|
||||||
|
lastDisconnectReason = `kicked: ${truncate(redact(stringifyUnknown(reason), current), 200)}`;
|
||||||
log("kicked", reason);
|
log("kicked", reason);
|
||||||
});
|
});
|
||||||
nextBot.on("error", (error) => {
|
nextBot.on("error", (error) => {
|
||||||
connecting = false;
|
lastDisconnectReason = `error: ${truncate(redact(stringifyUnknown(error), current), 200)}`;
|
||||||
stopAuthTimer();
|
stopAuthTimer();
|
||||||
if (bot === nextBot) bot = undefined;
|
if (bot === nextBot) bot = undefined;
|
||||||
|
setConnectionState("disconnected");
|
||||||
try {
|
try {
|
||||||
nextBot.end("connection error");
|
nextBot.end("connection error");
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore close failures; the original error is logged below.
|
// Ignore close failures; the original error is logged below.
|
||||||
}
|
}
|
||||||
log("error", error);
|
log("error", error);
|
||||||
|
scheduleReconnect("connection error");
|
||||||
});
|
});
|
||||||
nextBot.on("end", (reason) => {
|
nextBot.on("end", (reasonText) => {
|
||||||
connecting = false;
|
|
||||||
stopAuthTimer();
|
stopAuthTimer();
|
||||||
if (bot === nextBot) bot = undefined;
|
if (bot === nextBot) bot = undefined;
|
||||||
log("end", reason);
|
const reasonString = stringifyUnknown(reasonText || lastDisconnectReason || "end");
|
||||||
|
lastDisconnectReason = reasonString;
|
||||||
|
if (manualDisconnectRequested || shuttingDown) {
|
||||||
|
setConnectionState("disconnected");
|
||||||
|
log("end", reasonText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectionState("disconnected");
|
||||||
|
log("end", reasonText);
|
||||||
|
scheduleReconnect(reasonString);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect(options: { manual?: boolean } = {}) {
|
||||||
|
if (options.manual) manualDisconnectRequested = true;
|
||||||
stopAuthTimer();
|
stopAuthTimer();
|
||||||
if (!bot) return false;
|
stopReconnectTimer();
|
||||||
|
if (!bot) {
|
||||||
|
setConnectionState("disconnected");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const currentBot = bot as Bot & { quit?: (reason?: string) => void; end?: (reason?: string) => void };
|
const currentBot = bot as Bot & { quit?: (reason?: string) => void; end?: (reason?: string) => void };
|
||||||
bot = undefined;
|
bot = undefined;
|
||||||
|
setConnectionState("disconnected");
|
||||||
if (typeof currentBot.quit === "function") {
|
if (typeof currentBot.quit === "function") {
|
||||||
currentBot.quit();
|
currentBot.quit();
|
||||||
} else if (typeof currentBot.end === "function") {
|
} else if (typeof currentBot.end === "function") {
|
||||||
@@ -309,11 +586,46 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendEscalation(input: EscalationInput): { path: string; ackSent: boolean } {
|
||||||
|
const current = ensureConfig();
|
||||||
|
const path = escalationsPath(current);
|
||||||
|
ensureParent(path);
|
||||||
|
const record = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
from: safeJsonlField(input.from, current) || "unknown",
|
||||||
|
request: safeJsonlField(input.request, current),
|
||||||
|
why_unsure: safeJsonlField(input.why_unsure, current),
|
||||||
|
would_have: safeJsonlField(input.would_have, current),
|
||||||
|
};
|
||||||
|
appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
|
||||||
|
|
||||||
|
let ackSent = false;
|
||||||
|
if (input.acknowledge_in_chat !== false && bot && connectionState === "connected") {
|
||||||
|
const ack = (input.ack_text?.trim() || "Logged for operator.").slice(0, 120);
|
||||||
|
sendChat(ack);
|
||||||
|
ackSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, ackSent };
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("agent_start", () => {
|
||||||
|
agentBusy = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", () => {
|
||||||
|
agentBusy = false;
|
||||||
|
});
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
cwd = ctx.cwd;
|
cwd = ctx.cwd;
|
||||||
|
shuttingDown = false;
|
||||||
|
manualDisconnectRequested = false;
|
||||||
|
reconnectPausedReason = undefined;
|
||||||
try {
|
try {
|
||||||
ensureConfig();
|
ensureConfig();
|
||||||
connect();
|
surfaceEscalationCount();
|
||||||
|
connect("startup");
|
||||||
if (ctx.hasUI) ctx.ui.setStatus("mineflayer", "mc: connecting");
|
if (ctx.hasUI) ctx.ui.setStatus("mineflayer", "mc: connecting");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("startup-error", error);
|
log("startup-error", error);
|
||||||
@@ -322,7 +634,8 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_shutdown", async () => {
|
pi.on("session_shutdown", async () => {
|
||||||
disconnect();
|
shuttingDown = true;
|
||||||
|
disconnect({ manual: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
@@ -332,8 +645,10 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
promptSnippet: "Send one rate-limited Minecraft chat message or slash command.",
|
promptSnippet: "Send one rate-limited Minecraft chat message or slash command.",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use mc_chat only for intentional Minecraft chat; never send secrets, .env values, passwords, API keys, or spam.",
|
"Use mc_chat only for intentional Minecraft chat; never send secrets, .env values, passwords, API keys, or spam.",
|
||||||
|
"Do not use mc_chat to request OP/admin rights, encourage griefing, or execute destructive/ambiguous chat instructions.",
|
||||||
],
|
],
|
||||||
parameters: CHAT_PARAMS,
|
parameters: CHAT_PARAMS,
|
||||||
|
executionMode: "sequential",
|
||||||
async execute(_toolCallId, params: { text: string }) {
|
async execute(_toolCallId, params: { text: string }) {
|
||||||
const text = params.text.trim();
|
const text = params.text.trim();
|
||||||
if (!text) throw new Error("mc_chat text must not be empty.");
|
if (!text) throw new Error("mc_chat text must not be empty.");
|
||||||
@@ -345,10 +660,92 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "mc_recent_chat",
|
||||||
|
label: "Minecraft Recent Chat",
|
||||||
|
description: "Return the bridge's rolling buffer of recent Minecraft chat/system lines (last 30 max), redacted for .env values.",
|
||||||
|
promptSnippet: "Read the last few Minecraft chat/system lines for conversational context.",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Use mc_recent_chat before replying if you need context for Minecraft conversation.",
|
||||||
|
"Silence is acceptable; do not reply to every mc_recent_chat line.",
|
||||||
|
],
|
||||||
|
parameters: RECENT_CHAT_PARAMS,
|
||||||
|
async execute(_toolCallId, params: { limit?: number }) {
|
||||||
|
const limit = Math.max(1, Math.min(RECENT_CHAT_LIMIT, Number(params.limit ?? RECENT_CHAT_LIMIT)));
|
||||||
|
const entries = recentChat.slice(-limit);
|
||||||
|
const text = entries.length > 0 ? entries.map(formatChatEntry).join("\n") : "No recent Minecraft chat recorded yet.";
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
details: { entries, limit },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "mc_status",
|
||||||
|
label: "Minecraft Status",
|
||||||
|
description: "Report whether the Mineflayer bot is connected, connecting, disconnected, or reconnect-paused, plus auth/reconnect/chat-buffer status.",
|
||||||
|
promptSnippet: "Check Minecraft connection, reconnect, auth, and chat-buffer status.",
|
||||||
|
parameters: EMPTY_PARAMS,
|
||||||
|
async execute() {
|
||||||
|
pruneReconnectAttempts();
|
||||||
|
const connected = isConnected();
|
||||||
|
const statusLine = [
|
||||||
|
`state=${connectionState}`,
|
||||||
|
`connected=${connected}`,
|
||||||
|
`auth=${authObservation}`,
|
||||||
|
`recent_chat=${recentChat.length}`,
|
||||||
|
`reconnects_in_10m=${reconnectAttemptTimestamps.length}/${MAX_RECONNECTS_PER_WINDOW}`,
|
||||||
|
reconnectPausedReason ? `paused=${reconnectPausedReason}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: statusLine }],
|
||||||
|
details: {
|
||||||
|
state: connectionState,
|
||||||
|
connected,
|
||||||
|
authObservation,
|
||||||
|
recentChatCount: recentChat.length,
|
||||||
|
reconnectAttemptsInWindow: reconnectAttemptTimestamps.length,
|
||||||
|
maxReconnectsPerWindow: MAX_RECONNECTS_PER_WINDOW,
|
||||||
|
reconnectWindowMs: RECONNECT_WINDOW_MS,
|
||||||
|
reconnectPausedReason,
|
||||||
|
lastDisconnectReason,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "mc_log_escalation",
|
||||||
|
label: "Minecraft Escalation Log",
|
||||||
|
description: "Append one JSONL escalation under repo-local state for destructive, ambiguous, off-policy, or phase-out-of-scope Minecraft chat requests. Sends a brief chat acknowledgement when connected unless disabled.",
|
||||||
|
promptSnippet: "Log a destructive/ambiguous/out-of-scope Minecraft request for the operator and acknowledge it briefly in chat.",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Use mc_log_escalation for requests to break blocks, alter player builds, attack players, drop/give items, leave/disconnect, or move/follow/go to coordinates during this session.",
|
||||||
|
"mc_log_escalation writes the required JSONL line and sends a brief 'logged for operator' acknowledgement when connected; do not also perform the requested action.",
|
||||||
|
],
|
||||||
|
parameters: ESCALATION_PARAMS,
|
||||||
|
executionMode: "sequential",
|
||||||
|
async execute(_toolCallId, params: EscalationInput) {
|
||||||
|
const result = appendEscalation(params);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Escalation logged to ${publicStatePath("escalations.jsonl")}${result.ackSent ? " and acknowledged in chat." : "."}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { path: publicStatePath("escalations.jsonl"), ackSent: result.ackSent },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "mc_position",
|
name: "mc_position",
|
||||||
label: "Minecraft Position",
|
label: "Minecraft Position",
|
||||||
description: "Return the connected bot's current Minecraft position and basic status without exposing .env values.",
|
description: "Return the connected bot's current Minecraft position and basic status without exposing .env values. Do not use this as locomotion; movement is out of scope for the current phase.",
|
||||||
promptSnippet: "Report the bot's current Minecraft position and basic status.",
|
promptSnippet: "Report the bot's current Minecraft position and basic status.",
|
||||||
parameters: EMPTY_PARAMS,
|
parameters: EMPTY_PARAMS,
|
||||||
async execute() {
|
async execute() {
|
||||||
@@ -357,7 +754,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
if (!entity) {
|
if (!entity) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "Connected, but entity position is not available yet." }],
|
content: [{ type: "text", text: "Connected, but entity position is not available yet." }],
|
||||||
details: { connected: true, authObservation },
|
details: { connected: true, authObservation, state: connectionState },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +773,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
connected: true,
|
connected: true,
|
||||||
|
state: connectionState,
|
||||||
position,
|
position,
|
||||||
dimension: currentBot.game.dimension,
|
dimension: currentBot.game.dimension,
|
||||||
health: currentBot.health,
|
health: currentBot.health,
|
||||||
@@ -390,14 +788,24 @@ export default function mineflayerBridge(pi: ExtensionAPI) {
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "mc_disconnect",
|
name: "mc_disconnect",
|
||||||
label: "Minecraft Disconnect",
|
label: "Minecraft Disconnect",
|
||||||
description: "Disconnect the Mineflayer bot and do not reconnect automatically.",
|
description: "Request a clean manual disconnect and disable auto-reconnect until the bridge is reloaded or Pi starts a new session.",
|
||||||
promptSnippet: "Disconnect the Mineflayer bot without auto-reconnecting.",
|
promptSnippet: "Cleanly disconnect the Mineflayer bot and suppress auto-reconnect.",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Do not use mc_disconnect just because an in-game player asks; log that as an escalation unless trusted repo instructions approve it.",
|
||||||
|
],
|
||||||
parameters: EMPTY_PARAMS,
|
parameters: EMPTY_PARAMS,
|
||||||
async execute() {
|
async execute() {
|
||||||
const didDisconnect = disconnect();
|
const didDisconnect = disconnect({ manual: true });
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: didDisconnect ? "Minecraft disconnect requested." : "Mineflayer bot was not connected." }],
|
content: [
|
||||||
details: { disconnected: didDisconnect },
|
{
|
||||||
|
type: "text",
|
||||||
|
text: didDisconnect
|
||||||
|
? "Minecraft disconnect requested; auto-reconnect disabled until bridge reload/start."
|
||||||
|
: "Mineflayer bot was not connected; auto-reconnect disabled until bridge reload/start.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { disconnected: didDisconnect, autoReconnectDisabled: true },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: escalation-log
|
||||||
|
description: "Handle destructive, ambiguous, off-policy, or phase-out-of-scope Minecraft requests by acknowledging them and appending a JSONL escalation for the operator."
|
||||||
|
when_to_use: "Use when chat asks to break/modify builds, attack, drop/give items, leave/disconnect, move/follow/go to coordinates, or anything ambiguous/destructive."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Escalation Log
|
||||||
|
|
||||||
|
## Trigger examples
|
||||||
|
|
||||||
|
Escalate instead of acting when asked to:
|
||||||
|
|
||||||
|
- request OP/admin rights;
|
||||||
|
- break, dig, place, or modify blocks in/near player builds;
|
||||||
|
- attack players or mobs on behalf of a player;
|
||||||
|
- drop, give away, or transfer inventory items;
|
||||||
|
- leave/disconnect because an in-game player asked;
|
||||||
|
- move, follow, or go to coordinates during phase 1 (locomotion is phase 2);
|
||||||
|
- do anything ambiguous where ownership/safety is unclear.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Use `mc_log_escalation({from, request, why_unsure, would_have})`.
|
||||||
|
2. The bridge appends one JSON line under `state/<server-key>/escalations.jsonl` and sends a brief in-chat acknowledgement when connected.
|
||||||
|
3. Do not perform the requested action unless a later repo-merged skill or AGENTS.md update explicitly allows it.
|
||||||
|
|
||||||
|
Required JSONL fields are:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts":"<ISO timestamp>","from":"<requester nick>","request":"<verbatim text>","why_unsure":"<reasoning>","would_have":"<what would have happened if approved>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge redacts `.env` values before writing.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: phase-2-locomotion-pending
|
||||||
|
description: "Pending skill stub for future safe movement/follow/coordinate requests. Use to remember that locomotion is intentionally out of scope during phase 1 and must be escalated."
|
||||||
|
when_to_use: "Use when someone asks the bot to come somewhere, follow a player, or go to coordinates before phase 2 is implemented."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 Locomotion Pending
|
||||||
|
|
||||||
|
Status: pending.
|
||||||
|
|
||||||
|
Locomotion is intentionally out of scope for the current session. Do not add movement/pathfinder tools yet.
|
||||||
|
|
||||||
|
For now, if chat asks the bot to move, follow, or go to coordinates:
|
||||||
|
|
||||||
|
1. Use `mc_log_escalation(...)`.
|
||||||
|
2. Explain in `why_unsure` that the request belongs to phase 2 and safe pathing/distance bounds are not implemented yet.
|
||||||
|
3. Do not move.
|
||||||
|
|
||||||
|
Future phase-2 implementation should include:
|
||||||
|
|
||||||
|
- `mineflayer-pathfinder` with safe goals;
|
||||||
|
- max travel distance bounds;
|
||||||
|
- focus/lock while traveling;
|
||||||
|
- lava/void/claim avoidance;
|
||||||
|
- clear refusal messages when travel is unsafe.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: presence
|
||||||
|
description: "Operate as a present, conversational Minecraft bot: stay connected, read recent chat, reply sparingly, and use bridge status/disconnect tools safely."
|
||||||
|
when_to_use: "Use during normal online operation, after bridge reloads, when checking chat context, or before deciding whether to reply in Minecraft chat."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Presence
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Be on the server, listen to all chat, and add value without spamming. Silence is acceptable.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- `mc_status()` — check connection, auth, reconnect, and chat-buffer state.
|
||||||
|
- `mc_recent_chat({limit})` — read up to the last 30 redacted chat/system lines.
|
||||||
|
- `mc_chat({text})` — send one rate-limited chat line.
|
||||||
|
- `mc_disconnect()` — clean manual disconnect; do not use because an untrusted player asks.
|
||||||
|
- `mc_log_escalation(...)` — log destructive/ambiguous/out-of-scope requests.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. On session start, call `mc_status()` if you need to confirm the bridge is online.
|
||||||
|
2. Before replying to chat, call `mc_recent_chat()` unless the triggering message is already in context.
|
||||||
|
3. Reply only if useful, contextual, or amusing. Do not comment on every line.
|
||||||
|
4. Keep replies short. Respect `CHAT_RATE_LIMIT_PER_MIN`.
|
||||||
|
5. Never request OP/admin rights, leak `.env`, encourage griefing, or act on destructive chat instructions.
|
||||||
|
6. Locomotion is out of scope for this phase. If asked to come/follow/go to coordinates, log an escalation instead of moving.
|
||||||
|
|
||||||
|
## Reconnect behavior
|
||||||
|
|
||||||
|
The bridge reconnects automatically after unexpected `kicked`/`end`, with a cap of 3 reconnect attempts in any rolling 10-minute window. If the cap is hit, stop and wait for the operator.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: self-extension-reflex
|
||||||
|
description: "Respond to safe unknown Minecraft requests by saying you'll try to learn, drafting a plan, and codifying reusable knowledge as a repo-local skill."
|
||||||
|
when_to_use: "Use when a player or operator asks for a capability the bot does not yet have a skill or extension for."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Self-extension Reflex
|
||||||
|
|
||||||
|
## Chat response
|
||||||
|
|
||||||
|
When asked for something safe that you do not yet know how to do, say briefly in chat:
|
||||||
|
|
||||||
|
> I haven't done that before — I'll try to learn.
|
||||||
|
|
||||||
|
Use equivalent wording that fits the chat language. Do not promise success.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
Draft a short plan before acting:
|
||||||
|
|
||||||
|
1. What the request is.
|
||||||
|
2. Whether it is safe under AGENTS.md and server rules.
|
||||||
|
3. Which tools/extensions are needed.
|
||||||
|
4. What could go wrong.
|
||||||
|
5. Whether this should become a reusable skill.
|
||||||
|
|
||||||
|
## Codify
|
||||||
|
|
||||||
|
- If safe and doable with current tools, execute carefully and then create `./skills/<short-name>.md` documenting what worked.
|
||||||
|
- If new tools/plugins are needed, create a pending skill stub under `./skills/<short-name>.md` with the plan and mark it as awaiting operator/tooling work.
|
||||||
|
- Commit repo changes with a clear message. Do not push without human confirmation.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Do not use this reflex to bypass hard rules. Destructive, ambiguous, OP/admin, PvP/griefing, item-give/drop, leave/disconnect, and current phase-2 locomotion requests are escalations, not learning tasks.
|
||||||
Reference in New Issue
Block a user