forked from wmantly/mc-bot-town
868 lines
34 KiB
JavaScript
868 lines
34 KiB
JavaScript
'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,
|
|
};
|