forked from wmantly/mc-bot-town
Mostly works
This commit is contained in:
+772
-73
@@ -3,6 +3,7 @@
|
||||
const conf = require('../conf');
|
||||
const {sleep} = require('../utils');
|
||||
const { ProviderFactory } = require('./ai/providers');
|
||||
const memoryDB = require('./ai/memory-db');
|
||||
|
||||
|
||||
class Ai{
|
||||
@@ -15,6 +16,20 @@ class Ai{
|
||||
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
|
||||
@@ -32,84 +47,73 @@ class Ai{
|
||||
}
|
||||
|
||||
async init(){
|
||||
this.bot.on('onReady', async (argument)=>{
|
||||
try{
|
||||
await this.start();
|
||||
let messages = [''];
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
this.messageListener = this.bot.on('message', (message, type)=>{
|
||||
if(type === 'game_info') return;
|
||||
if(message.toString().startsWith('<') && message.toString().split('>')[0].includes(this.bot.bot.entity.username)){
|
||||
console.log('message blocked from message array')
|
||||
return;
|
||||
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()}`)
|
||||
messages.push('>', message.toString());
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
this.intervalStop = setInterval(async ()=>{
|
||||
let result;
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
try{
|
||||
result = await this.chat(JSON.stringify({
|
||||
messages, currentTime:Date.now()+1}
|
||||
));
|
||||
}catch(error){
|
||||
console.log('error AI API', error, result);
|
||||
messages = [];
|
||||
return ;
|
||||
}
|
||||
console.log(`${this.bot.name} AI ready — waiting for chat activity`);
|
||||
|
||||
try{
|
||||
messages = [''];
|
||||
const responseText = this.provider.getResponse(result);
|
||||
if(!responseText) return;
|
||||
|
||||
// Try to parse JSON response
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
if(Array.isArray(parsed)){
|
||||
for(let message of parsed){
|
||||
console.log('toSay', message.delay, message.text);
|
||||
if(message.text.trim().startsWith('_')) return;
|
||||
setTimeout(async (message)=>{
|
||||
await this.bot.sayAiSafe(message.text);
|
||||
}, 0*1000, message);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Response is not an array');
|
||||
}
|
||||
} catch(jsonError){
|
||||
// JSON parsing failed, treat as plain text
|
||||
console.log('JSON parse failed, treating as plain text:', responseText.substring(0, 100));
|
||||
// Skip empty responses, underscore signals, and single dash signals
|
||||
const text = responseText.trim();
|
||||
if(text && text !== '___' && !text.match(/^[-_]+$/)){
|
||||
await this.bot.sayAiSafe(text);
|
||||
}
|
||||
}
|
||||
}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
|
||||
}
|
||||
}
|
||||
}, this.intervalLength*1000);
|
||||
|
||||
}catch(error){
|
||||
console.log('error in onReady', error);
|
||||
}
|
||||
});
|
||||
} catch(error) {
|
||||
console.error(`${this.bot.name} AI setup failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async unload(){
|
||||
if(this.intervalStop){
|
||||
clearInterval(this.intervalStop);
|
||||
this.intervalStop = undefined;
|
||||
if(this._pollTimer){
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
if(this.messageListener){
|
||||
this.messageListener();
|
||||
@@ -117,13 +121,143 @@ class Ai{
|
||||
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();
|
||||
let bulbaItems = {};
|
||||
|
||||
console.log(`${this.bot.name} AI config:`, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
@@ -133,11 +267,37 @@ class Ai{
|
||||
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'),
|
||||
bulbaItems,
|
||||
toolsDocs,
|
||||
fullMemoryContext,
|
||||
timeInfo,
|
||||
this.prompCustom,
|
||||
);
|
||||
|
||||
@@ -147,6 +307,11 @@ class Ai{
|
||||
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})`);
|
||||
}
|
||||
@@ -161,6 +326,540 @@ class Ai{
|
||||
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
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,4 +868,4 @@ const AiWeb = require('./ai/web');
|
||||
Ai.createRouter = AiWeb.createRouter;
|
||||
Ai.webUI = AiWeb.webUI;
|
||||
|
||||
module.exports = Ai;
|
||||
module.exports = Ai;
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
'use strict';
|
||||
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* AI Memory Database
|
||||
* Stores player-specific memories and bot directives
|
||||
* Singleton pattern - one instance per bot
|
||||
*/
|
||||
|
||||
class AIMemoryDB {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.botName = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection and create tables
|
||||
* @param {string} dbPath - Path to sqlite database file
|
||||
* @param {string} botName - Bot name for namespacing
|
||||
*/
|
||||
async initialize(dbPath = './storage/ai-memory.db', botName = 'default') {
|
||||
if (this.db) {
|
||||
this.botName = botName;
|
||||
return; // Already initialized
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(dbPath);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`AI Memory: Initializing database at ${fullPath} for bot ${botName}`);
|
||||
|
||||
this.db = new sqlite3.Database(fullPath);
|
||||
this.botName = botName;
|
||||
|
||||
await this.createTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tables if they don't exist
|
||||
*/
|
||||
createTables() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.serialize(() => {
|
||||
// Player memories table
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS player_memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_name TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
memory_key TEXT NOT NULL,
|
||||
memory_value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(bot_name, player_name, memory_key)
|
||||
)
|
||||
`);
|
||||
|
||||
// Bot directives table - persistent instructions per bot
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS bot_directives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_name TEXT NOT NULL,
|
||||
directive_key TEXT NOT NULL,
|
||||
directive_value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(bot_name, directive_key)
|
||||
)
|
||||
`);
|
||||
|
||||
// General memories table - for non-player-specific info
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS general_memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_name TEXT NOT NULL,
|
||||
memory_key TEXT NOT NULL,
|
||||
memory_value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(bot_name, memory_key)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for faster lookups
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_player_memories_lookup ON player_memories(bot_name, player_name)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_bot_directives_lookup ON bot_directives(bot_name)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_general_memories_lookup ON general_memories(bot_name)`);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Player Memories (Shared across all bots)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Set a memory about a specific player (shared across all bots)
|
||||
*/
|
||||
async setPlayerMemory(playerName, key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO player_memories (bot_name, player_name, memory_key, memory_value, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, ['global', playerName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific memory about a player (shared across all bots)
|
||||
*/
|
||||
async getPlayerMemory(playerName, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
`, [playerName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all memories about a player (shared across all bots)
|
||||
*/
|
||||
async getAllPlayerMemories(playerName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ?
|
||||
`, [playerName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const memories = {};
|
||||
for (const row of rows) {
|
||||
memories[row.memory_key] = row.memory_value;
|
||||
}
|
||||
resolve(memories);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific player memory (shared across all bots)
|
||||
*/
|
||||
async deletePlayerMemory(playerName, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
DELETE FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
`, [playerName, key], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all players with stored memories (shared across all bots)
|
||||
*/
|
||||
async getAllKnownPlayers() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT DISTINCT player_name FROM player_memories
|
||||
WHERE bot_name = 'global'
|
||||
`, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows.map(r => r.player_name));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Bot Directives
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Set a directive for this bot
|
||||
*/
|
||||
async setDirective(key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO bot_directives (bot_name, directive_key, directive_value, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [this.botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific directive
|
||||
*/
|
||||
async getDirective(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT directive_value FROM bot_directives
|
||||
WHERE bot_name = ? AND directive_key = ?
|
||||
`, [this.botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.directive_value : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directives for this bot
|
||||
*/
|
||||
async getAllDirectives() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT directive_key, directive_value FROM bot_directives
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const directives = {};
|
||||
for (const row of rows) {
|
||||
directives[row.directive_key] = row.directive_value;
|
||||
}
|
||||
resolve(directives);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// General Memories
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Set a general memory (not player-specific)
|
||||
*/
|
||||
async setGeneralMemory(key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO general_memories (bot_name, memory_key, memory_value, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [this.botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a general memory
|
||||
*/
|
||||
async getGeneralMemory(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT memory_value FROM general_memories
|
||||
WHERE bot_name = ? AND memory_key = ?
|
||||
`, [this.botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all general memories
|
||||
*/
|
||||
async getAllGeneralMemories() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM general_memories
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const memories = {};
|
||||
for (const row of rows) {
|
||||
memories[row.memory_key] = row.memory_value;
|
||||
}
|
||||
resolve(memories);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all general memories with timestamps
|
||||
*/
|
||||
async getAllGeneralMemoriesWithTimestamps() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value, updated_at FROM general_memories
|
||||
WHERE bot_name = ?
|
||||
ORDER BY updated_at DESC
|
||||
`, [this.botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Summary Methods (for AI context)
|
||||
// ========================================
|
||||
/**
|
||||
* Get all directives as formatted string with timestamps
|
||||
*/
|
||||
async getDirectivesSummary() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT directive_key, directive_value, updated_at FROM bot_directives
|
||||
WHERE bot_name = ?
|
||||
ORDER BY updated_at DESC
|
||||
`, [this.botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
if (rows.length === 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = 'Active Directives:\n';
|
||||
for (const row of rows) {
|
||||
const timeAgo = this.formatTimeAgo(row.updated_at);
|
||||
summary += `- ${row.directive_key}: ${row.directive_value} (set ${timeAgo})\n`;
|
||||
}
|
||||
resolve(summary.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp as relative time string
|
||||
*/
|
||||
formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return 'unknown';
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full memory context for prompt injection
|
||||
*/
|
||||
async getMemoryContext() {
|
||||
const directives = await this.getDirectivesSummary();
|
||||
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps();
|
||||
|
||||
let context = '';
|
||||
|
||||
if (directives) {
|
||||
context += directives + '\n\n';
|
||||
}
|
||||
|
||||
if (generalMemories && generalMemories.length > 0) {
|
||||
context += 'General Memories:\n';
|
||||
for (const mem of generalMemories) {
|
||||
const timeAgo = this.formatTimeAgo(mem.updated_at);
|
||||
context += `- ${mem.memory_key}: ${mem.memory_value} (stored ${timeAgo})\n`;
|
||||
}
|
||||
context += '\n';
|
||||
}
|
||||
|
||||
return context || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memories for specific players (for prompt injection when they're online)
|
||||
* @param {string[]} playerNames - Array of player names to get memories for
|
||||
*/
|
||||
async getPlayerMemoriesForPrompt(playerNames) {
|
||||
if (!playerNames || playerNames.length === 0) return null;
|
||||
|
||||
let context = '';
|
||||
for (const playerName of playerNames) {
|
||||
const memories = await this.getAllPlayerMemories(playerName);
|
||||
if (Object.keys(memories).length > 0) {
|
||||
context += `Memories about ${playerName}:\n`;
|
||||
for (const [key, value] of Object.entries(memories)) {
|
||||
context += `- ${key}: ${value}\n`;
|
||||
}
|
||||
context += '\n';
|
||||
}
|
||||
}
|
||||
return context.trim() || null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Utility Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
async close() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.db) {
|
||||
this.db.close((err) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
this.db = null;
|
||||
this.botName = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
module.exports = new AIMemoryDB();
|
||||
@@ -6,6 +6,15 @@ class GeminiProvider {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.session = null;
|
||||
this.tools = [];
|
||||
}
|
||||
|
||||
supportsTools() {
|
||||
return true;
|
||||
}
|
||||
|
||||
setTools(tools) {
|
||||
this.tools = tools;
|
||||
}
|
||||
|
||||
async start(history) {
|
||||
@@ -18,7 +27,7 @@ class GeminiProvider {
|
||||
}
|
||||
|
||||
__settings(history) {
|
||||
return {
|
||||
const settings = {
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature || 1,
|
||||
topP: this.config.topP || 0.95,
|
||||
@@ -55,6 +64,19 @@ class GeminiProvider {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Add tools if configured
|
||||
if (this.tools && this.tools.length > 0) {
|
||||
settings.tools = this.tools.map(tool => ({
|
||||
functionDeclarations: [{
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
async chat(message, retryCount = 0) {
|
||||
@@ -65,6 +87,9 @@ class GeminiProvider {
|
||||
if (retryCount > 3) {
|
||||
throw new Error(`Gemini API error after ${retryCount} retries: ${error.message}`);
|
||||
}
|
||||
const baseDelay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||||
const jitter = Math.random() * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, baseDelay + jitter));
|
||||
// Recover by removing last history entry and restarting
|
||||
this.session.params.history.pop();
|
||||
await this.start(this.session.params.history);
|
||||
@@ -80,6 +105,14 @@ class GeminiProvider {
|
||||
return result.response.text();
|
||||
}
|
||||
|
||||
getToolCalls(result) {
|
||||
// Gemini returns function calls in result.response.functionCalls()
|
||||
if (result.response && typeof result.response.functionCalls === 'function') {
|
||||
return result.response.functionCalls();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,11 @@ module.exports = {
|
||||
ProviderFactory,
|
||||
GeminiProvider,
|
||||
OllamaProvider
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider interface for tool support
|
||||
* All providers must implement these methods:
|
||||
* - setTools(tools): Configure available tools for function calling
|
||||
* - supportsTools(): boolean - whether this provider supports tool calling
|
||||
*/
|
||||
@@ -10,12 +10,20 @@ class OllamaProvider {
|
||||
this.baseUrl = config.baseUrl || 'http://localhost:11434';
|
||||
this.model = config.model || 'llama3.2';
|
||||
this.messages = [];
|
||||
this.tools = [];
|
||||
}
|
||||
|
||||
supportsTools() {
|
||||
return true;
|
||||
}
|
||||
|
||||
setTools(tools) {
|
||||
this.tools = tools;
|
||||
}
|
||||
|
||||
async start(history) {
|
||||
// Convert Gemini-style history to Ollama format if needed
|
||||
this.messages = history || [];
|
||||
|
||||
|
||||
if (this.config.prompt) {
|
||||
console.log('Ollama provider initialized with model:', this.model);
|
||||
}
|
||||
@@ -27,13 +35,12 @@ class OllamaProvider {
|
||||
top_p: this.config.topP || 0.95,
|
||||
top_k: this.config.topK || 64,
|
||||
num_predict: this.config.maxOutputTokens || 2048,
|
||||
|
||||
num_ctx: this.config.num_ctx,
|
||||
};
|
||||
}
|
||||
|
||||
__jsonFormat() {
|
||||
return 'json'
|
||||
/* return {
|
||||
return {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
@@ -43,12 +50,26 @@ class OllamaProvider {
|
||||
},
|
||||
required: ['text', 'delay']
|
||||
}
|
||||
};*/
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown code fences (```json ... ```) from a response string.
|
||||
* Ollama models (especially smaller ones) sometimes wrap their output in fences
|
||||
* even when the system prompt says not to.
|
||||
*/
|
||||
static stripMarkdownFences(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
let cleaned = text.trim();
|
||||
// Remove leading ```json or ``` fences
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
|
||||
// Remove trailing ``` fences
|
||||
cleaned = cleaned.replace(/\n?```\s*$/, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
async chat(message, retryCount = 0) {
|
||||
try {
|
||||
// Build conversation from prompt + history
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -64,33 +85,45 @@ class OllamaProvider {
|
||||
}
|
||||
];
|
||||
|
||||
// console.log('Ollama messages', messages)
|
||||
const requestBody = {
|
||||
model: this.model,
|
||||
messages: messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
format: this.__jsonFormat(),
|
||||
options: this.__settings()
|
||||
};
|
||||
// console.log('Ollama request:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
// Only set format when NO tools are configured.
|
||||
// format + tools together confuses smaller models — they try to
|
||||
// satisfy both constraints and produce garbage (_/empty responses).
|
||||
const hasTools = this.tools && this.tools.length > 0;
|
||||
if (!hasTools) {
|
||||
requestBody.format = this.__jsonFormat();
|
||||
}
|
||||
|
||||
if (hasTools) {
|
||||
requestBody.tools = this.tools.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/chat`,
|
||||
requestBody,
|
||||
{
|
||||
// timeout: this.config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Log raw response for debugging
|
||||
const rawContent = response.data.message.content;
|
||||
const messageData = response.data.message;
|
||||
console.log('Ollama response', rawContent)
|
||||
// console.log('Ollama raw response:', JSON.stringify(rawContent));
|
||||
// console.log('Ollama raw response length:', rawContent?.length);
|
||||
|
||||
// Update history
|
||||
this.messages.push({
|
||||
@@ -105,14 +138,25 @@ class OllamaProvider {
|
||||
content: rawContent
|
||||
});
|
||||
|
||||
// Return in a format compatible with the Ai class
|
||||
return {
|
||||
// The text() closure strips markdown code fences so consumers
|
||||
// (processResponse, getToolCalls) get clean content.
|
||||
const result = {
|
||||
response: {
|
||||
text: () => response.data.message.content
|
||||
text: () => {
|
||||
let content = messageData.content || rawContent;
|
||||
content = OllamaProvider.stripMarkdownFences(content);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Ollama may return tool calls in messageData.tool_calls
|
||||
if (messageData.tool_calls) {
|
||||
result.tool_calls = messageData.tool_calls;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Log detailed error information
|
||||
const errorDetails = {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
@@ -125,8 +169,9 @@ class OllamaProvider {
|
||||
if (retryCount > 3) {
|
||||
throw new Error(`Ollama API error after ${retryCount} retries: ${error.message}`);
|
||||
}
|
||||
// Retry after delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500 * (retryCount + 1)));
|
||||
const baseDelay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||||
const jitter = Math.random() * 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, baseDelay + jitter));
|
||||
return await this.chat(message, retryCount + 1);
|
||||
}
|
||||
}
|
||||
@@ -144,4 +189,4 @@ class OllamaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OllamaProvider;
|
||||
module.exports = OllamaProvider;
|
||||
|
||||
+481
-8
@@ -19,7 +19,7 @@ function createRouter() {
|
||||
model: config.model || 'unknown',
|
||||
interval: ai.intervalLength,
|
||||
promptName: ai.promptName || 'unknown',
|
||||
active: !!ai.intervalStop,
|
||||
active: !!ai._active,
|
||||
};
|
||||
}
|
||||
res.json({ bots: result });
|
||||
@@ -29,6 +29,196 @@ function createRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of all bots (for UI)
|
||||
router.get('/api/ai/bots', (req, res) => {
|
||||
try {
|
||||
const bots = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
bots.push({
|
||||
name: name,
|
||||
hasAI: !!ai,
|
||||
hasMemory: !!(ai && ai.memoryDB)
|
||||
});
|
||||
}
|
||||
res.json({ bots });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/bots:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all players with memories (shared across all bots)
|
||||
router.get('/api/ai/memories/players', async (req, res) => {
|
||||
try {
|
||||
// Get players from any bot's memoryDB (they're shared now)
|
||||
let players = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (ai && ai.memoryDB) {
|
||||
players = await ai.memoryDB.getAllKnownPlayers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
res.json({ players });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/players:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get memories for a specific player
|
||||
router.get('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const memories = await ai.memoryDB.getAllPlayerMemories(playerName);
|
||||
res.json({ bot: botName, player: playerName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a player memory
|
||||
router.post('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName } = req.params;
|
||||
const { key, value } = req.body;
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setPlayerMemory(playerName, key, value);
|
||||
res.json({ success: true, bot: botName, player: playerName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a player memory
|
||||
router.delete('/api/ai/memories/:botName/:playerName/:key', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName, key } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.deletePlayerMemory(playerName, key);
|
||||
res.json({ success: true, bot: botName, player: playerName, key });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player/:key DELETE:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get bot directives
|
||||
router.get('/api/ai/directives/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const directives = await ai.memoryDB.getAllDirectives();
|
||||
res.json({ bot: botName, directives });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives/:bot:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a directive
|
||||
router.post('/api/ai/directives/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const { key, value } = req.body;
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setDirective(key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives/:bot POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get general memories
|
||||
router.get('/api/ai/general-memories/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const memories = await ai.memoryDB.getAllGeneralMemories();
|
||||
res.json({ bot: botName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories/:bot:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a general memory
|
||||
router.post('/api/ai/general-memories/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const { key, value } = req.body;
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setGeneralMemory(key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories/:bot POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -38,7 +228,16 @@ const webUI = {
|
||||
tabOrder: 30,
|
||||
html: `
|
||||
<div id="aiArea">
|
||||
<div style="padding:20px;color:#6b7280;text-align:center">Loading AI status...</div>
|
||||
<div class="ai-tabs">
|
||||
<button class="ai-tab-btn active" data-tab="status" onclick="switchAiTab('status')">AI Status</button>
|
||||
<button class="ai-tab-btn" data-tab="memories" onclick="switchAiTab('memories')">Memories</button>
|
||||
<button class="ai-tab-btn" data-tab="directives" onclick="switchAiTab('directives')">Directives</button>
|
||||
</div>
|
||||
<div id="ai-tab-content">
|
||||
<div id="ai-status" class="ai-tab-content active"></div>
|
||||
<div id="ai-memories" class="ai-tab-content"></div>
|
||||
<div id="ai-directives" class="ai-tab-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
css: `
|
||||
@@ -53,41 +252,96 @@ const webUI = {
|
||||
.ai-status-badge.active{background:#059669;color:#fff}
|
||||
.ai-status-badge.inactive{background:#6b7280;color:#fff}
|
||||
.ai-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
|
||||
.ai-tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid #374151;padding-bottom:8px}
|
||||
.ai-tab-btn{background:transparent;border:1px solid #374151;color:#9ca3af;padding:8px 16px;border-radius:6px;cursor:pointer;transition:all .2s}
|
||||
.ai-tab-btn:hover{border-color:#a78bfa;color:#e5e7eb}
|
||||
.ai-tab-btn.active{background:#a78bfa;border-color:#a78bfa;color:#111827}
|
||||
.ai-tab-content{display:none}
|
||||
.ai-tab-content.active{display:block}
|
||||
.memory-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:16px;margin-bottom:12px}
|
||||
.memory-card h4{color:#a78bfa;margin:0 0 12px 0;font-size:.95em}
|
||||
.memory-entry{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #1f2937}
|
||||
.memory-entry:last-child{border-bottom:none}
|
||||
.memory-key{color:#e5e7eb;font-weight:500}
|
||||
.memory-value{color:#9ca3af;max-width:60%;overflow:hidden;text-overflow:ellipsis}
|
||||
.memory-actions{display:flex;gap:8px}
|
||||
.btn-sm{padding:4px 8px;font-size:.75em;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#9ca3af;cursor:pointer}
|
||||
.btn-sm:hover{border-color:#a78bfa;color:#e5e7eb}
|
||||
.btn-danger{border-color:#dc2626;color:#fca5a5}
|
||||
.btn-danger:hover{background:#dc2626;color:#fff}
|
||||
.btn-success{border-color:#059669;color:#6ee7b7}
|
||||
.btn-success:hover{background:#059669;color:#fff}
|
||||
.memory-form{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
|
||||
.memory-form input{flex:1;min-width:150px;padding:8px;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#e5e7eb}
|
||||
.memory-form input:focus{outline:none;border-color:#a78bfa}
|
||||
.player-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||
.player-card{background:#1f2937;border:1px solid #374151;border-radius:6px;padding:12px;cursor:pointer;transition:border-color .2s}
|
||||
.player-card:hover{border-color:#a78bfa}
|
||||
.player-card.selected{border-color:#a78bfa;background:#2d1f4e}
|
||||
.form-group{margin-bottom:12px}
|
||||
.form-group label{display:block;color:#6b7280;font-size:.85em;margin-bottom:4px}
|
||||
.form-group input,.form-group textarea{width:100%;padding:8px;border-radius:4px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;box-sizing:border-box}
|
||||
.form-group textarea{min-height:80px;resize:vertical}
|
||||
.bot-select{margin-bottom:16px;padding:8px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;min-width:200px}
|
||||
`,
|
||||
onTabActive: 'onAiTabActive',
|
||||
js: `
|
||||
let aiInterval=null;
|
||||
let currentAiTab='status';
|
||||
let selectedBot=null;
|
||||
let selectedPlayer=null;
|
||||
|
||||
function onAiTabActive() {
|
||||
// Initialize tab content structure if needed
|
||||
var container = document.getElementById('ai-tab-content');
|
||||
if (container && !container.querySelector('#ai-status')) {
|
||||
container.innerHTML = '<div id="ai-status" class="ai-tab-content active"></div><div id="ai-memories" class="ai-tab-content"></div><div id="ai-directives" class="ai-tab-content"></div>';
|
||||
}
|
||||
loadAiStatus();
|
||||
if (!aiInterval) aiInterval = setInterval(() => { if (currentTab === 'ai') loadAiStatus(); }, 10000);
|
||||
}
|
||||
|
||||
function switchAiTab(tab) {
|
||||
currentAiTab = tab;
|
||||
var btns = document.querySelectorAll('.ai-tab-btn');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.remove('active');
|
||||
}
|
||||
var activeBtn = document.querySelector('.ai-tab-btn[data-tab="'+tab+'"]');
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
var contents = document.querySelectorAll('.ai-tab-content');
|
||||
for (var i = 0; i < contents.length; i++) {
|
||||
contents[i].classList.remove('active');
|
||||
}
|
||||
var target = document.getElementById('ai-'+tab);
|
||||
if (target) target.classList.add('active');
|
||||
if (tab === 'memories') loadAiPlayers();
|
||||
if (tab === 'directives') loadAiDirectives();
|
||||
}
|
||||
|
||||
async function loadAiStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/ai/status');
|
||||
if (!r.ok) { document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>'; return; }
|
||||
if (!r.ok) { document.getElementById('ai-status').innerHTML='<div class="ai-empty">Failed to load AI status</div>'; return; }
|
||||
const d = await r.json();
|
||||
renderAiStatus(d.bots || {});
|
||||
} catch(e) {
|
||||
document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>';
|
||||
document.getElementById('ai-status').innerHTML='<div class="ai-empty">Failed to load AI status: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAiStatus(bots) {
|
||||
const area = document.getElementById('aiArea');
|
||||
const names = Object.keys(bots);
|
||||
if (names.length === 0) {
|
||||
area.innerHTML='<div class="ai-empty">No bots with AI loaded</div>';
|
||||
document.getElementById('ai-status').innerHTML='<div class="ai-empty">No bots with AI loaded</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = '<div class="ai-grid">' + names.map(name => {
|
||||
let html = '<div class="ai-grid">' + names.map(name => {
|
||||
const ai = bots[name];
|
||||
const badge = ai.active
|
||||
? '<span class="ai-status-badge active">Active</span>'
|
||||
: '<span class="ai-status-badge inactive">Inactive</span>';
|
||||
|
||||
return '<div class="ai-card">' +
|
||||
'<h3><span class="bot-status ' + (ai.connected ? 'online' : 'offline') + '"></span> ' + escHtml(name) + ' ' + badge + '</h3>' +
|
||||
'<div class="ai-info"><span class="ai-label">Provider:</span> <span class="ai-value">' + escHtml(ai.provider) + '</span></div>' +
|
||||
@@ -96,6 +350,225 @@ function renderAiStatus(bots) {
|
||||
'<div class="ai-info"><span class="ai-label">Prompt:</span> <span class="ai-value">' + escHtml(ai.promptName) + '</span></div>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
document.getElementById('ai-status').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadAiPlayers() {
|
||||
try {
|
||||
// First get list of all bots (for selecting which bot to edit with)
|
||||
const botsR = await fetch('/api/ai/bots');
|
||||
if (!botsR.ok) throw new Error('Failed to load bots');
|
||||
const botsD = await botsR.json();
|
||||
|
||||
// Then get players with memories (shared across all bots)
|
||||
const r = await fetch('/api/ai/memories/players');
|
||||
if (!r.ok) throw new Error('Failed to load players');
|
||||
const d = await r.json();
|
||||
|
||||
renderAiPlayers(d.players || [], botsD.bots || []);
|
||||
} catch(e) {
|
||||
document.getElementById('ai-memories').innerHTML='<div class="ai-empty">Failed to load players: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAiPlayers(players, allBots) {
|
||||
// players is now a flat array (shared memories)
|
||||
// allBots is array of {name, hasAI, hasMemory}
|
||||
if (!allBots || allBots.length === 0) {
|
||||
document.getElementById('ai-memories').innerHTML='<div class="ai-empty">No bots available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="form-group"><label>Select Bot (for editing):</label><select class="bot-select" onchange="onBotSelect(this.value)">';
|
||||
html += '<option value="">-- Select --</option>';
|
||||
allBots.forEach(bot => {
|
||||
html += '<option value="'+escHtml(bot.name)+'">'+escHtml(bot.name) + (bot.hasAI ? '' : ' (no AI)')+'</option>';
|
||||
});
|
||||
html += '</select></div>';
|
||||
|
||||
// Player list (shared, no bot filtering)
|
||||
if (players && players.length > 0) {
|
||||
html += '<div class="player-list">' + players.map(player => {
|
||||
const selected = selectedPlayer === player ? 'selected' : '';
|
||||
return '<div class="player-card '+selected+'" onclick="selectPlayer(\\''+escHtml(player)+'\\')">'+escHtml(player)+'</div>';
|
||||
}).join('') + '</div>';
|
||||
} else {
|
||||
html += '<div class="ai-empty">No players with stored memories</div>';
|
||||
}
|
||||
|
||||
if (selectedPlayer) {
|
||||
html += '<div id="playerMemoriesArea" style="margin-top:16px"></div>';
|
||||
setTimeout(() => loadPlayerMemories(selectedBot, selectedPlayer), 0);
|
||||
}
|
||||
|
||||
document.getElementById('ai-memories').innerHTML = html;
|
||||
}
|
||||
|
||||
function onBotSelect(botName) {
|
||||
selectedBot = botName;
|
||||
}
|
||||
|
||||
function selectPlayer(playerName) {
|
||||
selectedPlayer = playerName;
|
||||
// Re-render to show selection highlight
|
||||
loadAiPlayers();
|
||||
}
|
||||
|
||||
async function loadPlayerMemories(botName, playerName) {
|
||||
// Memories are shared, but we need a bot selected to edit
|
||||
if (!botName) {
|
||||
const area = document.getElementById('playerMemoriesArea');
|
||||
if (area) {
|
||||
area.innerHTML='<div class="ai-empty">Select a bot above to view/edit memories</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName));
|
||||
if (!r.ok) throw new Error('Failed to load memories');
|
||||
const d = await r.json();
|
||||
renderPlayerMemories(d.memories || {}, botName, playerName);
|
||||
} catch(e) {
|
||||
const area = document.getElementById('playerMemoriesArea');
|
||||
if (area) {
|
||||
area.innerHTML='<div class="ai-empty">Failed to load memories: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlayerMemories(memories, botName, playerName) {
|
||||
const area = document.getElementById('playerMemoriesArea');
|
||||
if (!area) return; // Element doesn't exist yet
|
||||
|
||||
const keys = Object.keys(memories);
|
||||
if (keys.length === 0) {
|
||||
area.innerHTML='<div class="ai-empty">No memories stored for '+escHtml(playerName)+'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="memory-card"><h4>Memories for '+escHtml(playerName)+' (shared)</h4>';
|
||||
keys.forEach(key => {
|
||||
html += '<div class="memory-entry">'+
|
||||
'<span class="memory-key">'+escHtml(key)+'</span>'+
|
||||
'<span class="memory-value">'+escHtml(memories[key])+'</span>'+
|
||||
'<div class="memory-actions">'+
|
||||
'<button class="btn-sm btn-danger" onclick="deleteMemory(\\''+escHtml(botName)+'\\',\\''+escHtml(playerName)+'\\',\\''+escHtml(key)+'\\')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>'+
|
||||
'</div></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="memory-card"><h4>Add Memory</h4><div class="memory-form">'+
|
||||
'<input type="text" id="memoryKey" placeholder="Key (e.g., trust_level)">'+
|
||||
'<input type="text" id="memoryValue" placeholder="Value">'+
|
||||
'<button class="btn-sm btn-success" onclick="addMemory(\\''+escHtml(botName)+'\\',\\''+escHtml(playerName)+'\\')">Add</button>'+
|
||||
'</div></div>';
|
||||
|
||||
document.getElementById('playerMemoriesArea').innerHTML = html;
|
||||
}
|
||||
|
||||
async function deleteMemory(botName, playerName, key) {
|
||||
if (!confirm('Delete memory "'+key+'" for '+playerName+'?')) return;
|
||||
try {
|
||||
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName)+'/'+encodeURIComponent(key), { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error('Failed to delete');
|
||||
loadPlayerMemories(botName, playerName);
|
||||
} catch(e) {
|
||||
alert('Failed to delete: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addMemory(botName, playerName) {
|
||||
const key = document.getElementById('memoryKey').value;
|
||||
const value = document.getElementById('memoryValue').value;
|
||||
if (!key || !value) { alert('Key and value required'); return; }
|
||||
try {
|
||||
const r = await fetch('/api/ai/memories/'+encodeURIComponent(botName)+'/'+encodeURIComponent(playerName), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
if (!r.ok) throw new Error('Failed to add');
|
||||
document.getElementById('memoryKey').value = '';
|
||||
document.getElementById('memoryValue').value = '';
|
||||
loadPlayerMemories(botName, playerName);
|
||||
} catch(e) {
|
||||
alert('Failed to add: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAiDirectives() {
|
||||
try {
|
||||
const r = await fetch('/api/ai/bots');
|
||||
if (!r.ok) throw new Error('Failed to load bots');
|
||||
const d = await r.json();
|
||||
const bots = d.bots || [];
|
||||
|
||||
let html = '<div class="form-group"><label>Select Bot:</label><select class="bot-select" onchange="loadBotDirectives(this.value)">';
|
||||
html += '<option value="">-- Select --</option>';
|
||||
bots.forEach(bot => {
|
||||
html += '<option value="'+escHtml(bot.name)+'">'+escHtml(bot.name) + (bot.hasAI ? '' : ' (no AI)')+'</option>';
|
||||
});
|
||||
html += '</select></div><div id="directivesArea"></div>';
|
||||
document.getElementById('ai-directives').innerHTML = html;
|
||||
} catch(e) {
|
||||
document.getElementById('ai-directives').innerHTML='<div class="ai-empty">Failed to load bots: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBotDirectives(botName) {
|
||||
if (!botName) { document.getElementById('directivesArea').innerHTML = ''; return; }
|
||||
try {
|
||||
const r = await fetch('/api/ai/directives/'+encodeURIComponent(botName));
|
||||
if (!r.ok) throw new Error('Failed to load directives');
|
||||
const d = await r.json();
|
||||
renderBotDirectives(d.directives || {}, botName);
|
||||
} catch(e) {
|
||||
document.getElementById('directivesArea').innerHTML='<div class="ai-empty">Failed to load: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotDirectives(directives, botName) {
|
||||
const keys = Object.keys(directives);
|
||||
let html = '<div class="memory-card"><h4>Directives for '+escHtml(botName)+'</h4>';
|
||||
if (keys.length === 0) {
|
||||
html += '<div class="ai-empty">No directives set</div>';
|
||||
} else {
|
||||
keys.forEach(key => {
|
||||
html += '<div class="memory-entry">'+
|
||||
'<span class="memory-key">'+escHtml(key)+'</span>'+
|
||||
'<span class="memory-value">'+escHtml(directives[key])+'</span>'+
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="memory-card"><h4>Add Directive</h4><div class="form-group">'+
|
||||
'<label>Key</label><input type="text" id="directiveKey" placeholder="e.g., current_goal">'+
|
||||
'</div><div class="form-group">'+
|
||||
'<label>Value</label><textarea id="directiveValue" placeholder="Directive value"></textarea>'+
|
||||
'</div><button class="btn-sm btn-success" onclick="addDirective(\\''+escHtml(botName)+'\\')">Add</button>'+
|
||||
'</div>';
|
||||
|
||||
document.getElementById('directivesArea').innerHTML = html;
|
||||
}
|
||||
|
||||
async function addDirective(botName) {
|
||||
const key = document.getElementById('directiveKey').value;
|
||||
const value = document.getElementById('directiveValue').value;
|
||||
if (!key || !value) { alert('Key and value required'); return; }
|
||||
try {
|
||||
const r = await fetch('/api/ai/directives/'+encodeURIComponent(botName), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
if (!r.ok) throw new Error('Failed to add');
|
||||
document.getElementById('directiveKey').value = '';
|
||||
document.getElementById('directiveValue').value = '';
|
||||
loadBotDirectives(botName);
|
||||
} catch(e) {
|
||||
alert('Failed to add: ' + e.message);
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const Vec3 = require('vec3');
|
||||
const { goals: { GoalNear } } = require('mineflayer-pathfinder');
|
||||
const { sleep } = require('../../utils');
|
||||
|
||||
class Navigation {
|
||||
constructor(args) {
|
||||
this.bot = args.bot;
|
||||
|
||||
this.commands = [
|
||||
{
|
||||
name: 'goto',
|
||||
description: 'Move the bot to coordinates or a player position',
|
||||
parameters: [
|
||||
{ name: 'target', type: 'string', required: true, description: 'Coordinates "x y z" or player name' },
|
||||
{ name: 'range', type: 'number', required: false, description: 'Stop distance from target (default: 0)' }
|
||||
],
|
||||
category: 'movement'
|
||||
},
|
||||
{
|
||||
name: 'come',
|
||||
description: 'Come to the requesting player with recovery on failure',
|
||||
parameters: [
|
||||
{ name: 'player', type: 'string', required: false, description: 'Player to come to (defaults to requester)' }
|
||||
],
|
||||
category: 'movement'
|
||||
},
|
||||
{
|
||||
name: 'follow',
|
||||
description: 'Go to a player and maintain distance',
|
||||
parameters: [
|
||||
{ name: 'target', type: 'string', required: true, description: 'Player to follow' },
|
||||
{ name: 'range', type: 'number', required: false, description: 'Follow distance (default: 3)' }
|
||||
],
|
||||
category: 'movement'
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
description: 'Stop current pathfinding',
|
||||
parameters: [],
|
||||
category: 'movement'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async init() {}
|
||||
async unload() {
|
||||
try { this.bot.bot.pathfinder.stop(); } catch (e) { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleCommand(from, command, ...args) {
|
||||
switch (command) {
|
||||
case 'goto': {
|
||||
const [target, rangeStr] = args;
|
||||
const range = parseFloat(rangeStr) || 0;
|
||||
if (!target) return 'Specify coordinates "x y z" or a player name';
|
||||
|
||||
// Parse coordinates
|
||||
const parts = target.split(' ');
|
||||
if (parts.length >= 3) {
|
||||
const coords = parts.map(Number);
|
||||
if (coords.every(c => !isNaN(c))) {
|
||||
return this._goWithRecovery(new Vec3(coords[0], coords[1], coords[2]), range);
|
||||
}
|
||||
}
|
||||
|
||||
// Player target
|
||||
const player = this.bot.bot.players[target];
|
||||
if (player && player.entity) {
|
||||
return this._goWithRecovery(player.entity.position, range);
|
||||
}
|
||||
return `Target not found: ${target}`;
|
||||
}
|
||||
|
||||
case 'come': {
|
||||
const [playerName] = args;
|
||||
const target = playerName || from;
|
||||
const player = this.bot.bot.players[target];
|
||||
if (!player || !player.entity) return `Cannot find ${target}`;
|
||||
return this._goWithRecovery(player.entity.position, 3);
|
||||
}
|
||||
|
||||
case 'follow': {
|
||||
const [target, rangeStr] = args;
|
||||
const range = parseFloat(rangeStr) || 3;
|
||||
const player = this.bot.bot.players[target];
|
||||
if (!player || !player.entity) return `Cannot find ${target}`;
|
||||
return this._goWithRecovery(player.entity.position, range);
|
||||
}
|
||||
|
||||
case 'stop':
|
||||
try { this.bot.bot.pathfinder.stop(); } catch (e) { /* ignore */ }
|
||||
return 'Movement stopped';
|
||||
|
||||
default:
|
||||
return `Unknown navigation command: ${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart movement with recovery — retries with 360 scan and waypoint unstick when goTo fails
|
||||
async _goWithRecovery(targetPos, range) {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < 3) {
|
||||
const ok = await this.bot.goTo({ where: targetPos, range });
|
||||
if (ok) return `Arrived at destination`;
|
||||
|
||||
attempts++;
|
||||
console.log(`[Navigation] goTo failed (attempt ${attempts}/3), applying recovery...`);
|
||||
|
||||
// 360 visual refresh
|
||||
const bot = this.bot.bot;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await bot.look(bot.entity.yaw + Math.PI / 2, 0, true);
|
||||
await sleep(150);
|
||||
}
|
||||
|
||||
// Perpendicular waypoint to unstick
|
||||
const pos = bot.entity.position;
|
||||
if (!isNaN(pos.x) && !isNaN(pos.z)) {
|
||||
const yaw = Math.atan2(targetPos.z - pos.z, targetPos.x - pos.x);
|
||||
const perps = [yaw + Math.PI / 2, yaw - Math.PI / 2, yaw + Math.PI];
|
||||
for (const p of perps) {
|
||||
const wx = pos.x + Math.cos(p) * 3;
|
||||
const wz = pos.z + Math.sin(p) * 3;
|
||||
try {
|
||||
await bot.pathfinder.goto(
|
||||
new GoalNear(wx, pos.y, wz, 1)
|
||||
);
|
||||
await sleep(1000);
|
||||
bot.clearControlStates();
|
||||
break;
|
||||
} catch (e) { /* try next */ }
|
||||
}
|
||||
|
||||
// Backward nudge
|
||||
bot.setControlState('back', true);
|
||||
await sleep(300);
|
||||
bot.clearControlStates();
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
return 'Failed to reach destination after recovery attempts';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Navigation;
|
||||
@@ -153,8 +153,17 @@ module.exports = {
|
||||
const storage = this.plunginsLoaded['Storage'];
|
||||
if (!storage) return;
|
||||
|
||||
storage._busy = true;
|
||||
// Interrupt any active task and acquire operation lock
|
||||
await this.interruptTask(from);
|
||||
try {
|
||||
await storage._acquireOperationLock(5000);
|
||||
} catch (e) {
|
||||
this.whisper(from, 'Storage is busy, try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage._busy = true;
|
||||
const pending = storage.pendingWithdrawals.get(from);
|
||||
|
||||
await this.say('/trade accept');
|
||||
@@ -220,32 +229,43 @@ module.exports = {
|
||||
}
|
||||
clearTimeout(timeoutCheck);
|
||||
|
||||
if (pending) {
|
||||
// Withdrawal complete — clear pending
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
storage.pendingWithdrawals.delete(from);
|
||||
this.whisper(from, `Withdrawal complete! Enjoy your ${pending.itemName}.`);
|
||||
} else {
|
||||
// Deposit — collect items from bot inventory and sort into storage
|
||||
await sleep(500);
|
||||
let tradeResult = null;
|
||||
|
||||
const hotbarNames = new Set((storage.config.hotbarItems || []).map(h => h.name));
|
||||
const itemsReceived = [];
|
||||
for (const item of this.bot.inventory.items()) {
|
||||
if (hotbarNames.has(item.name)) continue;
|
||||
itemsReceived.push({ name: item.name, id: item.type, count: item.count, nbt: item.nbt });
|
||||
}
|
||||
if (pending) {
|
||||
// Withdrawal complete — clear pending
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
storage.pendingWithdrawals.delete(from);
|
||||
this.whisper(from, `Withdrawal complete! Enjoy your ${pending.itemName}.`);
|
||||
} else {
|
||||
// Deposit — collect items from bot inventory and sort into storage
|
||||
await sleep(500);
|
||||
|
||||
if (itemsReceived.length > 0) {
|
||||
this.whisper(from, `Received ${itemsReceived.length} item type(s). Sorting into storage.`);
|
||||
await storage.handleTrade(from, itemsReceived);
|
||||
} else {
|
||||
this.whisper(from, 'No items received.');
|
||||
}
|
||||
const hotbarNames = new Set((storage.config.hotbarItems || []).map(h => h.name));
|
||||
const itemsReceived = [];
|
||||
for (const item of this.bot.inventory.items()) {
|
||||
if (hotbarNames.has(item.name)) continue;
|
||||
itemsReceived.push({ name: item.name, id: item.type, count: item.count, nbt: item.nbt });
|
||||
}
|
||||
} finally {
|
||||
storage._busy = false;
|
||||
if (itemsReceived.length > 0) {
|
||||
this.whisper(from, `Received ${itemsReceived.length} item type(s). Sorting into storage.`);
|
||||
tradeResult = await storage.handleTrade(from, itemsReceived);
|
||||
} else {
|
||||
this.whisper(from, 'No items received.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
storage._busy = false;
|
||||
storage._releaseOperationLock();
|
||||
}
|
||||
|
||||
// Organize after lock is released (handleTrade runs under the lock)
|
||||
if (tradeResult && tradeResult.needsOrganize) {
|
||||
try {
|
||||
await storage.organizeLooseItems();
|
||||
} catch (error) {
|
||||
console.error('Storage: Post-trade organize failed:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,9 @@ module.exports = {
|
||||
// Check if bot has StoragePlugin
|
||||
if (this.plunginsLoaded['Storage']) {
|
||||
// Storage bot flow
|
||||
if (this.plunginsLoaded['Ai']) {
|
||||
this.plunginsLoaded['Ai']._expectingTradeWindow = true;
|
||||
}
|
||||
await this.say('/trade accept');
|
||||
let window = await this.once('windowOpen');
|
||||
|
||||
@@ -80,6 +83,9 @@ module.exports = {
|
||||
let chestBlock = findChestBySign(this, from);
|
||||
if(!chestBlock) return this.whisper(from, `You aren't allowed to trade with me...`);
|
||||
|
||||
if (this.plunginsLoaded['Ai']) {
|
||||
this.plunginsLoaded['Ai']._expectingTradeWindow = true;
|
||||
}
|
||||
await this.say('/trade accept');
|
||||
let window = await this.once('windowOpen');
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ class FarmSupply {
|
||||
await sleep(3000);
|
||||
// Wait for pathfinder to be idle
|
||||
while (this.bot.bot.pathfinder.isMoving()) {
|
||||
this.bot.bot.pathfinder.stop();
|
||||
this.bot.bot.clearControlStates();
|
||||
await sleep(500);
|
||||
}
|
||||
this.bot.bot.clearControlStates();
|
||||
|
||||
@@ -18,6 +18,7 @@ CJbot.pluginAdd(require('./goldFarm'));
|
||||
CJbot.pluginAdd(require('./storage'));
|
||||
CJbot.pluginAdd(require('./auto-eat'));
|
||||
CJbot.pluginAdd(require('./farm-supply'));
|
||||
CJbot.pluginAdd(require('./commands/navigation'));
|
||||
|
||||
for(let name in conf.mc.bots){
|
||||
if(CJbot.bots[name]) continue;
|
||||
|
||||
@@ -400,6 +400,20 @@ class Database {
|
||||
`, [itemName]);
|
||||
}
|
||||
|
||||
// Find chests with available slots, ordered nearest-first from a reference point.
|
||||
// Returns an array of { id, pos_x, pos_y, pos_z, chest_type, empty_slots }.
|
||||
async findNearestChestsWithSpace(neededSlots, refX, refY, refZ) {
|
||||
return await this.db.all(`
|
||||
SELECT c.id, c.pos_x, c.pos_y, c.pos_z, c.chest_type,
|
||||
(CASE WHEN c.chest_type = 'double' THEN 54 ELSE 27 END) - COUNT(s.id) as empty_slots
|
||||
FROM chests c
|
||||
LEFT JOIN shulkers s ON s.chest_id = c.id
|
||||
GROUP BY c.id
|
||||
HAVING empty_slots > 0
|
||||
ORDER BY ((c.pos_x - ?) * (c.pos_x - ?) + (c.pos_y - ?) * (c.pos_y - ?) + (c.pos_z - ?) * (c.pos_z - ?)) ASC
|
||||
`, [refX, refX, refY, refY, refZ, refZ]);
|
||||
}
|
||||
|
||||
// Find a chest slot that doesn't have a shulker (for placing newly crafted ones)
|
||||
async findEmptyChestSlot() {
|
||||
const chests = await this.db.all(`
|
||||
@@ -560,7 +574,7 @@ class Database {
|
||||
// Clear and rebuild from shulker_items
|
||||
await this.db.run('DELETE FROM item_index');
|
||||
await this.db.run(`
|
||||
INSERT INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
|
||||
INSERT OR REPLACE INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
|
||||
SELECT
|
||||
si.item_id,
|
||||
si.item_name,
|
||||
|
||||
+476
-335
File diff suppressed because it is too large
Load Diff
@@ -193,7 +193,7 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
async scanAllChests(bot, database) {
|
||||
async scanAllChests(bot, database, interruptCheck) {
|
||||
const chests = await database.getChests();
|
||||
console.log(`Scanner: Scanning all ${chests.length} tracked chests`);
|
||||
|
||||
@@ -246,7 +246,11 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Wait for anti-ESP to reveal nearby blocks after arriving
|
||||
await sleep(250);
|
||||
if (interruptCheck && interruptCheck()) {
|
||||
console.log(`Scanner: Interrupted after ${scannedCount} chests`);
|
||||
return totalShulkers;
|
||||
}
|
||||
await sleep(250);
|
||||
|
||||
// Discover any new chests now visible from this position (every 5th stop or first)
|
||||
if (scannedCount % 5 === 0) {
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
const Vec3 = require('vec3');
|
||||
const { sleep } = require('../../utils');
|
||||
const { goals: { GoalNear } } = require('mineflayer-pathfinder');
|
||||
|
||||
const Database = require('./database');
|
||||
|
||||
class ShulkerHandler {
|
||||
constructor() {
|
||||
this.operationInProgress = false;
|
||||
this.scanner = null; // set by Storage after init
|
||||
}
|
||||
|
||||
@@ -89,7 +88,7 @@ class ShulkerHandler {
|
||||
bot.bot.setControlState('sneak', false);
|
||||
|
||||
// Stop pathfinder movement too
|
||||
try { bot.bot.pathfinder.stop(); } catch (e) { /* ignore */ }
|
||||
bot.bot.clearControlStates();
|
||||
|
||||
// Retry dig up to 3 times — shulker closing animation can block the first attempt
|
||||
for (let digAttempt = 0; digAttempt < 3; digAttempt++) {
|
||||
@@ -395,18 +394,9 @@ class ShulkerHandler {
|
||||
async takeWholeShulker(bot, chestPos, chestSlot, shulkerId) {
|
||||
console.log(`ShulkerHandler: Taking whole shulker from chest at ${chestPos}, slot ${chestSlot}`);
|
||||
|
||||
if (this.operationInProgress) {
|
||||
throw new Error('Another shulker operation is in progress');
|
||||
}
|
||||
this.operationInProgress = true;
|
||||
|
||||
try {
|
||||
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
|
||||
console.log(`ShulkerHandler: Whole shulker taken, now in inventory slot ${invSlot}`);
|
||||
return invSlot;
|
||||
} finally {
|
||||
this.operationInProgress = false;
|
||||
}
|
||||
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
|
||||
console.log(`ShulkerHandler: Whole shulker taken, now in inventory slot ${invSlot}`);
|
||||
return invSlot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,7 +425,7 @@ class ShulkerHandler {
|
||||
const retreatZ = botPos.z + (dz / dist) * 1.5;
|
||||
try {
|
||||
|
||||
await bot.bot.pathfinder.goto(new GoalNear(retreatX, botPos.y, retreatZ, 0.5));
|
||||
await bot.goTo({ where: new Vec3(retreatX, botPos.y, retreatZ), range: 0 });
|
||||
await sleep(300);
|
||||
} catch (e) {
|
||||
console.log(`ShulkerHandler: Retreat pathfind failed: ${e.message}`);
|
||||
@@ -503,7 +493,7 @@ class ShulkerHandler {
|
||||
const angle = (attempt * Math.PI / 2) + Math.PI / 4;
|
||||
const moveX = pos.x + Math.cos(angle) * 3;
|
||||
const moveZ = pos.z + Math.sin(angle) * 3;
|
||||
await bot.bot.pathfinder.goto(new GoalNear(moveX, pos.y, moveZ, 1));
|
||||
await bot.goTo({ where: new Vec3(moveX, pos.y, moveZ), range: 1 });
|
||||
} catch (e) {
|
||||
console.log(`ShulkerHandler: Move failed: ${e.message}`);
|
||||
}
|
||||
@@ -591,11 +581,6 @@ class ShulkerHandler {
|
||||
async depositIntoShulker(bot, chestPos, chestSlot, itemName, count, shulkerId, chestId, itemFilter = null) {
|
||||
console.log(`ShulkerHandler: Depositing ${count}x ${itemName} into shulker at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
if (this.operationInProgress) {
|
||||
throw new Error('Another shulker operation is in progress');
|
||||
}
|
||||
this.operationInProgress = true;
|
||||
|
||||
let placedPos = null;
|
||||
|
||||
try {
|
||||
@@ -716,8 +701,6 @@ class ShulkerHandler {
|
||||
}
|
||||
if (!recovered) throw error;
|
||||
return { deposited: 0, updatedSlotItem: null };
|
||||
} finally {
|
||||
this.operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,132 +712,123 @@ class ShulkerHandler {
|
||||
async unpackShulkerFromInventory(bot, shulkerItem) {
|
||||
console.log(`ShulkerHandler: Unpacking shulker ${shulkerItem.name} from inventory`);
|
||||
|
||||
if (this.operationInProgress) {
|
||||
throw new Error('Another shulker operation is in progress');
|
||||
// Step 1: Find placement spot
|
||||
const spot = this.findPlacementSpot(bot);
|
||||
|
||||
// Ensure bot is far enough from placement spot
|
||||
const botPos = bot.bot.entity.position;
|
||||
const placeDist = botPos.distanceTo(spot.position.offset(0.5, 0, 0.5));
|
||||
if (placeDist < 1.5) {
|
||||
console.log(`ShulkerHandler: Too close to placement spot (${placeDist.toFixed(1)} blocks), stepping back`);
|
||||
const dx = botPos.x - spot.position.x;
|
||||
const dz = botPos.z - spot.position.z;
|
||||
const dist = Math.sqrt(dx * dx + dz * dz) || 1;
|
||||
const retreatX = botPos.x + (dx / dist) * 1.5;
|
||||
const retreatZ = botPos.z + (dz / dist) * 1.5;
|
||||
try {
|
||||
|
||||
await bot.goTo({ where: new Vec3(retreatX, botPos.y, retreatZ), range: 0 });
|
||||
await sleep(300);
|
||||
} catch (e) {
|
||||
console.log(`ShulkerHandler: Retreat pathfind failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
this.operationInProgress = true;
|
||||
|
||||
try {
|
||||
// Step 1: Find placement spot
|
||||
const spot = this.findPlacementSpot(bot);
|
||||
// Step 2: Equip and place the shulker
|
||||
await bot.bot.equip(shulkerItem, 'hand');
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
// Ensure bot is far enough from placement spot
|
||||
const botPos = bot.bot.entity.position;
|
||||
const placeDist = botPos.distanceTo(spot.position.offset(0.5, 0, 0.5));
|
||||
if (placeDist < 1.5) {
|
||||
console.log(`ShulkerHandler: Too close to placement spot (${placeDist.toFixed(1)} blocks), stepping back`);
|
||||
const dx = botPos.x - spot.position.x;
|
||||
const dz = botPos.z - spot.position.z;
|
||||
const dist = Math.sqrt(dx * dx + dz * dz) || 1;
|
||||
const retreatX = botPos.x + (dx / dist) * 1.5;
|
||||
const retreatZ = botPos.z + (dz / dist) * 1.5;
|
||||
try {
|
||||
// Verify we're actually holding the shulker
|
||||
const heldItem = bot.bot.heldItem;
|
||||
if (!heldItem || !heldItem.name.includes('shulker_box')) {
|
||||
throw new Error(`Cannot equip shulker for unpack (holding: ${heldItem?.name || 'nothing'})`);
|
||||
}
|
||||
|
||||
await bot.bot.pathfinder.goto(new GoalNear(retreatX, botPos.y, retreatZ, 0.5));
|
||||
await sleep(300);
|
||||
} catch (e) {
|
||||
console.log(`ShulkerHandler: Retreat pathfind failed: ${e.message}`);
|
||||
// Look at the top face center of placement block
|
||||
await bot.bot.lookAt(spot.placeOn.position.offset(0.5, 1, 0.5), true);
|
||||
await bot.bot.waitForTicks(3);
|
||||
|
||||
// SAFETY: verify held item is a shulker RIGHT before placing — never place anything else
|
||||
const prePlaceItem = bot.bot.heldItem;
|
||||
if (!prePlaceItem || !prePlaceItem.name.includes('shulker_box')) {
|
||||
throw new Error(`ABORT: held item changed before place (holding: ${prePlaceItem?.name || 'nothing'})`);
|
||||
}
|
||||
|
||||
await bot.bot.placeBlock(spot.placeOn, spot.faceVec);
|
||||
await bot.bot.waitForTicks(10);
|
||||
|
||||
// Verify shulker was placed
|
||||
const placedBlock = bot.bot.blockAt(spot.position);
|
||||
if (!placedBlock || !placedBlock.name.includes('shulker_box')) {
|
||||
// Something wrong was placed — break it immediately
|
||||
console.error(`ShulkerHandler: WRONG BLOCK placed at ${spot.position} (${placedBlock?.name}), breaking it`);
|
||||
try { await bot.bot.dig(bot.bot.blockAt(spot.position)); } catch (e) { /* best effort */ }
|
||||
throw new Error(`Failed to place shulker at ${spot.position}`);
|
||||
}
|
||||
|
||||
// Step 3: Open the shulker
|
||||
const shulkerWindow = await bot.openContainer(placedBlock);
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
// Step 4: Move ALL items from shulker into bot inventory
|
||||
const extracted = [];
|
||||
let inventoryFull = false;
|
||||
const shulkerSlotCount = shulkerWindow.inventoryStart;
|
||||
|
||||
for (let s = 0; s < shulkerSlotCount; s++) {
|
||||
const shulkerSlotItem = shulkerWindow.slots[s];
|
||||
if (!shulkerSlotItem) continue;
|
||||
|
||||
// Find a target slot in bot inventory (stack first, then empty)
|
||||
let targetSlot = null;
|
||||
|
||||
for (let i = shulkerWindow.inventoryStart; i < shulkerWindow.inventoryEnd; i++) {
|
||||
const invItem = shulkerWindow.slots[i];
|
||||
if (invItem && invItem.name === shulkerSlotItem.name && invItem.count < invItem.stackSize) {
|
||||
targetSlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Equip and place the shulker
|
||||
await bot.bot.equip(shulkerItem, 'hand');
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
// Verify we're actually holding the shulker
|
||||
const heldItem = bot.bot.heldItem;
|
||||
if (!heldItem || !heldItem.name.includes('shulker_box')) {
|
||||
throw new Error(`Cannot equip shulker for unpack (holding: ${heldItem?.name || 'nothing'})`);
|
||||
}
|
||||
|
||||
// Look at the top face center of placement block
|
||||
await bot.bot.lookAt(spot.placeOn.position.offset(0.5, 1, 0.5), true);
|
||||
await bot.bot.waitForTicks(3);
|
||||
|
||||
// SAFETY: verify held item is a shulker RIGHT before placing — never place anything else
|
||||
const prePlaceItem = bot.bot.heldItem;
|
||||
if (!prePlaceItem || !prePlaceItem.name.includes('shulker_box')) {
|
||||
throw new Error(`ABORT: held item changed before place (holding: ${prePlaceItem?.name || 'nothing'})`);
|
||||
}
|
||||
|
||||
await bot.bot.placeBlock(spot.placeOn, spot.faceVec);
|
||||
await bot.bot.waitForTicks(10);
|
||||
|
||||
// Verify shulker was placed
|
||||
const placedBlock = bot.bot.blockAt(spot.position);
|
||||
if (!placedBlock || !placedBlock.name.includes('shulker_box')) {
|
||||
// Something wrong was placed — break it immediately
|
||||
console.error(`ShulkerHandler: WRONG BLOCK placed at ${spot.position} (${placedBlock?.name}), breaking it`);
|
||||
try { await bot.bot.dig(bot.bot.blockAt(spot.position)); } catch (e) { /* best effort */ }
|
||||
throw new Error(`Failed to place shulker at ${spot.position}`);
|
||||
}
|
||||
|
||||
// Step 3: Open the shulker
|
||||
const shulkerWindow = await bot.openContainer(placedBlock);
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
// Step 4: Move ALL items from shulker into bot inventory
|
||||
const extracted = [];
|
||||
let inventoryFull = false;
|
||||
const shulkerSlotCount = shulkerWindow.inventoryStart;
|
||||
|
||||
for (let s = 0; s < shulkerSlotCount; s++) {
|
||||
const shulkerSlotItem = shulkerWindow.slots[s];
|
||||
if (!shulkerSlotItem) continue;
|
||||
|
||||
// Find a target slot in bot inventory (stack first, then empty)
|
||||
let targetSlot = null;
|
||||
|
||||
if (targetSlot === null) {
|
||||
for (let i = shulkerWindow.inventoryStart; i < shulkerWindow.inventoryEnd; i++) {
|
||||
const invItem = shulkerWindow.slots[i];
|
||||
if (invItem && invItem.name === shulkerSlotItem.name && invItem.count < invItem.stackSize) {
|
||||
if (!shulkerWindow.slots[i]) {
|
||||
targetSlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetSlot === null) {
|
||||
for (let i = shulkerWindow.inventoryStart; i < shulkerWindow.inventoryEnd; i++) {
|
||||
if (!shulkerWindow.slots[i]) {
|
||||
targetSlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetSlot === null) {
|
||||
console.log('ShulkerHandler: Bot inventory full during unpack');
|
||||
inventoryFull = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemName = shulkerSlotItem.name;
|
||||
const itemCount = shulkerSlotItem.count;
|
||||
await bot.bot.moveSlotItem(s, targetSlot);
|
||||
await sleep(200);
|
||||
extracted.push({ name: itemName, count: itemCount });
|
||||
} catch (error) {
|
||||
console.error(`ShulkerHandler: Error moving item from shulker slot ${s}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Close the shulker window — wait for closing animation
|
||||
await bot.bot.closeWindow(shulkerWindow);
|
||||
await bot.bot.waitForTicks(30);
|
||||
|
||||
// Step 6: Break the shulker block and pick it up
|
||||
const collected = await this.digAndCollectShulker(bot, spot.position);
|
||||
if (!collected) {
|
||||
console.error('ShulkerHandler: Shulker not found in inventory after breaking');
|
||||
if (targetSlot === null) {
|
||||
console.log('ShulkerHandler: Bot inventory full during unpack');
|
||||
inventoryFull = true;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`ShulkerHandler: Unpacked ${extracted.length} item stacks from shulker`);
|
||||
return { extracted, inventoryFull };
|
||||
|
||||
} finally {
|
||||
this.operationInProgress = false;
|
||||
try {
|
||||
const itemName = shulkerSlotItem.name;
|
||||
const itemCount = shulkerSlotItem.count;
|
||||
await bot.bot.moveSlotItem(s, targetSlot);
|
||||
await sleep(200);
|
||||
extracted.push({ name: itemName, count: itemCount });
|
||||
} catch (error) {
|
||||
console.error(`ShulkerHandler: Error moving item from shulker slot ${s}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Close the shulker window — wait for closing animation
|
||||
await bot.bot.closeWindow(shulkerWindow);
|
||||
await bot.bot.waitForTicks(30);
|
||||
|
||||
// Step 6: Break the shulker block and pick it up
|
||||
const collected = await this.digAndCollectShulker(bot, spot.position);
|
||||
if (!collected) {
|
||||
console.error('ShulkerHandler: Shulker not found in inventory after breaking');
|
||||
}
|
||||
|
||||
console.log(`ShulkerHandler: Unpacked ${extracted.length} item stacks from shulker`);
|
||||
return { extracted, inventoryFull };
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -865,11 +839,6 @@ class ShulkerHandler {
|
||||
async withdrawFromShulker(bot, chestPos, chestSlot, itemName, count, shulkerId, chestId) {
|
||||
console.log(`ShulkerHandler: Withdrawing ${count}x ${itemName} from shulker at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
if (this.operationInProgress) {
|
||||
throw new Error('Another shulker operation is in progress');
|
||||
}
|
||||
this.operationInProgress = true;
|
||||
|
||||
let placedPos = null;
|
||||
|
||||
try {
|
||||
@@ -1000,8 +969,6 @@ class ShulkerHandler {
|
||||
}
|
||||
if (!recovered) throw error;
|
||||
return { withdrawn: 0, updatedSlotItem: null };
|
||||
} finally {
|
||||
this.operationInProgress = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -1012,11 +979,6 @@ class ShulkerHandler {
|
||||
async withdrawFromShulkerSlot(bot, chestPos, chestSlot, shulkerSlot, count, shulkerId, chestId) {
|
||||
console.log(`ShulkerHandler: Withdrawing from shulker slot ${shulkerSlot} at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
if (this.operationInProgress) {
|
||||
throw new Error('Another shulker operation is in progress');
|
||||
}
|
||||
this.operationInProgress = true;
|
||||
|
||||
let placedPos = null;
|
||||
|
||||
try {
|
||||
@@ -1119,8 +1081,6 @@ class ShulkerHandler {
|
||||
console.error('ShulkerHandler: Slot withdraw failed, attempting recovery:', error.message);
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
|
||||
return { withdrawn: 0, updatedSlotItem: null };
|
||||
} finally {
|
||||
this.operationInProgress = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user