'use strict'; const conf = require('../../conf'); const { sleep } = require('../../utils'); const { ProviderFactory } = require('./providers'); function compilePrompt(templateStr) { return new Function('name', 'interval', 'currentPlayers', 'toolsDocs', 'memoryContext', 'timeInfo', 'custom', 'return `' + templateStr + '`'); } const memoryDB = require('./memory-db'); const { buildFleetTools } = require('./fleet-tools'); class AiManager { constructor() { this._provider = null; this._pollTimer = null; this._polling = false; this._faceBot = null; this._messages = []; this._allTools = []; this._memoryDB = memoryDB; this._active = false; this._config = null; this._consecutiveFailures = 0; this._backoffUntil = 0; this._lastSentMessages = []; this._messageListener = null; this._tradeWindow = null; this._tradeWindowState = null; this._expectingTradeWindow = false; } /** * Initialize the manager on a face bot. Idempotent — subsequent calls * with different bots are no-ops. Call shutdown() first to transfer. */ async init(faceBot, configOverride) { if (this._active) { if (this._faceBot === faceBot) { console.log('AiManager: already running on', faceBot.name); return; } console.log('AiManager: already running on', this._faceBot.name, '— shutdown first before re-initting on', faceBot.name); return; } this._faceBot = faceBot; this._config = { ...conf.ai, ...configOverride }; // Override runtime-tunable AI settings from DB (config file provides defaults) const settings = require('../settings/manager'); this._config.provider = settings.get('ai.provider'); this._config.model = settings.get('ai.model'); this._config.temperature = settings.get('ai.temperature'); this._config.topP = settings.get('ai.topP'); this._config.topK = settings.get('ai.topK'); this._config.interval = settings.get('ai.interval'); this._config.timeout = settings.get('ai.timeout'); this._config.promptName = settings.get('ai.promptName'); this._config.enableNativeTools = settings.get('ai.enableNativeTools'); this._config.baseUrl = settings.get('ai.baseUrl'); this._config.key = settings.get('ai.key'); this._config.faceBot = settings.get('ai.faceBot'); this._config.storageBot = settings.get('ai.storageBot'); // Initialize memory DB (first init creates DB, subsequent are no-ops) await this._memoryDB.initialize('./storage/ai-memory.db', faceBot.name); // Build fleet-wide tools this._allTools = buildFleetTools(this._config, this._memoryDB); console.log(`AiManager: ${this._allTools.length} fleet tools built`); // Create the ONE provider const prompt = await this._buildPrompt(); this._provider = ProviderFactory.create({ ...this._config, prompt, }); // Set tool schemas if provider supports native function calling AND enabled in config. // Most 9B models crash on native tool schemas (Ollama 500: "XML syntax error"). // When disabled, the LLM sees tools described in the prompt text and we parse // tool calls from the JSON response via _extractToolCalls fallback. if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) { this._provider.setTools(this._getToolsSchema()); console.log('AiManager: native tool schemas set on provider'); } else if (this._config.enableNativeTools) { console.log('AiManager: provider does not support tools, using text-based fallback'); } else { console.log('AiManager: native tools disabled via config, using text-based tool descriptions'); } await this._provider.start(); console.log(`AiManager: provider started (${this._config.provider}, model=${this._config.model})`); // Set up message listener on the face bot this._setupMessageListener(); // Start poll timer this._active = true; this._startPolling(); console.log(`AiManager: running on ${faceBot.name}, interval=${this._config.interval}s`); } /** * Reload the system prompt without restarting the provider. * Used by the .ai chat command to change personality on the fly. */ async reloadPrompt(promptName, prompCustom) { if (!this._active || !this._provider) return false; if (promptName !== undefined) this._config.promptName = promptName; if (prompCustom !== undefined) this._config.prompCustom = prompCustom; const prompt = await this._buildPrompt(); if (typeof this._provider.setPrompt === 'function') { this._provider.setPrompt(prompt); } else { await this._provider.close(); this._provider = ProviderFactory.create({ ...this._config, prompt }); if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) { this._provider.setTools(this._getToolsSchema()); } await this._provider.start(); } console.log(`AiManager: prompt reloaded — ${this._config.promptName}`); return true; } async shutdown() { this._active = false; if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; } if (this._messageListener) { this._messageListener(); this._messageListener = null; } if (this._provider) { try { await this._provider.close(); } catch (e) { /* ignore */ } this._provider = null; } this._messages = []; this._faceBot = null; console.log('AiManager: shut down'); } get isActive() { return this._active; } get faceBotName() { return this._faceBot ? this._faceBot.name : null; } /** * Called by SettingsManager when an ai.* setting is changed. * Updates the in-memory config immediately. Provider re-creation * happens on the next poll cycle if model/provider/key changed. */ onSettingChanged(key, newValue) { if (!this._config) return; const shortKey = key.replace('ai.', ''); console.log(`AiManager: setting changed — ${key} = ${JSON.stringify(newValue)}`); this._config[shortKey] = newValue; if (['promptName', 'prompCustom', 'prompts'].includes(shortKey)) { this.reloadPrompt(); } } /** * Let other bots/plugins inject a system notification into the face bot's * message queue so the LLM knows about trade completions, errors, etc. * Call this whenever a fleet bot does something the LLM should know about. */ notifySystemEvent(text) { if (!this._active) return; const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); this._messages.push({ type: 'system', text: `[SYSTEM] ${text}`, timestamp, timeAgo: this._getTimeAgo(timestamp), }); console.log(`AiManager: system event — ${text}`); } // ======================================== // Message listener // ======================================== _setupMessageListener() { this._messageListener = this._faceBot.on('message', (message, type) => { const msgText = message.toString(); // Log ALL messages the face bot receives (for debugging) const cleanText = msgText.replace(/\n/g, '\\n').substring(0, 300); console.log(`[MC→${this._faceBot.name}] (${type || 'unknown'}) ${cleanText}`); if (type === 'game_info') return; // Skip messages from the face bot itself if (msgText.startsWith('<')) { const firstBracket = msgText.split('>')[0]; const userMatch = firstBracket.match(/^<\[?.*?\]?\s*(\w+)>$/); if (userMatch) { const speakerName = userMatch[1]; if (speakerName === this._faceBot.bot.entity.username) return; } } const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); this._messages.push({ type: 'message', text: msgText, timestamp, timeAgo: this._getTimeAgo(timestamp), }); }); // Monitor trade windows for feedback loop this._faceBot.bot.on('windowOpen', (window) => { if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) { this._expectingTradeWindow = false; console.log('AiManager: trade window detected, capturing state'); this._setupTradeWindow(window); } }); } // ======================================== // Polling // ======================================== _startPolling() { const intervalMs = (this._config.interval || 10) * 1000; this._pollTimer = setInterval(() => this._pollCycle(), intervalMs); } async _pollCycle() { if (!this._active || Date.now() < this._backoffUntil) return; if (this._polling) return; this._polling = true; try { // Snapshot and reset const currentMessages = [...this._messages]; this._messages = []; const hasData = currentMessages.some(m => typeof m === 'object' && m.text); const hasTrade = !!this._tradeWindowState; if (!hasData && !hasTrade) return; // Build request data const requestData = { botName: this._faceBot.name, messages: currentMessages, currentTime: new Date().toLocaleString('sv-SE'), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, tradeWindow: this._tradeWindowState, }; // Refresh the system prompt with current memories and online players. // Ollama re-sends the system prompt on every request, so new // memories take effect immediately. (Gemini bakes the prompt into // session history and only picks this up on provider restart.) try { if (typeof this._provider.setPrompt === 'function') { this._provider.setPrompt(await this._buildPrompt()); } } catch (e) { console.log('AiManager: prompt refresh failed:', e.message); } console.log(`AiManager: poll cycle — ${currentMessages.length} messages`); let result; try { result = await this._provider.chat(JSON.stringify(requestData)); } catch (error) { console.log('AiManager: API error:', error.message); this._consecutiveFailures++; const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000); this._backoffUntil = Date.now() + backoffMs; console.log(`AiManager: backoff ${backoffMs}ms (#${this._consecutiveFailures})`); return; } this._consecutiveFailures = 0; this._backoffUntil = 0; // Check for tool calls const requestingPlayer = this._getLastSpeaker(currentMessages); const toolCalls = this._extractToolCalls(result); if (toolCalls && toolCalls.length > 0) { const seen = new Set(); const uniqueCalls = toolCalls.filter(tc => { const key = `${tc.name}:${JSON.stringify(tc.args || {})}`; if (seen.has(key)) { console.log('AiManager: deduplicating tool call:', key); return false; } seen.add(key); return true; }); console.log(`AiManager: ${uniqueCalls.length} tool calls — ${uniqueCalls.map(c => c.name).join(', ')}`); // Execute tools const toolResults = []; for (const tc of uniqueCalls) { try { const toolResult = await this._executeTool(tc.name, tc.args || {}, requestingPlayer); toolResults.push({ name: tc.name, result: toolResult, success: true }); } catch (execError) { console.error('AiManager: tool execution error:', execError); toolResults.push({ name: tc.name, error: execError.message, success: false }); } } // Follow-up: send tool results back to LLM for natural language response if (toolResults.length > 0) { // Build current fleet status for context const fleetStatus = this._getLiveFleetStatus(); const followupMsg = JSON.stringify({ toolResults, fleetStatus, tradeWindow: this._tradeWindowState, instruction: `Tool results and fleet status above. - Fleet bots listed as ONLINE can be interacted with directly by players via /trade. - If ${this._config.storageBot} is ONLINE, the player can trade with them without you doing anything. - Reply with ONE brief message (max 150 chars) as a plain JSON array: [{"text":"...","delay":0}]. - Be natural and casual. If the tool failed, apologize naturally. NEVER mention "tool", "bot", or "AI".` }); try { const followupResult = await this._provider.chat(followupMsg); const responseText = this._provider.getResponse(followupResult); await this._processResponse(responseText); } catch (e) { console.error('AiManager: follow-up chat error:', e); } } return; } // No tool calls — normal chat response const responseText = this._provider.getResponse(result); await this._processResponse(responseText); } catch (error) { console.error('AiManager: poll cycle error:', error); } finally { this._polling = false; } } // ======================================== // Tool execution // ======================================== async _executeTool(toolName, params, from) { const tool = this._allTools.find(t => t.name === toolName); if (!tool) throw new Error(`Unknown tool: ${toolName}`); console.log(`AiManager: executing ${toolName}`, params); return tool.execute(params, from); } _extractToolCalls(result) { // Gemini native function calls 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, args: call.args })); } } // Ollama native tool_calls if (result.tool_calls && result.tool_calls.length > 0) { return result.tool_calls.map(tc => ({ name: tc.name || tc.function?.name, args: tc.args || tc.arguments || tc.function?.arguments || {}, })); } // Fallback: parse JSON from response text const responseText = result.response ? result.response.text() : null; if (responseText) { try { const parsed = JSON.parse(responseText); if (parsed.tool_call) { return [{ name: parsed.tool_call.name, args: parsed.tool_call.args || parsed.tool_call.arguments || {} }]; } if (Array.isArray(parsed.tool_calls)) { return parsed.tool_calls.map(tc => ({ name: tc.name || tc.function?.name, args: tc.args || tc.arguments || tc.function?.arguments || {}, })); } } catch (e) { // Not JSON, no tool calls } // Final fallback: detect tool names embedded in chat text // This catches models that output [{"text":"storage_find stone","delay":0}] const detectedCalls = this._detectTextToolCalls(responseText); if (detectedCalls) return detectedCalls; } return null; } /** * Detect when the LLM embeds tool names in chat message text instead of * using the proper tool_call format. Parses JSON arrays like * [{"text":"storage_find stone","delay":0}] and converts to tool calls. */ _detectTextToolCalls(responseText) { try { const parsed = JSON.parse(responseText); // Handle JSON array of chat messages: [{"text":"storage_find stone","delay":0}] if (Array.isArray(parsed)) { const toolNames = this._allTools.map(t => t.name); const toolCalls = []; for (const msg of parsed) { const text = (msg.text || '').trim(); if (!text || text === '_' || text.length < 3) continue; for (const toolName of toolNames) { if (text === toolName || text.startsWith(toolName + ' ')) { const argsStr = text.substring(toolName.length).trim(); const args = this._parseTextArgs(argsStr, toolName); console.log(`AiManager: detected text tool call in chat array: ${toolName}`, args); toolCalls.push({ name: toolName, args }); break; } } } return toolCalls.length > 0 ? toolCalls : null; } } catch (e) { // Not JSON — check if the raw text starts with a known tool name const text = (responseText || '').trim(); if (text.length < 3 || text.startsWith('{') || text.startsWith('[')) return null; const toolNames = this._allTools.map(t => t.name); for (const toolName of toolNames) { if (text === toolName || text.startsWith(toolName + ' ')) { const argsStr = text.substring(toolName.length).trim(); const args = this._parseTextArgs(argsStr, toolName); console.log(`AiManager: detected text tool call in raw text: ${toolName}`, args); return [{ name: toolName, args }]; } } } return null; } /** * Parse space-separated text args into a structured object based on the * tool's parameter definitions. Handles formats like: * "stone" → {itemName: "stone"} * "stone 64 wmantly" → {itemName: "stone", count: 64, playerName: "wmantly"} */ _parseTextArgs(argsStr, toolName) { const tool = this._allTools.find(t => t.name === toolName); if (!tool || !tool.parameters || tool.parameters.length === 0) return {}; const parts = argsStr.split(/\s+/); const params = tool.parameters; const args = {}; for (let i = 0; i < Math.min(parts.length, params.length); i++) { const value = parts[i]; const paramType = params[i].type || 'string'; if (paramType === 'number') { const num = parseInt(value, 10); args[params[i].name] = isNaN(num) ? value : num; } else { args[params[i].name] = value; } } return args; } _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), }, })); } // ======================================== // Prompt building // ======================================== async _buildPrompt() { const settings = require('../settings/manager'); const allPrompts = settings.get('ai.prompts') || {}; const promptName = this._config.promptName || 'asshole'; let templateStr = allPrompts[promptName]; let promptFn; if (templateStr) { try { promptFn = compilePrompt(templateStr); } catch (e) { console.warn(`AiManager: failed to compile prompt '${promptName}', falling back to config:`, e.message); promptFn = conf.ai.prompts[promptName]; } } else { console.warn(`AiManager: prompt '${promptName}' not found in DB or config, falling back to asshole`); templateStr = allPrompts['asshole']; if (templateStr) { try { promptFn = compilePrompt(templateStr); } catch (e) { promptFn = conf.ai.prompts['asshole']; } } else { promptFn = conf.ai.prompts['asshole']; } this._config.promptName = 'asshole'; } if (!promptFn) { console.warn(`AiManager: prompt '${promptName}' not found, falling back to asshole`); return conf.ai.prompts['asshole']( this._faceBot.bot.entity.username, this._config.interval, '', '', '', '', '', ); } const fleetContext = this._getFleetContext(); const rawToolsDocs = this._getToolsDocumentation(); const toolsDocs = fleetContext + (rawToolsDocs ? '\n' + rawToolsDocs : ''); let currentPlayers = ''; try { const players = this._faceBot.getPlayers(); currentPlayers = Object.values(players) .map(p => `<[${p.lvl}] ${p.username}>`) .join('\n'); } catch (e) { currentPlayers = '(players unavailable)'; } const timeInfo = this._getCurrentTimeInfo(); const memoryContext = await this._getMemoryContext(); return promptFn( this._faceBot.bot.entity.username, this._config.interval, currentPlayers, toolsDocs, memoryContext, timeInfo, this._config.prompCustom || '', ); } /** * Assemble the memory block for the system prompt: bot directives, * general memories, and stored facts about every player currently online. */ async _getMemoryContext() { let context = ''; try { const general = await this._memoryDB.getMemoryContext(this._faceBot.name); if (general) context += general; // Player memories for everyone online right now (skip fleet bots) const { CJbot } = require('../../model/minecraft'); const botUsernames = new Set( Object.values(CJbot.bots) .map(b => b.bot?.entity?.username) .filter(Boolean) ); const onlinePlayers = Object.keys(this._faceBot.bot.players || {}) .filter(name => !botUsernames.has(name)); const playerMems = await this._memoryDB.getPlayerMemoriesForPrompt(onlinePlayers); if (playerMems) { context += 'WHAT YOU KNOW ABOUT PLAYERS CURRENTLY ONLINE (from past conversations — use it naturally, do not recite it):\n' + playerMems + '\n'; } } catch (e) { console.log('AiManager: memory context build failed:', e.message); } return context.trim(); } _getFleetContext() { const { CJbot } = require('../../model/minecraft'); const botNames = Object.keys(CJbot.bots); const onlineBots = botNames.filter(name => CJbot.bots[name].isReady); const offlineBots = botNames.filter(name => !CJbot.bots[name].isReady); const fleetBots = botNames.map(name => { const b = CJbot.bots[name]; const status = b.isReady ? 'online' : 'offline (on-demand)'; if (name === this._faceBot.name) return `- ${name} (YOU — always online)`; if (name === this._config.storageBot) return `- ${name} (storage, ${status})`; return `- ${name} (${status})`; }).join('\n'); const nativeToolsNote = this._config.enableNativeTools ? '' : ` TOOL CALL FORMAT (CRITICAL — you MUST use this exact format): When you need to call a tool, respond with a SINGLE JSON object: {"tool_call": {"name": "tool_name", "args": {"param1": "value1", "param2": "value2"}}} Example: to search for stone, respond with: {"tool_call": {"name": "storage_find", "args": {"itemName": "stone"}}} NEVER put tool names in chat text messages. NEVER type "storage_find stone" as a chat message. Use the {"tool_call": ...} format above instead. For normal chat (no tools needed), respond with the array format: [{"text": "your message", "delay": 0}] `; return `FLEET BOTS YOU CAN CONTROL: ${fleetBots} ${nativeToolsNote} CRITICAL: Every other name in chat is a PLAYER. Do NOT call bot_activate, bot_status, or any fleet tool on player names. Only use those tools for the fleet bots listed above. MEMORY (IMPORTANT — this is how you remember people between sessions): - When a player tells you a fact worth keeping — about themselves OR another player (base location, current project, who they trust, preferences, grudges, play schedule) — call remember_player RIGHT THEN. Don't wait to be asked to remember. - Keys are short snake_case: base_location, current_project, trust, friend_of, notes, warned_about. - One fact per key. Writing to an existing key overwrites it — use that to update stale facts. - What you already know about online players is in your context under "WHAT YOU KNOW ABOUT PLAYERS". For players not online, use recall_player. - If a fact turns out wrong, call forget_player to remove that key. - Standing orders from owners ("always greet new players", "stop mentioning X") are directives: use set_directive. CHARACTER RULES (YOU WILL BE REMOVED IF YOU VIOLATE): - You are a human player. NEVER say "bot", "AI", "tool", "fleet", or "activate." - NEVER type server commands (/msg, /trade, /invite, /help, /w, /tell) in public chat. If you need to message someone, use your tools instead. - NEVER suggest commands to other players ("type /msg X", "do /trade Y"). That's robotic. - Keep responses under 120 chars. Casual, brief, natural. - ez is "a friend who handles my storage" if asked. Not a bot.`; } /** * Lightweight fleet status snapshot for post-tool-execution follow-up. * Tells the LLM which bots are online and where they are so it doesn't * say incorrect things like "he can't find you" when the bot is already there. */ _getLiveFleetStatus() { const { CJbot } = require('../../model/minecraft'); const statuses = []; for (const [name, bot] of Object.entries(CJbot.bots)) { if (bot.isReady && bot.bot?.entity) { const e = bot.bot.entity; statuses.push({ bot: name, status: 'ONLINE', pos: `(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})` }); } else { statuses.push({ bot: name, status: 'OFFLINE' }); } } return statuses; } _getToolsDocumentation() { if (this._allTools.length === 0) return ''; 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; } // ======================================== // Response processing // ======================================== async _processResponse(responseText) { if (!responseText) return; const cleaned = this._stripMarkdownFences(responseText); // Try JSON array [{text, delay}] try { const parsed = JSON.parse(cleaned); if (Array.isArray(parsed)) { for (const message of parsed) { const msgText = (message.text || '').trim(); console.log('AiManager: toSay delay=', message.delay, msgText); if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue; const dedupeKey = msgText.toLowerCase(); if (this._lastSentMessages.includes(dedupeKey)) { console.log('AiManager: skipping duplicate:', msgText); continue; } if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift(); this._lastSentMessages.push(dedupeKey); await this._faceBot.sayAiSafe(msgText); } return; } } catch (jsonError) { // Not JSON, fall through } // Plain text fallback const text = cleaned.trim(); if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return; if (text.startsWith('{') || text.startsWith('```')) { console.log('AiManager: skipping raw JSON/code block'); return; } const dedupeKey = text.toLowerCase(); if (this._lastSentMessages.includes(dedupeKey)) { console.log('AiManager: skipping duplicate plain-text:', text); return; } if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift(); this._lastSentMessages.push(dedupeKey); await this._faceBot.sayAiSafe(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(); } // ======================================== // Trade window // ======================================== _setupTradeWindow(window) { this._tradeWindow = window; this._tradeWindowState = this._captureTradeState(window); 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); } }); } window.on('updateSlot:53', () => { if (this._tradeWindow === window) { this._tradeWindowState = this._captureTradeState(window); } }); window.on('updateSlot:37', () => { if (this._tradeWindow === window) { this._tradeWindowState = this._captureTradeState(window); } }); this._faceBot.bot.once('windowClose', () => { if (this._tradeWindow === window) { this._tradeWindow = null; this._tradeWindowState = null; } }); } _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'), }; } // ======================================== // Helpers // ======================================== _getLastSpeaker(messages) { if (!messages || !Array.isArray(messages)) return 'ai'; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (!msg || msg.type !== 'message') continue; const text = msg.text || ''; const match = text.match(/^<\[?.*?\]?\s+(\w+)>/); if (match && match[1] && match[1] !== this._faceBot.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}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return past.toLocaleDateString(); } _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, }), }; } } // Singleton let _instance = null; module.exports = { getInstance() { if (!_instance) _instance = new AiManager(); return _instance; }, AiManager, };