forked from wmantly/mc-bot-town
fable
This commit is contained in:
+93
-860
@@ -1,869 +1,102 @@
|
||||
'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 <username> 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
|
||||
})
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Ai plugin — thin shim.
|
||||
*
|
||||
* The real work (LLM provider, poll timer, tool registry, response processing)
|
||||
* lives in AiManager. This class exists only for compatibility with CJbot's
|
||||
* plugin system: pluginAdd/pluginLoad/pluginUnload, the .ai chat command,
|
||||
* and the trade plugin's _expectingTradeWindow flag.
|
||||
*
|
||||
* Only ONE bot should load this plugin. The config key `ai.faceBot` names it.
|
||||
*/
|
||||
class Ai {
|
||||
constructor(args) {
|
||||
this.bot = args.bot;
|
||||
|
||||
// Extract config — same destructure as the old Ai class
|
||||
const { bot: _bot, promptName, prompCustom, intervalLength, interval, ...configProps } = args;
|
||||
this.promptName = promptName;
|
||||
this.prompCustom = prompCustom || '';
|
||||
this.botConfig = args.botConfig || configProps || {};
|
||||
}
|
||||
|
||||
async init() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
const manager = getInstance();
|
||||
|
||||
// If manager is already active on another bot, that's a config conflict
|
||||
if (manager.isActive && manager.faceBotName !== this.bot.name) {
|
||||
console.warn(`Ai: manager already running on ${manager.faceBotName} — ignoring init on ${this.bot.name}`);
|
||||
console.warn(`Ai: shutdown the existing Ai plugin first, or change ai.faceBot in config`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If already active on THIS bot, this is a reload (e.g. .ai command)
|
||||
if (manager.isActive && manager.faceBotName === this.bot.name) {
|
||||
await manager.reloadPrompt(this.promptName, this.prompCustom);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh init
|
||||
await manager.init(this.bot, this._getConfig());
|
||||
this.bot._aiControlsTrade = true;
|
||||
console.log(`Ai: ${this.bot.name} is the face bot`);
|
||||
}
|
||||
|
||||
async unload() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
const manager = getInstance();
|
||||
|
||||
if (manager.faceBotName === this.bot.name) {
|
||||
await manager.shutdown();
|
||||
}
|
||||
delete this.bot._aiControlsTrade;
|
||||
console.log(`Ai: unloaded from ${this.bot.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
_getConfig() {
|
||||
return { ...conf.ai, ...this.botConfig };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Backward-compat proxies
|
||||
// These are accessed by trade.js and the web UI
|
||||
// ========================================
|
||||
|
||||
get _expectingTradeWindow() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._expectingTradeWindow;
|
||||
}
|
||||
set _expectingTradeWindow(val) {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
getInstance()._expectingTradeWindow = val;
|
||||
}
|
||||
|
||||
get _active() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance().isActive;
|
||||
}
|
||||
|
||||
get memoryDB() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._memoryDB;
|
||||
}
|
||||
|
||||
get intervalLength() {
|
||||
return this._getConfig().interval || 10;
|
||||
}
|
||||
|
||||
get provider() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._provider;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Web UI support — unchanged pattern, but routes now use the manager internally
|
||||
const AiWeb = require('./ai/web');
|
||||
Ai.createRouter = AiWeb.createRouter;
|
||||
Ai.webUI = AiWeb.webUI;
|
||||
|
||||
Reference in New Issue
Block a user