'use strict'; const conf = require('../conf'); const {sleep} = require('../utils'); const { ProviderFactory } = require('./ai/providers'); const memoryDB = require('./ai/memory-db'); class Ai{ constructor(args){ this.bot = args.bot; this.promptName = args.promptName; this.prompCustom = args.prompCustom || ''; // interval takes precedence over intervalLength (both are valid config names) this.intervalLength = args.interval || args.intervalLength || 30; this.intervalStop; this.messageListener; this.provider = null; this.memoryDB = memoryDB; this._allTools = []; this._lastSentMessages = []; // prevent duplicate chat spam (LRU queue) this._active = false; // AI is initialized and listening this._consecutiveFailures = 0; // track API failures for backoff this._backoffUntil = 0; // suppress calls until this timestamp this._messages = null; // messages array reference (set in init) this._polling = false; // prevent concurrent poll cycles this._pollTimer = null; // interval ref for polling // Trade feedback loop this._tradeWindow = null; this._tradeWindowState = null; this._expectingTradeWindow = false; // set by trade.js before auto-accept // Bot-specific AI config (overrides global config) // When loaded via config, args contains provider, model, baseUrl, etc. directly // When loaded via /ai command, only promptName/prompCustom are passed const { bot, promptName, prompCustom, intervalLength, interval, ...configProps } = args; this.botConfig = args.botConfig || configProps || {}; } // Get merged config: bot-specific settings override global settings __getConfig() { return { ...conf.ai, // Global defaults ...this.botConfig, // Bot-specific overrides }; } async init(){ // If bot is already ready, complete setup immediately and await it. // Otherwise, queue for when the bot becomes ready. if (this.bot.isReady) { await this._completeSetup(); } else { this.bot.on('onReady', () => this._completeSetup()); } } async _completeSetup() { try { await this.start(); this._messages = []; this._active = true; this.messageListener = this.bot.on('message', (message, type)=>{ if(type === 'game_info') return; const msgText = message.toString(); if(msgText.startsWith('<')){ const firstBracket = msgText.split('>')[0]; // Extract username from <[lvl] username> or format const userMatch = firstBracket.match(/^<\[.*?\]\s*(\w+)>$|^<(\w+)>$/); if(userMatch){ const speakerName = userMatch[1] || userMatch[2]; if(speakerName === this.bot.bot.entity.username){ console.log('message blocked from message array') return; } } } console.log(`Message ${type}: ${message.toString()}`) // Add timestamp to message for time awareness const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); this._messages.push({ type: 'message', text: message.toString(), timestamp: timestamp, timeAgo: this.getTimeAgo(timestamp) }); // Ensure poll timer is running this._ensurePolling(); }); // Monitor trade windows (even auto-accepted ones) for feedback loop this.bot.bot.on('windowOpen', (window) => { // Only fires when trade.js signals an expected trade — avoids // false positives from scanner opening chests (also 54+ slots). if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) { this._expectingTradeWindow = false; console.log('AI: Trade window detected, setting up feedback loop'); this._setupTradeWindow(window, 'auto-accepted'); this._ensurePolling(); } }); console.log(`${this.bot.name} AI ready — waiting for chat activity`); } catch(error) { console.error(`${this.bot.name} AI setup failed:`, error); throw error; } } async unload(){ if(this._pollTimer){ clearInterval(this._pollTimer); this._pollTimer = null; } if(this.messageListener){ this.messageListener(); } if(this.provider){ await this.provider.close(); } this._active = false; return true; } // ---- Simple interval polling ---- _ensurePolling() { if (this._pollTimer) return; const intervalMs = (this.intervalLength || 5) * 1000; this._pollTimer = setInterval(() => this._pollCycle(), intervalMs); } // ---- Main polling cycle ---- async _pollCycle() { // Respect backoff after errors if (Date.now() < this._backoffUntil) return; // Prevent concurrent cycles if (this._polling) return; this._polling = true; try { // Snapshot messages so new arrivals during processing aren't lost const currentMessages = [...this._messages]; // Reset for the next accumulation window this._messages = []; // Skip API call if there's no real data const hasRealData = currentMessages.some(m => typeof m === 'object' && m.text); const hasTradeWindow = !!this._tradeWindowState; if (!hasRealData && !hasTradeWindow) return; let result; try{ const currentTime = new Date(); const requestData = { messages: currentMessages, currentTime: currentTime.toISOString().replace('T', ' ').substring(0, 19), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, tradeWindow: this._tradeWindowState, }; result = await this.chat(JSON.stringify(requestData)); }catch(error){ console.log('error AI API', error); // Exponential backoff on failure — don't hammer an overloaded server this._consecutiveFailures++; const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000); this._backoffUntil = Date.now() + backoffMs; console.log(`AI backoff: ${backoffMs}ms (failure #${this._consecutiveFailures}), until ${new Date(this._backoffUntil).toISOString()}`); return; } // Success — reset failure tracking this._consecutiveFailures = 0; this._backoffUntil = 0; try{ // Determine the requesting player from chat context const requestingPlayer = this.getLastSpeaker(currentMessages); // Check for tool calls first const toolCalls = this.getToolCalls(result); if (toolCalls && toolCalls.length > 0) { // Deduplicate tool calls const seen = new Set(); const uniqueCalls = toolCalls.filter(tc => { const key = `${tc.name || tc.function?.name}:${JSON.stringify(tc.args || tc.arguments || {})}`; if (seen.has(key)) { console.log(`Deduplicating duplicate tool call: ${key}`); return false; } seen.add(key); return true; }); console.log(`Tool calls from AI: ${toolCalls.length} raw, ${uniqueCalls.length} after dedup — ${uniqueCalls.map(c => c.name || c.function?.name).join(', ')}`); // Execute tool calls and get results const toolResults = []; for (const toolCall of uniqueCalls) { try { const toolResult = await this._executeTool( toolCall.name || toolCall.function?.name, toolCall.args || toolCall.arguments || {}, requestingPlayer ); toolResults.push({ name: toolCall.name || toolCall.function?.name, result: toolResult, success: true }); } catch (execError) { console.error('Tool execution error:', execError); toolResults.push({ name: toolCall.name || toolCall.function?.name, error: execError.message, success: false }); } } // Send tool results back to AI for natural language response if (toolResults.length > 0) { const toolResultMessage = JSON.stringify({ toolResults: toolResults, tradeWindow: this._tradeWindowState, instruction: 'Tool results above. Respond with ONE brief message (max 150 chars) in the JSON array format. Be short and direct - do not narrate what happened.' }); try { const followupResult = await this.chat(toolResultMessage); const responseText = this.provider.getResponse(followupResult); await this.processResponse(responseText); } catch (followupError) { console.error('Error generating followup response:', followupError); } } return; } // No tool calls, process as normal chat response const responseText = this.provider.getResponse(result); await this.processResponse(responseText); }catch(error){ console.log('Error in AI message loop', error, result); try { if(result && this.provider.getResponse(result)){ console.log(this.provider.getResponse(result)) } } catch(e) { // Ignore } } } finally { this._polling = false; } } async start(history){ const config = this.__getConfig(); console.log(`${this.bot.name} AI config:`, { provider: config.provider, model: config.model, promptName: this.promptName, baseUrl: config.baseUrl, maxOutputTokens: config.maxOutputTokens, interval: config.interval, }); // Let the AI control trade decisions (disable auto-accept in minecraft.js) this.bot._aiControlsTrade = true; // Initialize memory database await this.memoryDB.initialize('./storage/ai-memory.db', this.bot.name); console.log(`${this.bot.name} AI memory database initialized`); // Get memory context for prompt (directives + general memories) const memoryContext = await this.memoryDB.getMemoryContext(); // Get player-specific memories for currently online players const onlinePlayers = Object.values(this.bot.getPlayers()).map(player => player.username); const playerMemoryContext = await this.memoryDB.getPlayerMemoriesForPrompt(onlinePlayers); // Combine memory contexts const fullMemoryContext = [memoryContext, playerMemoryContext].filter(Boolean).join('\n\n'); // Get current time info const timeInfo = this.getCurrentTimeInfo(); // Build consolidated tool registry this._buildAllTools(); const toolsDocs = this._getToolsDocumentation(); const prompt = conf.ai.prompts[this.promptName]( this.bot.bot.entity.username, config.interval, Object.values(this.bot.getPlayers()).map(player=>`<[${player.lvl}] ${player.username}>`).join('\n'), toolsDocs, fullMemoryContext, timeInfo, this.prompCustom, ); // Create the provider instance with merged config and prompt this.provider = ProviderFactory.create({ ...config, prompt: prompt, }); if (this.provider.supportsTools && this.provider.supportsTools()) { this.provider.setTools(this._getToolsSchema()); console.log(`${this.bot.name} AI tools configured: ${this._allTools.length} tools available`); } await this.provider.start(history); console.log(`${this.bot.name} AI ${config.provider} provider started (model: ${config.model})`); } async chat(message, retryCount=0){ console.log(`chat ${this.bot.name}`, retryCount) try{ let result = await this.provider.chat(message); return result }catch(error){ console.log('AI chat error', error) throw error; } } /** * Extract tool calls from provider response */ getToolCalls(result) { // Gemini: functionCalls() method if (result.response && typeof result.response.functionCalls === 'function') { const calls = result.response.functionCalls(); if (calls && calls.length > 0) { return calls.map(call => ({ name: call.name, function: { name: call.name }, args: call.args })); } } // Ollama: tool_calls in result or message data if (result.tool_calls && result.tool_calls.length > 0) { return result.tool_calls.map(tc => ({ name: tc.name || tc.function?.name, function: { name: tc.function?.name || tc.name }, args: tc.args || tc.arguments || tc.function?.arguments || {} })); } // Check for tool call in response text (fallback for models without native tool support) const responseText = result.response ? result.response.text() : null; if (responseText) { try { const parsed = JSON.parse(responseText); if (parsed.tool_call || parsed.toolCall) { const toolCall = parsed.tool_call || parsed.toolCall; return [{ name: toolCall.name, function: { name: toolCall.name }, args: toolCall.args || toolCall.arguments || toolCall.parameters || {} }]; } // Check for array of tool calls if (Array.isArray(parsed.tool_calls)) { return parsed.tool_calls.map(tc => ({ name: tc.name || tc.function?.name, function: { name: tc.function?.name || tc.name }, args: tc.args || tc.arguments || tc.function?.arguments || {} })); } } catch (e) { // Not JSON, no tool calls } } return null; } // ======================================== // Consolidated Tool Registry // ======================================== _buildAllTools() { this._allTools = []; // Plugin tools — discovered from loaded plugin.commands[] for (const [pluginName, plugin] of Object.entries(this.bot.plunginsLoaded)) { const commands = plugin.commands || []; for (const cmd of commands) { this._allTools.push({ name: `${pluginName}_${cmd.name}`, description: cmd.description, parameters: cmd.parameters || [], category: cmd.category || 'plugin', execute: async (params) => { const args = (cmd.parameters || []).map(p => params[p.name]); return plugin.handleCommand('ai', cmd.name, ...args); } }); } } // Memory tools this._allTools.push( { name: 'remember_player', category: 'memory', description: 'Store a memory about a player (trust level, role, preferences)', parameters: [ { name: 'playerName', type: 'string', required: true, description: 'Player name' }, { name: 'key', type: 'string', required: true, description: 'Memory key (trust_level, role, etc.)' }, { name: 'value', type: 'string', required: true, description: 'Value to store' } ], execute: (p) => this.memoryDB.setPlayerMemory(p.playerName, p.key, p.value) .then(() => `Stored ${p.key}=${p.value} for ${p.playerName}`) }, { name: 'recall_player', category: 'memory', description: 'Retrieve all stored memories about a player', parameters: [ { name: 'playerName', type: 'string', required: true, description: 'Player name' } ], execute: async (p) => { const m = await this.memoryDB.getAllPlayerMemories(p.playerName); const keys = Object.keys(m); return keys.length ? `Memories for ${p.playerName}: ${JSON.stringify(m)}` : `No memories for ${p.playerName}`; } }, { name: 'list_known_players', category: 'memory', description: 'List all players the bot has memories about', parameters: [], execute: async () => { const players = await this.memoryDB.getAllKnownPlayers(); return players.length ? `Known players: ${players.join(', ')}` : 'No known players yet'; } }, { name: 'set_directive', category: 'memory', description: 'Set a bot self-directive (current_goal, mood, focus)', parameters: [ { name: 'key', type: 'string', required: true, description: 'Directive key' }, { name: 'value', type: 'string', required: true, description: 'Directive value' } ], execute: (p) => this.memoryDB.setDirective(p.key, p.value) .then(() => `Directive set: ${p.key}=${p.value}`) }, { name: 'get_directive', category: 'memory', description: 'Retrieve a specific bot directive', parameters: [ { name: 'key', type: 'string', required: true, description: 'Directive key' } ], execute: async (p) => { const d = await this.memoryDB.getDirective(p.key); return d !== null ? `${p.key}=${d}` : `No directive for '${p.key}'`; } }, { name: 'list_directives', category: 'memory', description: 'List all active bot directives', parameters: [], execute: async () => { const all = await this.memoryDB.getAllDirectives(); const keys = Object.keys(all); return keys.length ? `Directives: ${JSON.stringify(all)}` : 'No active directives'; } } ); // Storage lookup tools (direct DB access, no lock needed) this._allTools.push( { name: 'lookup_item', category: 'storage', description: 'Check how many of an item we have in storage. Use this when asked "how much X do we have" or "do we have X".', parameters: [ { name: 'itemName', type: 'string', required: true, description: 'Item name or part of name' } ], execute: async (p) => { const Database = require('./storage/database'); const items = await Database.searchItems(p.itemName); if (items.length === 0) return `No items found matching '${p.itemName}'`; return items.slice(0, 8).map(i => `${i.item_name}: ${i.total_count}`).join(', '); } }, { name: 'list_items', category: 'storage', description: 'List top stocked items in storage. Use to see what items are available.', parameters: [ { name: 'limit', type: 'number', required: false, description: 'Max results (default 10)' } ], execute: async (p) => { const Database = require('./storage/database'); const items = await Database.searchItems(null); const limit = p.limit || 10; return items.slice(0, limit).map(i => `${i.item_name}: ${i.total_count}`).join(', '); } } ); // Trade tools this._allTools.push( { name: 'trade_initiate', category: 'trade', description: 'Start a trade with a player. Returns trade window state.', parameters: [ { name: 'playerName', type: 'string', required: true, description: 'Player to trade with' } ], execute: (p, from) => this._tradeInitiate(p.playerName || from) }, { name: 'trade_accept', category: 'trade', description: 'Accept an incoming trade request. Returns trade window state.', parameters: [], execute: () => this._tradeAccept() }, { name: 'trade_decline', category: 'trade', description: 'Cancel/close the active trade window.', parameters: [], execute: () => this._tradeDecline() }, { name: 'trade_confirm', category: 'trade', description: 'Confirm the trade on the bot side (ready check).', parameters: [], execute: () => this._tradeConfirm() }, { name: 'trade_status', category: 'trade', description: 'Get current trade window state (items on each side).', parameters: [], execute: () => this._tradeWindowState || 'No active trade window' } ); } _getToolsSchema() { return this._allTools.map(t => ({ name: t.name, description: t.description, parameters: { type: 'object', properties: (t.parameters || []).reduce((acc, p) => { acc[p.name] = { type: p.type || 'string', description: p.description || '' }; return acc; }, {}), required: t.parameters.filter(p => p.required).map(p => p.name) } })); } _getToolsDocumentation() { if (this._allTools.length === 0) return ''; // Group by category const byCategory = {}; for (const t of this._allTools) { const cat = t.category || 'other'; if (!byCategory[cat]) byCategory[cat] = []; byCategory[cat].push(t); } let doc = ''; for (const [cat, tools] of Object.entries(byCategory)) { doc += `## ${cat}\n`; for (const t of tools) { doc += `- **${t.name}**: ${t.description}`; if (t.parameters && t.parameters.length > 0) { doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`; } doc += '\n'; } doc += '\n'; } return doc; } async _executeTool(toolName, params = {}, from = 'ai') { const tool = this._allTools.find(t => t.name === toolName); if (!tool) throw new Error(`Unknown tool: ${toolName}`); console.log(`AI tool: ${toolName}`, params); return tool.execute(params, from); } // ---- Trade tool implementations ---- async _tradeInitiate(playerName) { if (this._tradeWindow) { return `Trade window already open. Current state: ${JSON.stringify(this._tradeWindowState)}`; } const player = this.bot.bot.players[playerName]; if (!player) { return `Player "${playerName}" is not online or not in range.`; } console.log(`AI: Initiating /trade with ${playerName}`); await this.bot.say(`/trade ${playerName}`); // Wait for trade window to open (player must accept) const window = await Promise.race([ this.bot.once('windowOpen'), sleep(60000).then(() => null), ]); if (!window) { return `Trade request to ${playerName} timed out (60s). They may not have accepted.`; } this._setupTradeWindow(window, playerName); // Place any withdrawn items into the trade window const storage = this.bot.plunginsLoaded['Storage']; if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') { await storage.placeWithdrawnItemsInTrade(window, playerName); // Re-capture state after placing items this._tradeWindowState = this._captureTradeState(window); } return `Trade opened with ${playerName}. State: ${JSON.stringify(this._tradeWindowState)}`; } async _tradeAccept() { if (this._tradeWindow) { return `Trade window already open. Current state: ${JSON.stringify(this._tradeWindowState)}`; } console.log('AI: Accepting incoming trade'); // Use bot.chat directly to bypass sayAiSafe command filtering this.bot.bot.chat('/trade accept'); const window = await Promise.race([ this.bot.once('windowOpen'), sleep(30000).then(() => null), ]); if (!window) { return 'Trade accept timed out (30s). The trade request may have expired.'; } this._setupTradeWindow(window, 'incoming'); return `Trade accepted. State: ${JSON.stringify(this._tradeWindowState)}`; } async _tradeDecline() { if (!this._tradeWindow) { return 'No active trade to decline.'; } console.log('AI: Declining/cancelling trade'); try { this.bot.bot.closeWindow(this._tradeWindow); } catch (e) { // Window may already be closed } this._tradeWindow = null; this._tradeWindowState = null; this._expectingTradeWindow = false; return 'Trade declined/window closed.'; } async _tradeConfirm() { if (!this._tradeWindow) { return 'No active trade to confirm. Use trade_accept or trade_initiate first.'; } console.log('AI: Confirming trade'); this.bot.bot.moveSlotItem(37, 37); // Update state after confirmation this._tradeWindowState = this._captureTradeState(this._tradeWindow); return `Trade confirmed on bot side. State: ${JSON.stringify(this._tradeWindowState)}`; } // ---- Trade window management ---- _setupTradeWindow(window, playerName) { this._tradeWindow = window; this._tradeWindowState = this._captureTradeState(window); // Monitor customer-side slots for real-time feedback const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26]; for (const slot of customerSlots) { window.on(`updateSlot:${slot}`, () => { if (this._tradeWindow === window) { this._tradeWindowState = this._captureTradeState(window); this._ensurePolling(); } }); } // Monitor confirmation indicator slots window.on('updateSlot:53', () => { if (this._tradeWindow === window) { this._tradeWindowState = this._captureTradeState(window); this._ensurePolling(); } }); window.on('updateSlot:37', () => { if (this._tradeWindow === window) { this._tradeWindowState = this._captureTradeState(window); this._ensurePolling(); } }); // Cleanup on window close const onClose = () => { if (this._tradeWindow === window) { this._tradeWindow = null; this._tradeWindowState = null; } }; this.bot.bot.once('windowClose', onClose); } _captureTradeState(window) { const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21]; const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26]; const readSlots = (slots) => slots .map(s => window.slots[s]) .filter(Boolean) .map(item => ({ name: item.name, count: item.count })); return { botItems: readSlots(botSlots), customerItems: readSlots(customerSlots), customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'), botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'), }; } /** * Process AI response and send to chat. * Strips markdown code fences, deduplicates messages, and skips noise. */ async processResponse(responseText) { if (!responseText) return; // Strip markdown code fences that some models wrap JSON in const cleaned = this._stripMarkdownFences(responseText); // Try to parse as JSON array [{text, delay}] try { const parsed = JSON.parse(cleaned); if (Array.isArray(parsed)) { for (let message of parsed) { const msgText = (message.text || '').trim(); console.log('toSay', message.delay, msgText); // Skip empty, underscore-only, or noise responses if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue; // Deduplicate — don't send the same message twice const dedupeKey = msgText.toLowerCase(); if (this._lastSentMessages.includes(dedupeKey)) { console.log('Skipping duplicate message:', msgText); continue; } if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift(); this._lastSentMessages.push(dedupeKey); await this.bot.sayAiSafe(msgText); } return; } } catch (jsonError) { // Not valid JSON array — fall through to plain text } // Plain text fallback const text = cleaned.trim(); if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return; // Don't send raw JSON or code blocks if (text.startsWith('{') || text.startsWith('```')) { console.log('Skipping raw JSON/code block response:', text.substring(0, 80)); return; } const dedupeKey = text.toLowerCase(); if (this._lastSentMessages.includes(dedupeKey)) { console.log('Skipping duplicate plain-text message:', text); return; } if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift(); this._lastSentMessages.push(dedupeKey); await this.bot.sayAiSafe(text); } /** * Strip markdown code fences from AI response text. */ _stripMarkdownFences(text) { if (!text || typeof text !== 'string') return text; let cleaned = text.trim(); cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, ''); cleaned = cleaned.replace(/\n?```\s*$/, ''); return cleaned.trim(); } /** * Extract the last player who addressed this bot from recent messages * @param {Array} messages - Array of message objects {type, text, ...} * @returns {string} Player name or 'ai' if no player found */ getLastSpeaker(messages) { if (!messages || !Array.isArray(messages)) return 'ai'; // Search in reverse to find the most recent player message for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (!msg || msg.type !== 'message') continue; const text = msg.text || ''; // Parse format: <[lvl] playername> message text const match = text.match(/^<\[.*?\]\s+(\w+)>/); if (match && match[1] && match[1] !== this.bot.bot.entity.username) { return match[1]; } } return 'ai'; } getTimeAgo(timestamp) { const now = new Date(); const past = new Date(timestamp); const diffMs = now - past; const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return 'just now'; if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; return past.toLocaleDateString(); } /** * Get current time info for prompt injection */ getCurrentTimeInfo() { const now = new Date(); return { iso: now.toISOString().replace('T', ' ').substring(0, 19), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, human: now.toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }) }; } } const AiWeb = require('./ai/web'); Ai.createRouter = AiWeb.createRouter; Ai.webUI = AiWeb.webUI; module.exports = Ai;