forked from wmantly/mc-bot-town
fable
This commit is contained in:
@@ -0,0 +1,867 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user