diff --git a/.env.example b/.env.example index f2e6b07..aa73d4d 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,10 @@ TICK_INTERVAL_SECONDS=60 # trigger around 20/min. CHAT_RATE_LIMIT_PER_MIN=15 +# Guarded travel refuses coordinate requests farther than this many blocks from +# the bot's current position. +MAX_TRAVEL_BLOCKS=500 + # --- Trusted operators (optional) --------------------------------------------- # Comma-separated MC nicknames whose chat messages the bot treats as trusted: # scope checks are relaxed (the bot will *try* to do new things instead of diff --git a/README.md b/README.md index 7ce981a..d95b9ca 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ 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 1 — Presence** implemented and operator trust wired: the bridge stays online with bounded reconnects, keeps a rolling chat buffer, exposes status/recent-chat/operator/escalation tools, applies `OPERATOR_USERNAMES` as scope-only trust, and can prompt the Pi loop to reply sparingly. Phase 5 self-extension is documented and in progress; Phase 6 escalation logging is implemented. +🌳 **Phase 1 — Presence** implemented and operator trust wired: the bridge stays online with bounded reconnects, keeps a rolling chat buffer, exposes status/recent-chat/operator/escalation tools, applies `OPERATOR_USERNAMES` as scope-only trust, and can prompt the Pi loop to reply sparingly. -🌿 **Phase 2 — Locomotion with guard rails** in progress (operator task: build a 5×5 pyramid; `mineflayer-pathfinder` installed). +🌿 **Phase 2 — Locomotion/build rails** in progress: `mineflayer-pathfinder` is wired with guarded `mc_goto`, plus `mc_build_pyramid_5x5` for the operator-approved empty-site pyramid task. Dynamic following is still pending. Phase 5 self-extension is documented and in progress; Phase 6 escalation logging is implemented. 🌱 **Phase 3 — Goal-driven autonomy** seeded: [`docs/memory-model.md`](./docs/memory-model.md) defines shared-knowledge vs personal-memory; per-server `goal.md` / `plan.md` / `current-task.json` / `diary/` shape autonomous behaviour. Kickoff via [`prompts/live-your-life.md`](./prompts/live-your-life.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 4590262..e38a74e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,9 +21,9 @@ The bot is **on the server, all the time** (except for a clean human-issued disc Stretch: short-term chat memory (last N lines) so it can reference what was just said. -## Phase 2 — Locomotion with guard rails 🌱 +## Phase 2 — Locomotion with guard rails 🌿 -The bot can be **summoned** by chat: "come to 100 64 -200", "follow me", "go to spawn". With three hard rails: +The bot can be **summoned** by trusted/sanctioned coordinate requests via `mc_goto`; dynamic follow is still pending. Goal: "come to 100 64 -200", "follow me", "go to spawn" with three hard rails: - **Distance bound.** Refuse trips longer than `MAX_TRAVEL_BLOCKS` (e.g. 500 blocks straight-line) from current position. Politely explain why. - **Focus.** While moving toward a target, ignore competing summons. Reply once with "currently on my way to X, will be free in ~N seconds." Don't context-switch mid-trip. diff --git a/extensions/mineflayer-bridge.ts b/extensions/mineflayer-bridge.ts index 9e269d7..288fa2d 100644 --- a/extensions/mineflayer-bridge.ts +++ b/extensions/mineflayer-bridge.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createRequire } from "node:module"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { Bot } from "mineflayer"; @@ -8,6 +8,15 @@ import type { Bot } from "mineflayer"; const require = createRequire(import.meta.url); const dotenv = require("dotenv") as typeof import("dotenv"); const mineflayer = require("mineflayer") as typeof import("mineflayer"); +const pathfinderModule = require("mineflayer-pathfinder") as { + pathfinder: (bot: Bot) => void; + Movements: new (bot: Bot) => any; + goals: { + GoalNear: new (x: number, y: number, z: number, range: number) => any; + GoalPlaceBlock: new (pos: any, world: any, options?: Record) => any; + }; +}; +const Vec3 = require("vec3").Vec3 as new (x: number, y: number, z: number) => any; type AuthMode = "offline" | "microsoft"; type AuthObservation = @@ -30,6 +39,7 @@ interface BridgeConfig { authmePassword: string; operatorUsernames: string[]; chatRateLimitPerMinute: number; + maxTravelBlocks: number; stateDir: string; legacyStateDir: string; redactions: string[]; @@ -53,12 +63,66 @@ interface EscalationInput { acknowledge_in_chat?: boolean; } +type WorldTaskKind = "goto" | "build"; + +interface ActiveWorldTask { + id: string; + kind: WorldTaskKind; + label: string; + target?: { x: number; y: number; z: number }; + startedAt: number; + lastBusyChatAt?: number; +} + +interface GotoInput { + x: number; + y: number; + z: number; + range?: number; + dry_run?: boolean; +} + +interface BuildPyramidInput { + x: number; + y: number; + z: number; + material?: string; + dry_run?: boolean; +} + +type MemoryAction = "set_current_task" | "clear_current_task" | "append_diary" | "register_location"; + +interface MemoryInput { + action: MemoryAction; + task?: string; + kind?: string; + text?: string; + name?: string; + x?: number; + y?: number; + z?: number; + dimension?: string; + notes?: string; +} + 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 BUSY_CHAT_COOLDOWN_MS = 15_000; +const DEFAULT_MAX_TRAVEL_BLOCKS = 500; +const MIN_TRAVEL_RANGE = 1; +const MAX_TRAVEL_RANGE = 5; +const PATH_PREVIEW_TIMEOUT_MS = 8_000; +const MIN_WORLD_TASK_TIMEOUT_MS = 30_000; +const MAX_WORLD_TASK_TIMEOUT_MS = 180_000; +const PYRAMID_BASE_SIZE = 5; +const PYRAMID_BLOCK_COUNT = 35; +const AUTONOMY_IDLE_MS = 7 * 60_000; +const AUTONOMY_TICK_MS = 60_000; +const MAX_DIARY_ENTRY_LENGTH = 240; const CHAT_PARAMS = { type: "object", @@ -124,12 +188,84 @@ const EMPTY_PARAMS = { additionalProperties: false, } as const; +const GOTO_PARAMS = { + type: "object", + properties: { + x: { type: "number", description: "Target X coordinate." }, + y: { type: "number", description: "Target Y coordinate." }, + z: { type: "number", description: "Target Z coordinate." }, + range: { + type: "number", + minimum: MIN_TRAVEL_RANGE, + maximum: MAX_TRAVEL_RANGE, + description: "How close is close enough, in blocks. Defaults to 1.5.", + }, + dry_run: { + type: "boolean", + description: "If true, preview safety/path checks without moving.", + }, + }, + required: ["x", "y", "z"], + additionalProperties: false, +} as const; + +const BUILD_PYRAMID_PARAMS = { + type: "object", + properties: { + x: { type: "number", description: "Approximate center X coordinate for the 5x5 pyramid." }, + y: { type: "number", description: "Feet/ground Y coordinate; bottom layer is placed at this Y." }, + z: { type: "number", description: "Approximate center Z coordinate for the 5x5 pyramid." }, + material: { + type: "string", + description: "Optional inventory block item name to use, e.g. dirt or cobblestone. If omitted, a harmless available material is chosen.", + }, + dry_run: { + type: "boolean", + description: "If true, check distance/path/inventory without moving or placing blocks.", + }, + }, + required: ["x", "y", "z"], + additionalProperties: false, +} as const; + +const MEMORY_PARAMS = { + type: "object", + properties: { + action: { + type: "string", + enum: ["set_current_task", "clear_current_task", "append_diary", "register_location"], + description: "Memory operation to perform.", + }, + task: { type: "string", description: "Short current-task summary for set_current_task." }, + kind: { type: "string", description: "Optional task/location kind, e.g. scout, build, base, farm." }, + text: { type: "string", description: "Concise diary text for append_diary." }, + name: { type: "string", description: "Location name for register_location." }, + x: { type: "number", description: "X coordinate for set_current_task/register_location." }, + y: { type: "number", description: "Y coordinate for set_current_task/register_location." }, + z: { type: "number", description: "Z coordinate for set_current_task/register_location." }, + dimension: { type: "string", description: "Optional Minecraft dimension for a registered location." }, + notes: { type: "string", description: "Optional concise notes; never include secrets." }, + }, + required: ["action"], + additionalProperties: false, +} as const; + function required(parsed: Record, key: string): string { const value = parsed[key]?.trim(); if (!value) throw new Error(`Missing required .env key: ${key}`); return value; } +function optionalPositiveInteger(parsed: Record, key: string, fallback: number): number { + const raw = parsed[key]?.trim(); + if (!raw) return fallback; + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${key} must be a positive integer.`); + } + return value; +} + function sanitizePathSegment(value: string): string { const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); return cleaned.slice(0, 128) || "server"; @@ -161,11 +297,8 @@ function loadConfig(cwd: string): BridgeConfig { const versionRaw = required(parsed, "MC_VERSION"); const version = versionRaw.toLowerCase() === "auto" ? false : versionRaw; - const chatLimitRaw = parsed.CHAT_RATE_LIMIT_PER_MIN?.trim() || "15"; - const chatRateLimitPerMinute = Number.parseInt(chatLimitRaw, 10); - if (!Number.isInteger(chatRateLimitPerMinute) || chatRateLimitPerMinute < 1) { - throw new Error("CHAT_RATE_LIMIT_PER_MIN must be a positive integer."); - } + const chatRateLimitPerMinute = optionalPositiveInteger(parsed, "CHAT_RATE_LIMIT_PER_MIN", 15); + const maxTravelBlocks = optionalPositiveInteger(parsed, "MAX_TRAVEL_BLOCKS", DEFAULT_MAX_TRAVEL_BLOCKS); const operatorUsernames = (parsed.OPERATOR_USERNAMES ?? "") .split(",") @@ -186,6 +319,7 @@ function loadConfig(cwd: string): BridgeConfig { authmePassword: parsed.MC_AUTHME_PASSWORD?.trim() || "", operatorUsernames, chatRateLimitPerMinute, + maxTravelBlocks, stateDir: resolve(cwd, "state", sanitizePathSegment(host)), legacyStateDir: resolve(cwd, "state", sanitizePathSegment(`${host}_${port}`)), redactions, @@ -254,6 +388,11 @@ export default function mineflayerBridge(pi: ExtensionAPI) { let lastAddressedReviewAt = 0; let lastAmbientReviewAt = 0; let agentBusy = false; + let activeWorldTask: ActiveWorldTask | undefined; + let autonomyTimer: ReturnType | undefined; + let lastHumanChatAt = Date.now(); + let lastAutonomyPromptAt = 0; + let startupMemoryReviewed = false; function log(event: string, detail?: unknown) { const suffix = detail === undefined ? "" : `: ${truncate(redact(stringifyUnknown(detail), config))}`; @@ -299,6 +438,191 @@ export default function mineflayerBridge(pi: ExtensionAPI) { return `state//${fileName}`; } + function memoryPath(current: BridgeConfig, fileName: string): string { + return resolve(current.stateDir, fileName); + } + + function currentTaskPath(current: BridgeConfig): string { + return memoryPath(current, "current-task.json"); + } + + function planPath(current: BridgeConfig): string { + return memoryPath(current, "plan.md"); + } + + function goalPath(current: BridgeConfig): string { + return memoryPath(current, "goal.md"); + } + + function locationsPath(current: BridgeConfig): string { + return memoryPath(current, "locations.json"); + } + + function diaryDir(current: BridgeConfig): string { + return memoryPath(current, "diary"); + } + + function diaryPath(current: BridgeConfig, date = new Date()): string { + return resolve(diaryDir(current), `${date.toISOString().slice(0, 10)}.md`); + } + + function migrateLegacyMemory(current: BridgeConfig) { + if (current.legacyStateDir === current.stateDir || !existsSync(current.legacyStateDir)) return; + mkdirSync(current.stateDir, { recursive: true }); + for (const fileName of [ + "goal.md", + "plan.md", + "current-task.json", + "locations.json", + "inventory-log.jsonl", + "escalations.jsonl", + "escalations.seen", + ]) { + const source = resolve(current.legacyStateDir, fileName); + const target = resolve(current.stateDir, fileName); + if (!existsSync(source) || existsSync(target)) continue; + ensureParent(target); + cpSync(source, target); + } + + const sourceDiaryDir = resolve(current.legacyStateDir, "diary"); + const targetDiaryDir = diaryDir(current); + if (existsSync(sourceDiaryDir) && !existsSync(targetDiaryDir)) { + ensureParent(targetDiaryDir); + cpSync(sourceDiaryDir, targetDiaryDir, { recursive: true }); + } + } + + function ensureMemoryLayout(current: BridgeConfig = ensureConfig()) { + migrateLegacyMemory(current); + mkdirSync(current.stateDir, { recursive: true }); + mkdirSync(diaryDir(current), { recursive: true }); + if (!existsSync(currentTaskPath(current))) writeFileSync(currentTaskPath(current), "{}\n", "utf8"); + if (!existsSync(locationsPath(current))) writeFileSync(locationsPath(current), `${JSON.stringify({ locations: [] }, null, 2)}\n`, "utf8"); + } + + function readMemoryText(current: BridgeConfig, path: string, maxLength: number): string { + if (!existsSync(path)) return ""; + return truncate(redact(readFileSync(path, "utf8"), current), maxLength); + } + + function readGoalText(current: BridgeConfig): string { + ensureMemoryLayout(current); + return readMemoryText(current, goalPath(current), 1_800); + } + + function readPlanText(current: BridgeConfig): string { + ensureMemoryLayout(current); + return readMemoryText(current, planPath(current), 2_400); + } + + function sanitizeMemoryValue(value: unknown, current: BridgeConfig): unknown { + if (typeof value === "string") return safeJsonlField(value, current); + if (Array.isArray(value)) return value.map((item) => sanitizeMemoryValue(item, current)); + if (value && typeof value === "object") { + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + const safeKey = safeJsonlField(key, current) || "field"; + result[safeKey] = sanitizeMemoryValue(nested, current); + } + return result; + } + return value; + } + + function readCurrentTask(current: BridgeConfig): Record | undefined { + ensureMemoryLayout(current); + const path = currentTaskPath(current); + const raw = existsSync(path) ? readFileSync(path, "utf8").trim() : ""; + if (!raw || raw === "{}" || raw === "null") return undefined; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const task = parsed as Record; + if (Object.keys(task).length === 0 || task.status === "cleared" || task.status === "done") return undefined; + return task; + } catch { + return { status: "blocked", summary: "unparseable current-task.json", raw: truncate(redact(raw, current), 200) }; + } + } + + function summarizeCurrentTask(task: Record | undefined, current: BridgeConfig): string { + if (!task) return "empty"; + const summary = typeof task.summary === "string" + ? task.summary + : typeof task.task === "string" + ? task.task + : typeof task.kind === "string" + ? task.kind + : "unnamed task"; + return truncate(safeJsonlField(summary, current) || "unnamed task", 160); + } + + function writeCurrentTaskRecord(current: BridgeConfig, record: Record) { + ensureMemoryLayout(current); + const sanitized = sanitizeMemoryValue(record, current) as Record; + writeFileSync(currentTaskPath(current), `${JSON.stringify(sanitized, null, 2)}\n`, "utf8"); + } + + function clearCurrentTask(current: BridgeConfig) { + ensureMemoryLayout(current); + writeFileSync(currentTaskPath(current), "{}\n", "utf8"); + } + + function appendDiary(current: BridgeConfig, entry: string) { + ensureMemoryLayout(current); + const safe = safeJsonlField(entry, current).slice(0, MAX_DIARY_ENTRY_LENGTH); + if (!safe) return; + const now = new Date(); + const path = diaryPath(current, now); + ensureParent(path); + appendFileSync(path, `${now.toISOString().slice(11, 16)} ${safe}\n`, "utf8"); + } + + function markCurrentTaskBlocked(current: BridgeConfig, summary: string, error: unknown) { + const existing = readCurrentTask(current) ?? { summary }; + writeCurrentTaskRecord(current, { + ...existing, + status: "blocked", + updatedAt: new Date().toISOString(), + blocker: truncate(redact(stringifyUnknown(error), current), 200), + }); + } + + function registerMemoryLocation(current: BridgeConfig, input: MemoryInput) { + ensureMemoryLayout(current); + const name = safeJsonlField(input.name ?? "", current); + if (!name) throw new Error("register_location requires a non-empty name."); + const location = { + name, + kind: input.kind ? safeJsonlField(input.kind, current) : undefined, + x: finiteNumber(input.x, "x"), + y: finiteNumber(input.y, "y"), + z: finiteNumber(input.z, "z"), + dimension: input.dimension ? safeJsonlField(input.dimension, current) : (bot as any)?.game?.dimension, + notes: input.notes ? safeJsonlField(input.notes, current) : undefined, + updatedAt: new Date().toISOString(), + }; + + const path = locationsPath(current); + let locations: Array> = []; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (Array.isArray(parsed)) locations = parsed as Array>; + else if (parsed && typeof parsed === "object" && Array.isArray((parsed as any).locations)) { + locations = (parsed as any).locations as Array>; + } + } catch { + locations = []; + } + + const index = locations.findIndex((candidate) => candidate?.name === location.name); + if (index >= 0) locations[index] = location; + else locations.push(location); + writeFileSync(path, `${JSON.stringify({ locations }, null, 2)}\n`, "utf8"); + return location; + } + function operatorTrustWarningPath(current: BridgeConfig): string { return resolve(current.stateDir, "operator-trust.warning.flag"); } @@ -398,6 +722,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { if (authObservation === "pending") { authObservation = "none-detected"; log("auth", "no in-game auth prompt detected within 5s"); + completeStartupMemoryReview("auth-none-detected"); } }, 5_000); } @@ -484,15 +809,92 @@ export default function mineflayerBridge(pi: ExtensionAPI) { } } + function sendAutonomyPrompt(trigger: "resume" | "idle-tick") { + const current = ensureConfig(); + const task = readCurrentTask(current); + const taskSummary = summarizeCurrentTask(task, current); + const recent = recentChat.slice(-10).map(formatChatEntry).join("\n") || "(no recent chat)"; + const prompt = [ + trigger === "resume" + ? "Resume-on-restart trigger from the Mineflayer bridge. Apply AGENTS.md Operating principle #5 priority order." + : "Autonomous idle tick from the Mineflayer bridge. Chat has been quiet; apply AGENTS.md Operating principle #5 priority order.", + "First re-check recent chat: if a human message deserves a reply, handle that before any world action.", + "If there is a non-empty current-task, resume it. Otherwise pick the next unchecked plan.md milestone and make one concrete safe move.", + "Before any meaningful world action, set current-task.json (use mc_memory if available, otherwise file tools). Clear it and append a concise diary line on completion; leave a blocker if safety/tooling fails.", + "Hard safety remains absolute: no OP/admin, no other players' builds/chests/claims, no PvP/griefing, no .env leakage, no nether/end for now.", + `Active world task: ${formatActiveTask() ?? "none"}`, + `Current task: ${taskSummary}`, + "Plan.md (redacted/truncated):", + readPlanText(current) || "(missing)", + "Goal.md (redacted/truncated):", + readGoalText(current) || "(missing)", + "Recent chat:", + recent, + ].join("\n"); + lastAutonomyPromptAt = Date.now(); + sendUserMessageForChat(prompt); + } + + function completeStartupMemoryReview(reason: string) { + if (startupMemoryReviewed) return; + startupMemoryReviewed = true; + const current = ensureConfig(); + ensureMemoryLayout(current); + const task = readCurrentTask(current); + if (task) { + const summary = summarizeCurrentTask(task, current); + appendDiary(current, `resuming: ${summary}`); + log("memory-resume", `${reason}; ${summary}`); + sendAutonomyPrompt("resume"); + return; + } + + appendDiary(current, "starting fresh session"); + const plan = readPlanText(current); + log("memory-start", plan ? `${reason}; plan available` : `${reason}; plan missing`); + } + + function maybePromptAutonomy() { + const current = ensureConfig(); + if (!isConnected()) return; + if (authObservation === "not-started" || authObservation === "pending") return; + if (authObservation === "register-prompt-password-missing" || authObservation === "login-prompt-password-missing") return; + if (agentBusy || activeWorldTask) return; + + const now = Date.now(); + if (now - lastHumanChatAt < AUTONOMY_IDLE_MS) return; + if (now - lastAutonomyPromptAt < AUTONOMY_IDLE_MS) return; + + ensureMemoryLayout(current); + sendAutonomyPrompt("idle-tick"); + } + + function startAutonomyTimer() { + stopAutonomyTimer(); + autonomyTimer = setInterval(() => { + try { + maybePromptAutonomy(); + } catch (error) { + log("autonomy-error", error); + } + }, AUTONOMY_TICK_MS); + (autonomyTimer as any).unref?.(); + } + + function stopAutonomyTimer() { + if (autonomyTimer) clearInterval(autonomyTimer); + autonomyTimer = undefined; + } + function queueOperatorLearning(rawFrom: string, rawText: string, entry: ChatEntry) { const recent = recentChat.slice(-10).map(formatChatEntry).join("\n") || "(no recent chat)"; const prompt = [ "Scope-trusted Minecraft operator request arrived. Apply AGENTS.md principle #4: I'll try to learn.", - "The bridge already acknowledged in chat, so do not duplicate the acknowledgement unless you need one short follow-up.", + "The bridge may already have acknowledged in chat, so do not duplicate the acknowledgement unless you need one short follow-up.", "Do NOT log a scope escalation solely because the request is outside the current roadmap phase; this sender is scope-trusted.", "Still obey hard safety rules. If you discover a safety issue, use mc_log_escalation with classification=safety.", - "If the task requires missing tools, draft or update a repo-local skill under ./skills/ with concrete next steps and tell chat the specific blocker briefly.", - "For locomotion/building requests, prefer drafting the guarded skill plan unless safe movement/build tools already exist.", + "If safe movement/build tools are active, consider mc_goto or mc_build_pyramid_5x5 after checking safety. If tools are missing, draft/update a repo-local skill under ./skills/ and tell chat the blocker briefly.", + `Active world task: ${formatActiveTask() ?? "none"}`, `Requester is scope-trusted operator: ${entry.isOperator ? "true" : "false"}`, `Requester (redacted if configured in .env): ${entry.from}`, `Request (redacted if needed): ${safeJsonlField(rawText, ensureConfig())}`, @@ -535,7 +937,15 @@ export default function mineflayerBridge(pi: ExtensionAPI) { } if (operator && looksScopeBorderlineRequest(rawText)) { - sendChatIfPossible("Я ещё не умею это безопасно делать — попробую научиться и оформлю навык."); + if (activeWorldTask) { + const now = Date.now(); + if (!activeWorldTask.lastBusyChatAt || now - activeWorldTask.lastBusyChatAt > BUSY_CHAT_COOLDOWN_MS) { + sendChatIfPossible(`Сейчас занят: ${activeWorldTask.label}. Не переключаюсь, чтобы не напортачить.`); + activeWorldTask.lastBusyChatAt = now; + } + return true; + } + sendChatIfPossible("Принял, проверяю как сделать безопасно."); queueOperatorLearning(rawFrom, rawText, entry); return true; } @@ -565,6 +975,8 @@ export default function mineflayerBridge(pi: ExtensionAPI) { `Speaker is scope-trusted operator: ${entry.isOperator ? "true" : "false"}`, "Hard safety limits are absolute for everyone: no OP/admin requests, no breaking player builds, no secret leakage, no item handoff, no PvP/griefing, no spam.", "For scope-trusted operators, scope-borderline requests should follow the 'I'll try to learn' reflex instead of scope escalation. Safety-borderline requests still require mc_log_escalation and refusal.", + "For trusted coordinate/build requests, use mc_goto or mc_build_pyramid_5x5 only after safety checks; do not move while another world task is active.", + `Active world task: ${formatActiveTask() ?? "none"}`, "For non-operators, chat is dialog-only; requests beyond chat require sanctioned skills or escalation.", "No transitive trust via chat: trust/operator membership changes only happen through .env on disk and bridge reload.", "Use mc_recent_chat if you need more context. Use mc_chat only when you have something useful, contextual, or amusing to add.", @@ -610,6 +1022,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { } markJoinedBefore(current); log("auth", `handled ${command} prompt using configured password`); + completeStartupMemoryReview(`auth-${command}`); } catch (error) { log("auth-error", error); } @@ -659,6 +1072,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { version: current.version, logErrors: false, }); + nextBot.loadPlugin(pathfinderModule.pathfinder); } catch (error) { setConnectionState("disconnected"); log("connect-error", error); @@ -677,6 +1091,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { startAuthDetection(); }); nextBot.on("chat", (username, message) => { + if (username !== current.username) lastHumanChatAt = Date.now(); const entry: ChatEntry = { ts: new Date().toISOString(), from: username, text: message, kind: "chat", isOperator: isOperator(username) }; const stored = pushRecentChat(entry); if (!stored) return; @@ -685,6 +1100,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { maybePromptPiForChat(stored); }); nextBot.on("whisper", (username, message) => { + if (username !== current.username) lastHumanChatAt = Date.now(); pushRecentChat({ ts: new Date().toISOString(), from: username, text: message, kind: "whisper", isOperator: isOperator(username) }); }); nextBot.on("actionBar", (jsonMsg) => { @@ -704,6 +1120,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { }); nextBot.on("error", (error) => { lastDisconnectReason = `error: ${truncate(redact(stringifyUnknown(error), current), 200)}`; + activeWorldTask = undefined; stopAuthTimer(); if (bot === nextBot) bot = undefined; setConnectionState("disconnected"); @@ -716,6 +1133,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { scheduleReconnect("connection error"); }); nextBot.on("end", (reasonText) => { + activeWorldTask = undefined; stopAuthTimer(); if (bot === nextBot) bot = undefined; const reasonString = stringifyUnknown(reasonText || lastDisconnectReason || "end"); @@ -735,6 +1153,8 @@ export default function mineflayerBridge(pi: ExtensionAPI) { if (options.manual) manualDisconnectRequested = true; stopAuthTimer(); stopReconnectTimer(); + stopPathfinderIfPossible(); + activeWorldTask = undefined; if (!bot) { setConnectionState("disconnected"); return false; @@ -774,6 +1194,386 @@ export default function mineflayerBridge(pi: ExtensionAPI) { return { path, ackSent }; } + function activePathfinderBot(): Bot & { pathfinder: any; registry: any; inventory: any; world: any } { + const currentBot = activeBot() as Bot & { pathfinder?: any; registry?: any; inventory?: any; world?: any }; + if (!currentBot.entity) throw new Error("Mineflayer bot is connected, but entity position is not available yet."); + if (!currentBot.pathfinder) throw new Error("Pathfinder plugin is not loaded; reload the Mineflayer bridge."); + return currentBot as Bot & { pathfinder: any; registry: any; inventory: any; world: any }; + } + + function stopPathfinderIfPossible() { + const currentBot = bot as (Bot & { pathfinder?: any; clearControlStates?: () => void }) | undefined; + try { + currentBot?.pathfinder?.setGoal?.(null); + currentBot?.pathfinder?.stop?.(); + currentBot?.clearControlStates?.(); + } catch (error) { + log("pathfinder-stop-error", error); + } + } + + function beginWorldTask(kind: WorldTaskKind, label: string, target?: { x: number; y: number; z: number }): string { + if (activeWorldTask) { + throw new Error(`Already busy with ${activeWorldTask.label}; finish or stop that task before starting another world task.`); + } + const id = `${kind}-${Date.now()}`; + activeWorldTask = { id, kind, label, target, startedAt: Date.now() }; + return id; + } + + function finishWorldTask(id: string) { + if (activeWorldTask?.id === id) activeWorldTask = undefined; + } + + function formatActiveTask(): string | undefined { + if (!activeWorldTask) return undefined; + const ageSeconds = Math.max(1, Math.round((Date.now() - activeWorldTask.startedAt) / 1000)); + const target = activeWorldTask.target + ? ` target=${activeWorldTask.target.x},${activeWorldTask.target.y},${activeWorldTask.target.z}` + : ""; + return `${activeWorldTask.kind}:${activeWorldTask.label}${target}; age=${ageSeconds}s`; + } + + function finiteNumber(value: unknown, label: string): number { + const numberValue = Number(value); + if (!Number.isFinite(numberValue)) throw new Error(`${label} must be a finite number.`); + return numberValue; + } + + function normalizeRange(value: unknown): number { + const range = value === undefined ? 1.5 : finiteNumber(value, "range"); + if (range < MIN_TRAVEL_RANGE || range > MAX_TRAVEL_RANGE) { + throw new Error(`range must be between ${MIN_TRAVEL_RANGE} and ${MAX_TRAVEL_RANGE} blocks.`); + } + return range; + } + + function distance3d(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + const dz = a.z - b.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + function currentPosition(currentBot: Bot): { x: number; y: number; z: number } { + const position = currentBot.entity?.position; + if (!position) throw new Error("Bot entity position is not available yet."); + return { x: position.x, y: position.y, z: position.z }; + } + + function blockKey(pos: { x: number; y: number; z: number }): string { + return `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`; + } + + function addAvoidBlockName(currentBot: Bot & { registry: any }, set: Set, name: string) { + const id = currentBot.registry?.blocksByName?.[name]?.id; + if (typeof id === "number") set.add(id); + } + + function createSafeMovements(currentBot: Bot & { registry: any; pathfinder: any }) { + const movements = new pathfinderModule.Movements(currentBot); + movements.canDig = false; + movements.allow1by1towers = false; + movements.allowParkour = false; + movements.allowSprinting = true; + movements.maxDropDown = 2; + movements.infiniteLiquidDropdownDistance = false; + movements.dontCreateFlow = true; + movements.dontMineUnderFallingBlock = true; + movements.scafoldingBlocks = []; + movements.blocksToAvoid = new Set(movements.blocksToAvoid ?? []); + movements.liquids = new Set(movements.liquids ?? []); + for (const name of [ + "lava", + "water", + "fire", + "soul_fire", + "magma_block", + "cactus", + "campfire", + "soul_campfire", + "sweet_berry_bush", + "powder_snow", + "cobweb", + ]) { + addAvoidBlockName(currentBot, movements.blocksToAvoid, name); + if (name === "lava" || name === "water") addAvoidBlockName(currentBot, movements.liquids, name); + } + return movements; + } + + function assertHealthyEnough(currentBot: Bot) { + if (typeof currentBot.health === "number" && currentBot.health <= 6) { + throw new Error(`Refusing world task: health is too low (${currentBot.health}).`); + } + if (typeof currentBot.food === "number" && currentBot.food <= 3) { + throw new Error(`Refusing world task: food is too low (${currentBot.food}).`); + } + } + + function previewSafePath( + currentBot: Bot & { pathfinder: any }, + movements: any, + goal: any, + maxSearchBlocks: number, + ) { + currentBot.pathfinder.setMovements(movements); + currentBot.pathfinder.thinkTimeout = PATH_PREVIEW_TIMEOUT_MS; + currentBot.pathfinder.searchRadius = Math.max(32, Math.ceil(maxSearchBlocks + 16)); + const path = currentBot.pathfinder.getPathTo(movements, goal, PATH_PREVIEW_TIMEOUT_MS); + if (path.status !== "success") { + throw new Error(`No safe path found (status=${path.status}).`); + } + const unsafeMove = (path.path ?? []).find((move: any) => (move.toBreak?.length ?? 0) > 0 || (move.toPlace?.length ?? 0) > 0); + if (unsafeMove) { + throw new Error("Path would require breaking or scaffold-placing blocks; refusing guarded travel."); + } + return path; + } + + function cleanupAfterMovement(currentBot: Bot & { pathfinder?: any; clearControlStates?: () => void }) { + try { + currentBot.pathfinder?.setGoal?.(null); + currentBot.clearControlStates?.(); + } catch (error) { + log("movement-cleanup-error", error); + } + } + + async function withAbortAndTimeout(promise: Promise, timeoutMs: number, signal: AbortSignal | undefined, onStop: () => void): Promise { + let timeout: ReturnType | undefined; + let abortHandler: (() => void) | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(`World task timed out after ${Math.round(timeoutMs / 1000)}s.`)), timeoutMs); + }); + const races: Promise[] = [promise, timeoutPromise]; + if (signal) { + const abortPromise = new Promise((_resolve, reject) => { + abortHandler = () => reject(new Error("World task cancelled.")); + signal.addEventListener("abort", abortHandler, { once: true }); + }); + races.push(abortPromise); + } + try { + return await Promise.race(races); + } catch (error) { + onStop(); + throw error; + } finally { + if (timeout) clearTimeout(timeout); + if (signal && abortHandler) signal.removeEventListener("abort", abortHandler); + } + } + + function travelTimeoutMs(distance: number): number { + return Math.min(MAX_WORLD_TASK_TIMEOUT_MS, Math.max(MIN_WORLD_TASK_TIMEOUT_MS, Math.ceil(distance * 1_500))); + } + + async function guardedGoto(params: GotoInput, signal: AbortSignal | undefined) { + const currentBot = activePathfinderBot(); + assertHealthyEnough(currentBot); + const current = ensureConfig(); + const target = { + x: finiteNumber(params.x, "x"), + y: finiteNumber(params.y, "y"), + z: finiteNumber(params.z, "z"), + }; + const range = normalizeRange(params.range); + const distance = distance3d(currentPosition(currentBot), target); + if (distance > current.maxTravelBlocks) { + throw new Error(`Target is ${distance.toFixed(1)} blocks away, beyond MAX_TRAVEL_BLOCKS=${current.maxTravelBlocks}.`); + } + const movements = createSafeMovements(currentBot); + const goal = new pathfinderModule.goals.GoalNear(target.x, target.y, target.z, range); + const path = previewSafePath(currentBot, movements, goal, Math.max(distance, range)); + if (!params.dry_run) { + const timeoutMs = travelTimeoutMs(distance); + await withAbortAndTimeout(currentBot.pathfinder.goto(goal), timeoutMs, signal, () => cleanupAfterMovement(currentBot)); + cleanupAfterMovement(currentBot); + } + const finalPosition = currentPosition(currentBot); + return { + target, + range, + distance: Number(distance.toFixed(2)), + pathCost: Number((path.cost ?? 0).toFixed(2)), + pathLength: path.path?.length ?? 0, + visitedNodes: path.visitedNodes, + dryRun: Boolean(params.dry_run), + finalPosition: { + x: Number(finalPosition.x.toFixed(3)), + y: Number(finalPosition.y.toFixed(3)), + z: Number(finalPosition.z.toFixed(3)), + }, + }; + } + + function isAirLike(block: any): boolean { + return !block || block.type === 0 || block.name === "air" || block.name === "cave_air" || block.name === "void_air"; + } + + function isDangerousBlockName(name: string): boolean { + return /(?:^|_)(?:lava|fire|magma_block|cactus|campfire|sweet_berry_bush|powder_snow)(?:$|_)/.test(name); + } + + function isSolidSupport(block: any): boolean { + if (!block || isAirLike(block)) return false; + if (isDangerousBlockName(block.name)) return false; + return block.boundingBox === "block" || block.physical === true; + } + + function looksProtectedBlockName(name: string): boolean { + return /chest|barrel|shulker|furnace|smoker|blast_furnace|crafting_table|anvil|enchanting_table|bed$|_bed|door|trapdoor|sign|banner|lectern|hopper|dropper|dispenser|piston|redstone|lever|button|pressure_plate|rail|torch|lantern|campfire|beacon|conduit|bell|brewing_stand|jukebox|note_block|bookshelf|glass|pane|stairs|slab|wall|fence|gate/.test(name); + } + + function requireBlockAt(currentBot: Bot, pos: any, label: string) { + const block = currentBot.blockAt(pos); + if (!block) throw new Error(`${label} at ${pos.x},${pos.y},${pos.z} is not loaded.`); + return block; + } + + function pyramidPositions(center: { x: number; y: number; z: number }): any[] { + const positions: any[] = []; + for (let layer = 0; layer < 3; layer += 1) { + const radius = 2 - layer; + for (let dx = -radius; dx <= radius; dx += 1) { + for (let dz = -radius; dz <= radius; dz += 1) { + positions.push(new Vec3(center.x + dx, center.y + layer, center.z + dz)); + } + } + } + return positions; + } + + function normalizeMaterialName(value: string): string { + return value.trim().toLowerCase().replace(/^minecraft:/, "").replace(/[\s-]+/g, "_"); + } + + function isUsableBuildMaterial(currentBot: Bot & { registry: any }, item: any): boolean { + const block = currentBot.registry?.blocksByName?.[item.name]; + if (!block) return false; + if (block.boundingBox && block.boundingBox !== "block") return false; + if (/tnt|chest|barrel|shulker|furnace|hopper|dispenser|dropper|bed$|_bed|door|trapdoor|button|lever|pressure_plate|rail|redstone|torch|lantern|campfire|glass|pane|sign|banner|anvil|beacon|command_block|structure_block|jigsaw|barrier|water|lava|fire|cactus|magma_block/.test(item.name)) return false; + if (/diamond|emerald|netherite|ancient_debris|(?:^|_)ore$|raw_|gold_block|iron_block|copper_block|lapis_block|coal_block|redstone_block|obsidian/.test(item.name)) return false; + return true; + } + + function aggregateBuildMaterials(currentBot: Bot & { registry: any; inventory: any }) { + const byName = new Map(); + for (const item of currentBot.inventory.items() as any[]) { + if (!isUsableBuildMaterial(currentBot, item)) continue; + const existing = byName.get(item.name); + if (existing) { + existing.count += item.count; + } else { + byName.set(item.name, { name: item.name, displayName: item.displayName ?? item.name, count: item.count, type: item.type }); + } + } + return [...byName.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + } + + function chooseBuildMaterial(currentBot: Bot & { registry: any; inventory: any }, requested: string | undefined, needed: number) { + const candidates = aggregateBuildMaterials(currentBot); + if (requested?.trim()) { + const wanted = normalizeMaterialName(requested); + const match = candidates.find((item) => item.name === wanted || normalizeMaterialName(item.displayName) === wanted); + if (!match) throw new Error(`No usable inventory block named ${wanted}.`); + if (match.count < needed) throw new Error(`Need ${needed} ${match.name}, but only have ${match.count}.`); + return match; + } + for (const preferred of [ + "dirt", + "cobblestone", + "stone", + "oak_planks", + "spruce_planks", + "birch_planks", + "sandstone", + "deepslate", + ]) { + const match = candidates.find((item) => item.name === preferred && item.count >= needed); + if (match) return match; + } + const fallback = candidates.find((item) => item.count >= needed); + if (fallback) return fallback; + const available = candidates.slice(0, 8).map((item) => `${item.name}x${item.count}`).join(", ") || "none"; + throw new Error(`Need ${needed} safe placeable blocks in inventory; available candidates: ${available}.`); + } + + function inspectBuildSite(currentBot: Bot & { registry: any }, positions: any[], center: { x: number; y: number; z: number }) { + const planned = new Set(positions.map(blockKey)); + const problems: string[] = []; + for (const pos of positions) { + const target = requireBlockAt(currentBot, pos, "target block"); + if (!isAirLike(target)) problems.push(`target ${pos.x},${pos.y},${pos.z} is ${target.name}, not air`); + const belowPos = pos.offset(0, -1, 0); + const belowKey = blockKey(belowPos); + if (!planned.has(belowKey)) { + const support = requireBlockAt(currentBot, belowPos, "support block"); + if (!isSolidSupport(support)) problems.push(`support ${belowPos.x},${belowPos.y},${belowPos.z} is not solid/safe (${support.name})`); + } + } + + for (let x = center.x - 4; x <= center.x + 4; x += 1) { + for (let y = center.y - 1; y <= center.y + 4; y += 1) { + for (let z = center.z - 4; z <= center.z + 4; z += 1) { + const pos = new Vec3(x, y, z); + const key = blockKey(pos); + if (planned.has(key)) continue; + const block = currentBot.blockAt(pos); + if (!block || isAirLike(block)) continue; + if (isDangerousBlockName(block.name)) problems.push(`dangerous block near site at ${x},${y},${z}: ${block.name}`); + if (looksProtectedBlockName(block.name)) problems.push(`player-made/protected-looking block near site at ${x},${y},${z}: ${block.name}`); + } + } + } + + for (const entity of Object.values((currentBot as any).entities ?? {}) as any[]) { + if (!entity?.position || entity === currentBot.entity) continue; + const pos = entity.position; + if (pos.x >= center.x - 3 && pos.x <= center.x + 3 && pos.z >= center.z - 3 && pos.z <= center.z + 3 && pos.y >= center.y - 1 && pos.y <= center.y + 4) { + problems.push(`entity ${entity.name ?? entity.username ?? entity.type ?? "unknown"} is inside/near the build footprint`); + } + } + + if (problems.length > 0) { + const sample = problems.slice(0, 5).join("; "); + throw new Error(`Build site rejected: ${sample}${problems.length > 5 ? `; +${problems.length - 5} more` : ""}.`); + } + return { checkedPositions: positions.length, scannedRadius: 4 }; + } + + async function gotoPlacementReach(currentBot: Bot & { pathfinder: any; world: any }, pos: any, signal: AbortSignal | undefined) { + const movements = createSafeMovements(currentBot as any); + const goal = new pathfinderModule.goals.GoalPlaceBlock(pos, currentBot.world, { range: 4 }); + previewSafePath(currentBot, movements, goal, 32); + await withAbortAndTimeout(currentBot.pathfinder.goto(goal), MIN_WORLD_TASK_TIMEOUT_MS, signal, () => cleanupAfterMovement(currentBot)); + cleanupAfterMovement(currentBot); + } + + async function equipMaterial(currentBot: Bot & { inventory: any }, materialName: string) { + const item = (currentBot.inventory.items() as any[]).find((candidate) => candidate.name === materialName); + if (!item) throw new Error(`Ran out of ${materialName} while building.`); + await currentBot.equip(item, "hand"); + } + + async function placeOneBlock(currentBot: Bot & { pathfinder: any; inventory: any; world: any }, pos: any, materialName: string, signal: AbortSignal | undefined) { + let target = requireBlockAt(currentBot, pos, "target block"); + if (!isAirLike(target)) throw new Error(`Target ${pos.x},${pos.y},${pos.z} became occupied by ${target.name}.`); + await gotoPlacementReach(currentBot, pos, signal); + target = requireBlockAt(currentBot, pos, "target block"); + if (!isAirLike(target)) throw new Error(`Target ${pos.x},${pos.y},${pos.z} became occupied by ${target.name}.`); + const reference = requireBlockAt(currentBot, pos.offset(0, -1, 0), "reference block"); + if (!isSolidSupport(reference)) throw new Error(`Reference block below ${pos.x},${pos.y},${pos.z} is not safe (${reference.name}).`); + await equipMaterial(currentBot, materialName); + await currentBot.lookAt(pos.offset(0.5, 0.5, 0.5), true); + await currentBot.placeBlock(reference, new Vec3(0, 1, 0)); + await currentBot.waitForTicks?.(2); + const placed = requireBlockAt(currentBot, pos, "placed block"); + if (isAirLike(placed)) throw new Error(`Placement at ${pos.x},${pos.y},${pos.z} did not appear in the world.`); + return placed.name; + } + pi.on("agent_start", () => { agentBusy = true; }); @@ -787,11 +1587,16 @@ export default function mineflayerBridge(pi: ExtensionAPI) { shuttingDown = false; manualDisconnectRequested = false; reconnectPausedReason = undefined; + startupMemoryReviewed = false; + lastHumanChatAt = Date.now(); + lastAutonomyPromptAt = 0; try { - ensureConfig(); + const current = ensureConfig(); + ensureMemoryLayout(current); warnIfUnsafeOperatorTrustConfigured(); surfaceEscalationCount(); connect("startup"); + startAutonomyTimer(); if (ctx.hasUI) ctx.ui.setStatus("mineflayer", "mc: connecting"); } catch (error) { log("startup-error", error); @@ -801,6 +1606,7 @@ export default function mineflayerBridge(pi: ExtensionAPI) { pi.on("session_shutdown", async () => { shuttingDown = true; + stopAutonomyTimer(); disconnect({ manual: true }); }); @@ -865,6 +1671,8 @@ export default function mineflayerBridge(pi: ExtensionAPI) { `recent_chat=${recentChat.length}`, `operator_trust=${trustEnabled ? "enabled" : current.operatorUsernames.length > 0 ? "disabled" : "unconfigured"}`, `operator_count=${current.operatorUsernames.length}`, + `max_travel_blocks=${current.maxTravelBlocks}`, + activeWorldTask ? `active_task=${formatActiveTask()}` : undefined, `reconnects_in_10m=${reconnectAttemptTimestamps.length}/${MAX_RECONNECTS_PER_WINDOW}`, reconnectPausedReason ? `paused=${reconnectPausedReason}` : undefined, ] @@ -881,6 +1689,8 @@ export default function mineflayerBridge(pi: ExtensionAPI) { operatorTrustEnabled: trustEnabled, operatorCount: current.operatorUsernames.length, identityProtection: hasIdentityProtection(current), + maxTravelBlocks: current.maxTravelBlocks, + activeWorldTask, reconnectAttemptsInWindow: reconnectAttemptTimestamps.length, maxReconnectsPerWindow: MAX_RECONNECTS_PER_WINDOW, reconnectWindowMs: RECONNECT_WINDOW_MS, @@ -946,10 +1756,77 @@ export default function mineflayerBridge(pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "mc_memory", + label: "Minecraft Memory", + description: "Update repo-local per-server Minecraft memory: current task, diary, and named locations, without exposing .env-derived state paths.", + promptSnippet: "Set/clear current task, append concise diary entries, or register named Minecraft locations under state/.", + promptGuidelines: [ + "Use mc_memory to set current-task before meaningful autonomous actions, clear it on completion, append concise diary notes, and register named locations.", + "Do not store secrets or .env values in mc_memory; diary entries should be one short line.", + ], + parameters: MEMORY_PARAMS, + executionMode: "sequential", + async execute(_toolCallId, params: MemoryInput) { + const current = ensureConfig(); + ensureMemoryLayout(current); + const now = new Date().toISOString(); + + if (params.action === "set_current_task") { + const summary = safeJsonlField(params.task ?? "", current); + if (!summary) throw new Error("set_current_task requires a non-empty task."); + const target = params.x !== undefined || params.y !== undefined || params.z !== undefined + ? { x: finiteNumber(params.x, "x"), y: finiteNumber(params.y, "y"), z: finiteNumber(params.z, "z") } + : undefined; + writeCurrentTaskRecord(current, { + status: "in-progress", + kind: params.kind ? safeJsonlField(params.kind, current) : "manual", + summary, + target, + notes: params.notes ? safeJsonlField(params.notes, current) : undefined, + startedAt: now, + updatedAt: now, + }); + return { + content: [{ type: "text", text: `Set current task in ${publicStatePath("current-task.json")}: ${summary}` }], + details: { action: params.action, path: publicStatePath("current-task.json"), summary, target }, + }; + } + + if (params.action === "clear_current_task") { + clearCurrentTask(current); + return { + content: [{ type: "text", text: `Cleared ${publicStatePath("current-task.json")}.` }], + details: { action: params.action, path: publicStatePath("current-task.json") }, + }; + } + + if (params.action === "append_diary") { + const text = safeJsonlField(params.text ?? "", current); + if (!text) throw new Error("append_diary requires non-empty text."); + appendDiary(current, text); + return { + content: [{ type: "text", text: `Appended one diary line to ${publicStatePath("diary/YYYY-MM-DD.md")}.` }], + details: { action: params.action, path: publicStatePath("diary/YYYY-MM-DD.md"), text: truncate(text, MAX_DIARY_ENTRY_LENGTH) }, + }; + } + + if (params.action === "register_location") { + const location = registerMemoryLocation(current, params); + return { + content: [{ type: "text", text: `Registered location ${location.name} in ${publicStatePath("locations.json")}.` }], + details: { action: params.action, path: publicStatePath("locations.json"), location }, + }; + } + + throw new Error(`Unsupported memory action: ${(params as any).action}`); + }, + }); + pi.registerTool({ name: "mc_position", label: "Minecraft Position", - 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.", + description: "Return the connected bot's current Minecraft position and basic status without exposing .env values.", promptSnippet: "Report the bot's current Minecraft position and basic status.", parameters: EMPTY_PARAMS, async execute() { @@ -989,6 +1866,194 @@ export default function mineflayerBridge(pi: ExtensionAPI) { }, }); + pi.registerTool({ + name: "mc_goto", + label: "Minecraft Guarded Go To", + description: "Move to nearby coordinates with guard rails: max distance, no digging, no scaffold placement, lava/liquid/danger avoidance, and a single active world-task lock.", + promptSnippet: "Safely walk to nearby coordinates with distance and hazard guard rails.", + promptGuidelines: [ + "Use mc_goto only for trusted or sanctioned movement requests after checking safety and active task status.", + "Do not use mc_goto for non-operator chat requests unless a repo skill explicitly sanctions that scope.", + "mc_goto refuses long trips, unsafe paths, low health/food, block breaking, and scaffold placement.", + ], + parameters: GOTO_PARAMS, + executionMode: "sequential", + async execute(_toolCallId, params: GotoInput, signal?: AbortSignal) { + if (activeWorldTask) throw new Error(`Already busy with ${activeWorldTask.label}.`); + const current = ensureConfig(); + const normalized: GotoInput = { + x: finiteNumber(params.x, "x"), + y: finiteNumber(params.y, "y"), + z: finiteNumber(params.z, "z"), + range: normalizeRange(params.range), + dry_run: Boolean(params.dry_run), + }; + const summary = `travel to ${normalized.x.toFixed(1)},${normalized.y.toFixed(1)},${normalized.z.toFixed(1)}`; + let taskId: string | undefined; + if (!normalized.dry_run) { + taskId = beginWorldTask("goto", summary, { + x: normalized.x, + y: normalized.y, + z: normalized.z, + }); + writeCurrentTaskRecord(current, { + status: "in-progress", + kind: "goto", + summary, + target: { x: normalized.x, y: normalized.y, z: normalized.z, range: normalized.range }, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + appendDiary(current, `started: ${summary}`); + } + try { + const result = await guardedGoto(normalized, signal); + if (!normalized.dry_run) { + appendDiary(current, `arrived near ${result.target.x},${result.target.y},${result.target.z}`); + clearCurrentTask(current); + } + return { + content: [ + { + type: "text", + text: normalized.dry_run + ? `Dry-run safe path to x=${result.target.x}, y=${result.target.y}, z=${result.target.z}; distance=${result.distance}; path_nodes=${result.pathLength}.` + : `Arrived near x=${result.target.x}, y=${result.target.y}, z=${result.target.z}; final x=${result.finalPosition.x}, y=${result.finalPosition.y}, z=${result.finalPosition.z}.`, + }, + ], + details: result, + }; + } catch (error) { + if (!normalized.dry_run) { + markCurrentTaskBlocked(current, summary, error); + appendDiary(current, `blocked: ${summary} — ${truncate(redact(stringifyUnknown(error), current), 120)}`); + } + throw error; + } finally { + if (taskId) finishWorldTask(taskId); + } + }, + }); + + pi.registerTool({ + name: "mc_build_pyramid_5x5", + label: "Minecraft Build 5x5 Pyramid", + description: "Build a small 5x5/3x3/1 block pyramid centered at coordinates after guarded travel, site inspection, inventory material selection, and no-player-build checks.", + promptSnippet: "Build a guarded 5x5 pyramid from safe inventory blocks at an operator-approved empty site.", + promptGuidelines: [ + "Use mc_build_pyramid_5x5 only for scope-trusted operator or repo-sanctioned small-build requests.", + "mc_build_pyramid_5x5 must not be used to alter existing blocks; it refuses non-air targets and protected-looking nearby blocks.", + "If mc_build_pyramid_5x5 fails due to missing materials or unsafe site, report the blocker in chat instead of forcing it.", + ], + parameters: BUILD_PYRAMID_PARAMS, + executionMode: "sequential", + async execute(_toolCallId, params: BuildPyramidInput, signal?: AbortSignal) { + if (activeWorldTask) throw new Error(`Already busy with ${activeWorldTask.label}.`); + const current = ensureConfig(); + const center = { + x: Math.round(finiteNumber(params.x, "x")), + y: Math.floor(finiteNumber(params.y, "y")), + z: Math.round(finiteNumber(params.z, "z")), + }; + const dryRun = Boolean(params.dry_run); + const currentBot = activePathfinderBot(); + const positions = pyramidPositions(center); + const material = chooseBuildMaterial(currentBot, params.material, PYRAMID_BLOCK_COUNT); + const summary = `build ${PYRAMID_BASE_SIZE}x${PYRAMID_BASE_SIZE} pyramid at ${center.x},${center.y},${center.z}`; + let taskId: string | undefined; + if (!dryRun) { + taskId = beginWorldTask("build", summary, center); + writeCurrentTaskRecord(current, { + status: "in-progress", + kind: "build", + summary, + target: center, + material: material.name, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + appendDiary(current, `started: ${summary}`); + } + try { + const travel = await guardedGoto({ x: center.x, y: center.y, z: center.z, range: 4, dry_run: dryRun }, signal); + if (dryRun) { + return { + content: [ + { + type: "text", + text: `Dry-run ok for 5x5 pyramid centered at ${center.x},${center.y},${center.z}; material=${material.name} (${material.count} available); remote site will be inspected after travel.`, + }, + ], + details: { center, blocksNeeded: PYRAMID_BLOCK_COUNT, material, travel, dryRun: true }, + }; + } + + await currentBot.waitForChunksToLoad?.(); + const site = inspectBuildSite(currentBot, positions, center); + const placed: Array<{ x: number; y: number; z: number; name: string }> = []; + for (const pos of positions) { + const name = await placeOneBlock(currentBot, pos, material.name, signal); + placed.push({ x: pos.x, y: pos.y, z: pos.z, name }); + } + const finalPosition = currentPosition(currentBot); + appendDiary(current, `built 5x5 pyramid at ${center.x},${center.y},${center.z} using ${material.name}; placed ${placed.length}`); + clearCurrentTask(current); + return { + content: [ + { + type: "text", + text: `Built 5x5 pyramid at ${center.x},${center.y},${center.z} using ${material.name}; placed ${placed.length} blocks.`, + }, + ], + details: { + center, + material: material.name, + blocksNeeded: PYRAMID_BLOCK_COUNT, + blocksPlaced: placed.length, + travel, + site, + finalPosition: { + x: Number(finalPosition.x.toFixed(3)), + y: Number(finalPosition.y.toFixed(3)), + z: Number(finalPosition.z.toFixed(3)), + }, + }, + }; + } catch (error) { + if (!dryRun) { + markCurrentTaskBlocked(current, summary, error); + appendDiary(current, `blocked: ${summary} — ${truncate(redact(stringifyUnknown(error), current), 120)}`); + } + throw error; + } finally { + if (taskId) finishWorldTask(taskId); + } + }, + }); + + pi.registerTool({ + name: "mc_stop_world_task", + label: "Minecraft Stop World Task", + description: "Stop the current guarded movement/build pathfinder task and clear the world-task lock without disconnecting.", + promptSnippet: "Stop current guarded movement/build task without disconnecting.", + parameters: EMPTY_PARAMS, + executionMode: "sequential", + async execute() { + const previous = formatActiveTask(); + const current = ensureConfig(); + stopPathfinderIfPossible(); + activeWorldTask = undefined; + if (previous) { + appendDiary(current, `stopped: ${previous}`); + clearCurrentTask(current); + } + return { + content: [{ type: "text", text: previous ? `Stopped world task: ${previous}.` : "No active world task was recorded; pathfinder stop still requested." }], + details: { stopped: Boolean(previous), previousTask: previous }, + }; + }, + }); + pi.registerTool({ name: "mc_disconnect", label: "Minecraft Disconnect", diff --git a/package-lock.json b/package-lock.json index 35fa25a..c4e160c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "dotenv": "^16.4.5", - "mineflayer": "^4.37.1" + "mineflayer": "^4.37.1", + "mineflayer-pathfinder": "^2.4.5" }, "engines": { "node": ">=20" @@ -464,6 +465,21 @@ "node": ">=22" } }, + "node_modules/mineflayer-pathfinder": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/mineflayer-pathfinder/-/mineflayer-pathfinder-2.4.5.tgz", + "integrity": "sha512-Jh3JnUgRLwhMh2Dugo4SPza68C41y+NPP5sdsgxRu35ydndo70i1JJGxauVWbXrpNwIxYNztUw78aFyb7icw8g==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.5.1", + "prismarine-block": "^1.16.3", + "prismarine-entity": "^2.1.1", + "prismarine-item": "^1.11.5", + "prismarine-nbt": "^2.2.1", + "prismarine-physics": "^1.5.2", + "vec3": "^0.1.7" + } + }, "node_modules/mojangson": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz", diff --git a/package.json b/package.json index d5bd848..4bec46c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "dotenv": "^16.4.5", - "mineflayer": "^4.37.1" + "mineflayer": "^4.37.1", + "mineflayer-pathfinder": "^2.4.5" }, "repository": { "type": "git", diff --git a/skills/phase-2-locomotion-pending.md b/skills/phase-2-locomotion-pending.md index 9405483..ca57507 100644 --- a/skills/phase-2-locomotion-pending.md +++ b/skills/phase-2-locomotion-pending.md @@ -1,27 +1,41 @@ --- -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." +name: phase-2-locomotion +description: "Use guarded Mineflayer pathfinder movement for trusted/sanctioned coordinate requests, with distance, focus, and hazard rails." +when_to_use: "Use when a scope-trusted operator or repo-sanctioned skill asks the bot to come somewhere, follow a safe coordinate instruction, or go to coordinates." --- -# Phase 2 Locomotion Pending +# Phase 2 Locomotion -Status: pending. +Status: implemented pending bridge reload. -Locomotion is intentionally out of scope for the current session. Do not add movement/pathfinder tools yet. +The bridge now registers `mc_goto` using `mineflayer-pathfinder`. -For now, if non-operator chat asks the bot to move, follow, or go to coordinates: +## Rails -1. Use `mc_log_escalation(...)`. -2. Explain in `why_unsure` that safe pathing/distance bounds are not implemented yet. -3. Do not move. +- Refuses targets farther than `MAX_TRAVEL_BLOCKS` from current position (default `500`). +- Keeps one active world-task lock; do not context-switch while walking/building. +- Uses safe movements: + - no digging; + - no scaffold placement; + - no 1x1 towers; + - no parkour; + - max drop-down of 2; + - avoids water/lava/fire/magma/cactus/campfire/berry/powder-snow/cobweb hazards. +- Refuses if health or food is critically low. +- Supports `dry_run` for path preview. -If a scope-trusted operator asks, do not log a scope escalation. Reply that you will try to learn, then draft or update the guarded locomotion skill plan. Do not actually move until safe pathing tools and rails exist. +## Use -Future phase-2 implementation should include: +For a scope-trusted operator request such as `go to 100 64 -200`: -- `mineflayer-pathfinder` with safe goals; -- max travel distance bounds; -- focus/lock while traveling; -- lava/void/claim avoidance; -- clear refusal messages when travel is unsafe. +1. Confirm it is not a hard-safety issue. +2. Check no active world task is running (`mc_status`). +3. Call `mc_goto({ x, y, z, range })`. +4. If it fails, report the blocker in chat instead of forcing movement. + +For non-operator requests, chat remains dialog-only unless a repo skill explicitly sanctions movement. + +## Not implemented yet + +- Dynamic following of a moving player. +- Claim-plugin awareness beyond refusing protected-looking build sites in small-build tools. diff --git a/skills/safe-small-builds-pending.md b/skills/safe-small-builds-pending.md index bf825b6..ed5b355 100644 --- a/skills/safe-small-builds-pending.md +++ b/skills/safe-small-builds-pending.md @@ -1,54 +1,50 @@ --- -name: safe-small-builds-pending -description: "Pending skill plan for operator-approved small block placement tasks such as a 5x5 pyramid on an explicitly safe empty site." -when_to_use: "Use when a scope-trusted operator asks the bot to build a small structure before safe pathing and block-placement tools exist." +name: safe-small-builds +description: "Use guarded block-placement tools for scope-trusted small builds, especially a 5x5 pyramid on an explicitly safe empty site." +when_to_use: "Use when a scope-trusted operator asks the bot to build a small structure such as a 5x5 pyramid." --- -# Safe Small Builds Pending +# Safe Small Builds -Status: pending. +Status: implemented pending bridge reload. Observed request: a scope-trusted operator asked for a 5x5 pyramid at `x=587.070 y=67.0 z=235.891` on an explicitly described empty island site, using any material. +The bridge now registers `mc_build_pyramid_5x5`. + ## Safety decision This is a scope-trusted operator request and is not inherently a hard-safety violation because the operator described the target as an empty safe build area. Do **not** treat this as transitive trust, OP/admin, PvP, item handoff, or griefing. -Do not build until the bot has safe locomotion and block-placement rails. If the site appears to overlap another player's build, claim, chest area, farm, or protected structure, stop and ask/log before placing blocks. +The tool still refuses to build if the inspected site appears to overlap another player's build, claim, chest area, farm, protected-looking block, hazardous block, occupied entity area, or non-air footprint. -## Required tools/extensions +## Tool behavior -- `mineflayer-pathfinder` or equivalent guarded movement with: - - max travel distance bounds; - - lava/void/fall avoidance; - - focus lock while traveling; - - cancellation/refusal if route is unsafe. -- A block-placement tool that can: - - inspect nearby blocks before placement; - - place blocks only from the bot's inventory; - - refuse protected/occupied locations; - - report missing materials instead of taking from players. -- Optional inventory snapshot skill to choose a harmless available material. +`mc_build_pyramid_5x5({ x, y, z, material?, dry_run? })`: -## 5x5 pyramid plan - -For a compact three-layer pyramid centered near the requested coordinate: - -1. Travel to the site and verify the 5x5 footprint is empty, flat enough, and away from player builds. -2. Pick an available non-valuable material from inventory. -3. Place bottom layer: 5x5 square. -4. Place second layer: centered 3x3 square one block above. -5. Place top layer: centered 1x1 block one block above. -6. Step back, verify shape, and report completion or the first blocking issue. +1. Rounds X/Z to the intended center and floors Y as the bottom-layer Y. +2. Requires 35 safe placeable inventory blocks. +3. Uses guarded travel to approach the site. +4. Inspects the 5x5/3x3/1 footprint and nearby radius. +5. Places: + - bottom layer: 5x5 square; + - second layer: centered 3x3 square one block above; + - top layer: centered 1x1 block. +6. Stops on the first blocker and reports it. ## Failure modes - Requested coordinate is too far or path is unsafe. -- Footprint is not empty or looks player-owned. -- Bot lacks enough blocks (35 blocks for full 5x5/3x3/1 pyramid). +- Footprint is not empty or lacks solid support. +- Nearby blocks look player-made/protected. +- Bot lacks 35 safe placeable blocks. - Server anti-cheat or claims plugin rejects movement/placement. -- Another player enters the build area during placement. +- Another entity enters the build area during placement. -## Current behavior +## Current requested build -Until the required tools exist, acknowledge the operator with the self-extension reflex, keep the request recorded here, and do not move or place blocks. +After the bridge reloads, the queued operator request can be attempted with: + +```json +{"x":587.070,"y":67.0,"z":235.891} +```