forked from wmantly/mc-bot-town
fable
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
'use strict';
|
||||
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const { sleep } = require('../../utils');
|
||||
|
||||
function _notify(msg) {
|
||||
try {
|
||||
const { getInstance } = require('./manager');
|
||||
getInstance().notifySystemEvent(msg);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet orchestration tools — registered once with the AiManager.
|
||||
* The LLM calls these to activate, move, and coordinate bots across the server.
|
||||
*
|
||||
* All bot→player and bot→bot interaction uses vanilla commands only:
|
||||
* /invite, /trade, /msg
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build and return the full fleet tool registry.
|
||||
* @param {object} config - merged ai config
|
||||
* @param {object} memoryDB - AIMemoryDB singleton
|
||||
* @returns {Array} tool definitions with { name, description, parameters, category, execute }
|
||||
*/
|
||||
function buildFleetTools(config, memoryDB) {
|
||||
const faceBotName = config.faceBot;
|
||||
const storageBotName = config.storageBot;
|
||||
|
||||
const tools = [];
|
||||
|
||||
// ========================================
|
||||
// Bot lifecycle
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_activate',
|
||||
category: 'fleet',
|
||||
description: `Bring an offline bot online so it can do work. Use before asking a bot to do anything. The bot will auto-disconnect after being idle. Available bots: ${storageBotName} (storage/items), plus any bot in the fleet.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Name of the bot to bring online' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (bot.isReady) return `${params.botName} is already online`;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
bot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${params.botName} is now ONLINE and ready for tasks`);
|
||||
return `${params.botName} is now online and ready`;
|
||||
} catch (err) {
|
||||
return `Failed to bring ${params.botName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_deactivate',
|
||||
category: 'fleet',
|
||||
description: 'Disconnect a bot when it is no longer needed. On-demand bots auto-disconnect after being idle, but this forces it sooner.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Name of the bot to disconnect' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (!bot.isReady) return `${params.botName} is already offline`;
|
||||
bot.autoReConnect = false;
|
||||
bot.quit(true);
|
||||
return `${params.botName} is disconnecting`;
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Movement
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_goto_player',
|
||||
category: 'fleet',
|
||||
description: 'Send a bot to a player\'s current location. The bot will pathfind there automatically. IMPORTANT: the target bot must already be online (use bot_activate first if needed).',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to move' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Which player to go to' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot || !bot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const player = bot.bot.players[params.playerName];
|
||||
if (!player || !player.entity) return `Cannot find player ${params.playerName} — they may be too far or offline`;
|
||||
|
||||
const nav = bot.plunginsLoaded['Navigation'];
|
||||
if (nav && typeof nav.handleCommand === 'function') {
|
||||
const result = nav.handleCommand('ai', 'goto', `${player.entity.position.x} ${player.entity.position.y} ${player.entity.position.z}`, '3');
|
||||
_notify(`${params.botName} is moving to ${params.playerName} at (${Math.round(player.entity.position.x)},${Math.round(player.entity.position.y)},${Math.round(player.entity.position.z)})`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: use goTo directly
|
||||
try {
|
||||
await bot.goTo({ where: player.entity.position, range: 3 });
|
||||
return `${params.botName} arrived near ${params.playerName}`;
|
||||
} catch (err) {
|
||||
return `${params.botName} failed to reach ${params.playerName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_come_to_face',
|
||||
category: 'fleet',
|
||||
description: `Send a bot to come to you (${faceBotName}), the face bot's location. Useful for bot-to-bot trades.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to bring here' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const targetBot = CJbot.bots[params.botName];
|
||||
if (!targetBot || !targetBot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const faceBot = CJbot.bots[faceBotName];
|
||||
if (!faceBot || !faceBot.isReady || !faceBot.bot?.entity) return 'Face bot is not online';
|
||||
|
||||
const nav = targetBot.plunginsLoaded['Navigation'];
|
||||
const pos = faceBot.bot.entity.position;
|
||||
if (nav && typeof nav.handleCommand === 'function') {
|
||||
return nav.handleCommand('ai', 'goto', `${pos.x} ${pos.y} ${pos.z}`, '3');
|
||||
}
|
||||
|
||||
try {
|
||||
await targetBot.goTo({ where: pos, range: 3 });
|
||||
return `${params.botName} arrived at ${faceBotName}'s location`;
|
||||
} catch (err) {
|
||||
return `${params.botName} failed to reach ${faceBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Trading
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_trade_with_player',
|
||||
category: 'fleet',
|
||||
description: 'Have a bot initiate a trade with a player. The bot sends /trade <player>, waits for them to accept, then you can guide the trade. The bot will auto-place any pending withdrawal items in the trade window.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot should trade' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Which player to trade with' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot || !bot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const player = bot.bot.players[params.playerName];
|
||||
if (!player) return `Player ${params.playerName} is not online or not in range`;
|
||||
|
||||
try {
|
||||
// Send trade request
|
||||
await bot.say(`/trade ${params.playerName}`);
|
||||
|
||||
// Wait for trade window
|
||||
const window = await Promise.race([
|
||||
bot.once('windowOpen'),
|
||||
sleep(30000).then(() => null)
|
||||
]);
|
||||
|
||||
if (!window) return `Trade request to ${params.playerName} timed out (30s)`;
|
||||
|
||||
// If bot has Storage plugin, let it handle placing withdrawn items
|
||||
const storage = bot.plunginsLoaded['Storage'];
|
||||
if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') {
|
||||
await storage.placeWithdrawnItemsInTrade(window, params.playerName);
|
||||
}
|
||||
|
||||
// Confirm on bot side — single click, not moveSlotItem's
|
||||
// pickup+putdown pair (anti-cheat flags that as bad packets)
|
||||
try { await bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
|
||||
return `Trade window opened with ${params.playerName}. Bot side confirmed.`;
|
||||
} catch (err) {
|
||||
return `Trade failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'face_trade_accept',
|
||||
category: 'fleet',
|
||||
description: 'Accept an incoming trade request on the face bot. Use when another bot or player is trying to trade with you.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const faceBot = CJbot.bots[faceBotName];
|
||||
if (!faceBot || !faceBot.isReady) return 'Face bot is not online';
|
||||
|
||||
try {
|
||||
faceBot.bot.chat('/trade accept');
|
||||
const window = await Promise.race([
|
||||
faceBot.once('windowOpen'),
|
||||
sleep(15000).then(() => null)
|
||||
]);
|
||||
if (!window) return 'Trade accept timed out (15s)';
|
||||
|
||||
return `Trade window opened on ${faceBotName}`;
|
||||
} catch (err) {
|
||||
return `Failed to accept trade: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Bot status
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_status',
|
||||
category: 'fleet',
|
||||
description: 'Check whether a bot is online, its position, and health.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to check' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (!bot.isReady) return `${params.botName} is offline`;
|
||||
if (!bot.bot?.entity) return `${params.botName} is connecting (no entity yet)`;
|
||||
|
||||
const e = bot.bot.entity;
|
||||
return `${params.botName}: online, health=${bot.bot.health}/20, food=${bot.bot.food}/20, pos=(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_list_all',
|
||||
category: 'fleet',
|
||||
description: 'List all fleet bots and whether they are online or offline.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const lines = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
if (bot.isReady && bot.bot?.entity) {
|
||||
const e = bot.bot.entity;
|
||||
lines.push(`${name}: ONLINE (${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})`);
|
||||
} else {
|
||||
lines.push(`${name}: offline`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Storage — read-only (no bot needed)
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'storage_find',
|
||||
category: 'storage',
|
||||
description: 'Search the storage database for an item. Use when someone asks "how much X do we have" or "do you have any Y". Returns item names and counts. No bot needs to be online.',
|
||||
parameters: [
|
||||
{ name: 'itemName', type: 'string', required: true, description: 'Item name or partial name to search for' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const Database = require('../storage/database');
|
||||
const items = await Database.searchItems(params.itemName);
|
||||
if (!items || items.length === 0) return `Storage has no '${params.itemName}'`;
|
||||
return items.slice(0, 8).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_list',
|
||||
category: 'storage',
|
||||
description: 'List the most stocked items in storage. Use to see what is available.',
|
||||
parameters: [
|
||||
{ name: 'limit', type: 'number', required: false, description: 'Max results (default 10)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const Database = require('../storage/database');
|
||||
const items = await Database.searchItems(null);
|
||||
const limit = params.limit || 10;
|
||||
return (items || []).slice(0, limit).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Storage — actions (requires storage bot)
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'storage_withdraw',
|
||||
category: 'storage',
|
||||
description: `Withdraw items from storage and deliver them to a player. This will: activate ${storageBotName} if offline, pull items from shulkers, move to the player, and open a trade. The whole process takes 30-90 seconds. Use this when someone asks you to get them items. After delivery, ${storageBotName} will auto-disconnect.`,
|
||||
parameters: [
|
||||
{ name: 'itemName', type: 'string', required: true, description: 'Item to withdraw (e.g. diamond, golden_carrot, iron_ingot)' },
|
||||
{ name: 'count', type: 'number', required: true, description: 'How many to withdraw (e.g. 64)' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player to deliver to' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
// Step 1: ensure storage bot is online
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: execute withdraw via storage plugin's handleCommand
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage || typeof storage.handleCommand !== 'function') {
|
||||
return `Storage plugin is not loaded on ${storageBotName}`;
|
||||
}
|
||||
|
||||
// handleWithdrawRequest does the full flow: withdraw → trade with player
|
||||
// But we need to call it properly. It expects playerName to be the recipient.
|
||||
try {
|
||||
await storageBot.interruptTask('ai');
|
||||
await storage.handleWithdrawRequest(params.playerName, params.itemName, params.count);
|
||||
return `Withdrawing ${params.count} ${params.itemName} for ${params.playerName}. ${storageBotName} will trade with them shortly.`;
|
||||
} catch (err) {
|
||||
return `Storage withdraw failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_scan',
|
||||
category: 'storage',
|
||||
description: `Bring ${storageBotName} online, scan the storage area to update the item database, then disconnect. Use when inventory might be stale. Takes ~30 seconds.`,
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage) return 'Storage plugin not loaded';
|
||||
|
||||
try {
|
||||
const result = await storage.handleCommand('ai', 'scan');
|
||||
// Auto-deactivate after scan since nothing else is queued
|
||||
return result;
|
||||
} catch (err) {
|
||||
return `Scan failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_organize',
|
||||
category: 'storage',
|
||||
description: `Bring ${storageBotName} online and sort everything into place: unpack mixed shulkers, file loose items into the right shulkers, and consolidate partial ones. Use when someone asks to "put items away", "sort the storage", or after a big deposit. Can take several minutes.`,
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage) return 'Storage plugin not loaded';
|
||||
|
||||
// Long operation — run in the background so the face bot keeps
|
||||
// chatting; report the outcome via a system event when done
|
||||
const notify = (text) => {
|
||||
try {
|
||||
const { getInstance } = require('./manager');
|
||||
if (getInstance().isActive) getInstance().notifySystemEvent(text);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
storage.handleCommand('ai', 'organize')
|
||||
.then(result => notify(`${storageBotName} finished organizing storage: ${result}`))
|
||||
.catch(err => notify(`${storageBotName} organize failed: ${err.message}`));
|
||||
|
||||
return `${storageBotName} started organizing storage. It runs in the background and takes a few minutes; you'll get a system message when it finishes.`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_status',
|
||||
category: 'storage',
|
||||
description: 'Get storage totals: item count, shulker count, chest count. No bot needs to be online.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const Database = require('../storage/database');
|
||||
const stats = await Database.getStats();
|
||||
return `Storage: ${stats.totalItems} items in ${stats.totalShulkers} shulkers (${stats.totalChests} chests)`;
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Trade between bots
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_trade_between',
|
||||
category: 'fleet',
|
||||
description: 'Orchestrate a trade between two bots. Both bots must be online. Bot A initiates /trade with Bot B, both accept, and items can transfer. Use for restocking — e.g. ez trades a shulker of shells to Art.',
|
||||
parameters: [
|
||||
{ name: 'fromBot', type: 'string', required: true, description: 'Bot that has the items (initiates trade)' },
|
||||
{ name: 'toBot', type: 'string', required: true, description: 'Bot receiving the items' },
|
||||
{ name: 'itemName', type: 'string', required: false, description: 'Specific item to move (optional, leave blank to move whatever is in pending withdrawals)' },
|
||||
{ name: 'count', type: 'number', required: false, description: 'Amount to move to the other bot (optional)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const fromWrapped = CJbot.bots[params.fromBot];
|
||||
const toWrapped = CJbot.bots[params.toBot];
|
||||
if (!fromWrapped || !fromWrapped.isReady) return `${params.fromBot} is not online`;
|
||||
if (!toWrapped || !toWrapped.isReady) return `${params.toBot} is not online`;
|
||||
|
||||
try {
|
||||
// fromBot sends trade request
|
||||
await fromWrapped.say(`/trade ${params.toBot}`);
|
||||
|
||||
// Wait for trade window to open on fromBot
|
||||
const window = await Promise.race([
|
||||
fromWrapped.once('windowOpen'),
|
||||
sleep(30000).then(() => null)
|
||||
]);
|
||||
|
||||
if (!window) return `Trade between ${params.fromBot} and ${params.toBot} timed out`;
|
||||
|
||||
// If fromBot is the storage bot with pending withdrawals, place those items
|
||||
const storage = fromWrapped.plunginsLoaded['Storage'];
|
||||
if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') {
|
||||
await storage.placeWithdrawnItemsInTrade(window, params.toBot);
|
||||
}
|
||||
|
||||
// fromBot confirms — single click (see anti-cheat note above)
|
||||
try { await fromWrapped.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
await sleep(1000);
|
||||
|
||||
// Wait for window to close (trade complete or timeout)
|
||||
await Promise.race([
|
||||
fromWrapped.once('windowClose'),
|
||||
sleep(60000)
|
||||
]);
|
||||
|
||||
// Close window if still open
|
||||
try { fromWrapped.bot.closeWindow(window); } catch (e) { /* ignore */ }
|
||||
|
||||
return `Trade from ${params.fromBot} to ${params.toBot} completed`;
|
||||
} catch (err) {
|
||||
return `Bot-to-bot trade failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// FarmSupply — deposit filled shulker boxes to storage
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'farm_empty_filled_boxes',
|
||||
category: 'farm',
|
||||
description: `Empty the "filled boxes" chest on a farm bot, depositing all filled shulker boxes into storage via ${storageBotName}. The farm bot pauses its action plugins, trades each batch of up to 12 shulkers to ${storageBotName}, then resumes. ${storageBotName} must be online or will be activated. Use when a farm bot's output chest is full and needs to be cleared. Takes 30-90 seconds.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName}). Must have FarmSupply loaded.` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for farm deposit`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Pause farm plugins, empty boxes, resume
|
||||
try {
|
||||
const paused = fs.pauseFarmPlugins();
|
||||
_notify(`${farmBotName} paused farm plugins, emptying filled boxes to storage`);
|
||||
await fs.emptyFilledBoxes();
|
||||
await fs.resumeFarmPlugins(paused);
|
||||
_notify(`${farmBotName} finished emptying filled boxes, farm plugins resumed`);
|
||||
return `${farmBotName} emptied all filled shulker boxes to storage. Farm plugins resumed.`;
|
||||
} catch (err) {
|
||||
return `Failed to empty filled boxes on ${farmBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'farm_fill_empty_shulkers',
|
||||
category: 'farm',
|
||||
description: `Refill the "empty shulkers" chest on a farm bot. Withdraws shulker_shells and chests from ${storageBotName} if needed, crafts shulker boxes, and deposits them. ${storageBotName} must be online or will be activated. Use when the farm bot is out of empty shulker boxes or when someone asks to "refill the empty shulkers at the farm". Takes 30-90 seconds.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName}). Must have FarmSupply loaded.` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for empty shulker refill`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_notify(`${farmBotName} refilling empty shulkers chest`);
|
||||
await fs.fillEmptyShulkers();
|
||||
_notify(`${farmBotName} empty shulkers chest refilled`);
|
||||
return `${farmBotName} empty shulkers chest refilled with freshly crafted shulker boxes.`;
|
||||
} catch (err) {
|
||||
return `Failed to fill empty shulkers on ${farmBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'farm_resupply',
|
||||
category: 'farm',
|
||||
description: `Run the full resupply cycle on a farm bot: pause farm plugins, empty "filled boxes" chest to ${storageBotName}, refill "empty shulkers" chest (crafting shulker boxes if needed), resume farm plugins. ${storageBotName} will be activated if offline. Takes 1-3 minutes. Use when a farm needs complete resupply or when someone asks to "restock the farm".`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName})` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for farm resupply`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Run full resupply
|
||||
try {
|
||||
_notify(`${farmBotName} starting full farm resupply (empty + refill)`);
|
||||
await fs.resupply();
|
||||
_notify(`${farmBotName} resupply complete, farm resumed`);
|
||||
return `${farmBotName} resupply complete: filled boxes emptied to storage, empty shulkers chest refilled, farm plugins resumed.`;
|
||||
} catch (err) {
|
||||
return `Resupply on ${farmBotName} failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Settings tools
|
||||
// ========================================
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'list_settings',
|
||||
category: 'settings',
|
||||
description: 'List all application settings grouped by category (ai, storage, farm). Use to see what is configurable and their current values.',
|
||||
parameters: [
|
||||
{ name: 'category', type: 'string', required: false, description: 'Filter by category: ai, storage, or farm' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const all = params.category
|
||||
? settings.getAllByCategory(params.category)
|
||||
: settings.getAll();
|
||||
const registry = settings.getRegistry();
|
||||
const lines = [];
|
||||
for (const r of registry) {
|
||||
if (params.category && r.category !== params.category) continue;
|
||||
lines.push(`${r.key}: ${JSON.stringify(all[r.key])} [${r.category}] ${r.description}`);
|
||||
}
|
||||
return lines.join('\n') || 'No settings found';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_setting',
|
||||
category: 'settings',
|
||||
description: 'Get the current value of a specific setting. Use before changing a setting to see its current state.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key (e.g., ai.temperature, storage.scanRadius)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const value = settings.get(params.key);
|
||||
const registry = settings.getRegistry().find(r => r.key === params.key);
|
||||
const desc = registry ? ` (${registry.description})` : '';
|
||||
return value !== undefined
|
||||
? `${params.key} = ${JSON.stringify(value)}${desc}`
|
||||
: `Unknown setting: ${params.key}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set_setting',
|
||||
category: 'settings',
|
||||
description: 'Change a setting value. Use to update AI behavior, storage config, or farm supply config. Changes take effect on the next AI poll cycle or immediately for ai.* settings.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key to change (e.g., ai.temperature, storage.scanRadius)' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (numbers and booleans as strings are auto-converted)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
try {
|
||||
const newValue = await settings.set(params.key, params.value);
|
||||
return `Set ${params.key} = ${JSON.stringify(newValue)}`;
|
||||
} catch (err) {
|
||||
return `Failed to set ${params.key}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'list_prompts',
|
||||
category: 'settings',
|
||||
description: 'List all available AI prompt names and their template previews. Use to see what personalities are available.',
|
||||
parameters: [],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const prompts = settings.get('ai.prompts') || {};
|
||||
const names = Object.keys(prompts);
|
||||
if (names.length === 0) return 'No prompts configured.';
|
||||
const currentName = settings.get('ai.promptName');
|
||||
const lines = names.map(n => {
|
||||
const marker = n === currentName ? ' [ACTIVE]' : '';
|
||||
const preview = (prompts[n] || '').substring(0, 80).replace(/\n/g, ' ');
|
||||
return `- ${n}${marker}: ${preview}...`;
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Memory tools
|
||||
// ========================================
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'remember_player',
|
||||
category: 'memory',
|
||||
description: 'Store a fact about a player so you remember it forever (survives restarts, shared with the whole fleet). Use PROACTIVELY the moment you learn something — a player mentions their base, their project, a friend, a preference. Same key overwrites, so use it to update facts too.',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player the fact is about (not necessarily who told you)' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Short snake_case key: base_location, current_project, trust, friend_of, notes' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'The fact, one sentence' }
|
||||
],
|
||||
execute: (p) => memoryDB.setPlayerMemory(p.playerName, p.key, p.value)
|
||||
.then(() => `Stored ${p.key}=${p.value} for ${p.playerName}`)
|
||||
},
|
||||
{
|
||||
name: 'forget_player',
|
||||
category: 'memory',
|
||||
description: 'Delete one stored fact about a player (when it was wrong or is obsolete).',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player name' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Memory key to delete' }
|
||||
],
|
||||
execute: (p) => memoryDB.deletePlayerMemory(p.playerName, p.key)
|
||||
.then(() => `Forgot ${p.key} 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 memoryDB.getAllPlayerMemories(p.playerName);
|
||||
const keys = Object.keys(m);
|
||||
return keys.length ? `Memories: ${JSON.stringify(m)}` : `No memories for ${p.playerName}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_known_players',
|
||||
category: 'memory',
|
||||
description: 'List all players you have stored memories about.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const players = await memoryDB.getAllKnownPlayers();
|
||||
return players.length ? `Known players: ${players.join(', ')}` : 'No known players yet';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set_directive',
|
||||
category: 'memory',
|
||||
description: 'Set a persistent instruction for yourself (current_goal, mood, focus). Survives restarts.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'Directive value' }
|
||||
],
|
||||
execute: (p) => memoryDB.setDirective(faceBotName, p.key, p.value)
|
||||
.then(() => `Directive set: ${p.key}=${p.value}`)
|
||||
},
|
||||
{
|
||||
name: 'get_directive',
|
||||
category: 'memory',
|
||||
description: 'Retrieve a specific directive you set.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const d = await memoryDB.getDirective(faceBotName, p.key);
|
||||
return d !== null ? `${p.key}=${d}` : `No directive for '${p.key}'`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_directives',
|
||||
category: 'memory',
|
||||
description: 'List all active directives.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const all = await memoryDB.getAllDirectives(faceBotName);
|
||||
const keys = Object.keys(all);
|
||||
return keys.length ? `Directives: ${JSON.stringify(all)}` : 'No active directives';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Settings (global and per-bot)
|
||||
// ========================================
|
||||
|
||||
const SettingsManager = require('../settings/manager');
|
||||
|
||||
tools.push({
|
||||
name: 'settings_list',
|
||||
category: 'settings',
|
||||
description: 'List ALL application settings by category. Returns key, type, current value, label, and description. Use this to see what can be configured. Secret values (passwords, API keys) appear as "***".',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const all = SettingsManager.getAll();
|
||||
const registry = SettingsManager.getRegistry();
|
||||
const cats = {};
|
||||
for (const r of registry) {
|
||||
const cat = r.category;
|
||||
if (!cats[cat]) cats[cat] = [];
|
||||
let displayValue = all[r.key];
|
||||
if (r.type === 'secret' && typeof displayValue === 'string' && displayValue.length > 0) {
|
||||
displayValue = '***';
|
||||
}
|
||||
cats[cat].push(`${r.key}=${JSON.stringify(displayValue)} (${r.type}: ${r.description})`);
|
||||
}
|
||||
const parts = [];
|
||||
for (const [cat, items] of Object.entries(cats)) {
|
||||
parts.push(`== ${cat} ==\n${items.join('\n')}`);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_get',
|
||||
category: 'settings',
|
||||
description: 'Get a single setting value by key path (e.g. "ai.model", "storage.scanRadius").',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key, e.g. ai.model, storage.scanRadius' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const registry = SettingsManager.getRegistry();
|
||||
const entry = registry.find(r => r.key === params.key);
|
||||
if (!entry) return `Unknown setting: ${params.key}`;
|
||||
let val = SettingsManager.get(params.key);
|
||||
if (entry.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
return `${params.key}=${JSON.stringify(val)} (${entry.type}, ${entry.category}: ${entry.description})`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_set',
|
||||
category: 'settings',
|
||||
description: 'Change a setting value. Accepts string, number, boolean, or JSON. Changes persist across restarts. Use this to reconfigure the system at runtime.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key to change, e.g. ai.temperature, storage.scanRadius' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (will be coerced to the setting type)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const newVal = await SettingsManager.set(params.key, params.value);
|
||||
return `Set ${params.key}=${JSON.stringify(newVal)}`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_list_bots',
|
||||
category: 'settings',
|
||||
description: 'List all bots and their configuration. Shows autoConnect, onDemand, idleTimeout, commands, plugins, etc. Passwords are redacted.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const names = SettingsManager.getBotNames();
|
||||
if (!names.length) return 'No bots configured';
|
||||
const botReg = SettingsManager.getBotSettingsRegistry();
|
||||
const parts = [];
|
||||
for (const name of names) {
|
||||
const settings = SettingsManager.getBotSettings(name);
|
||||
if (!settings) continue;
|
||||
const lines = [`== ${name} ==`];
|
||||
for (const br of botReg) {
|
||||
let val = settings[br.key];
|
||||
if (br.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
lines.push(` ${br.key}=${JSON.stringify(val)} (${br.type})`);
|
||||
}
|
||||
parts.push(lines.join('\n'));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_get_bot',
|
||||
category: 'settings',
|
||||
description: 'Get a single bot\'s full configuration by name.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Bot name (e.g. art, ez, henry)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = SettingsManager.getBotSettings(params.botName);
|
||||
if (!settings) return `Unknown bot: ${params.botName}`;
|
||||
const botReg = SettingsManager.getBotSettingsRegistry();
|
||||
const lines = [`== ${params.botName} ==`];
|
||||
for (const br of botReg) {
|
||||
let val = settings[br.key];
|
||||
if (br.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
lines.push(` ${br.key}=${JSON.stringify(val)} (${br.type}: ${br.label})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_set_bot',
|
||||
category: 'settings',
|
||||
description: 'Change a bot configuration value. Changes apply immediately to live bots where possible. Use this to enable/disable bots, change their timeouts, modify plugins, update auth credentials, etc.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Bot name to configure (e.g. art, ez, henry)' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key: username, password, auth, autoConnect, autoReConnect, onDemand, idleTimeout, commands, plugins, hasAi' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (coerced to setting type)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const newVal = await SettingsManager.setBotSetting(params.botName, params.key, params.value);
|
||||
const displayVal = (typeof newVal === 'string' && newVal.length > 0 && params.key === 'password') ? '***' : JSON.stringify(newVal);
|
||||
return `Set ${params.botName}.${params.key}=${displayVal}`;
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
module.exports = { buildFleetTools };
|
||||
@@ -0,0 +1,867 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../../conf');
|
||||
const { sleep } = require('../../utils');
|
||||
const { ProviderFactory } = require('./providers');
|
||||
|
||||
function compilePrompt(templateStr) {
|
||||
return new Function('name', 'interval', 'currentPlayers', 'toolsDocs', 'memoryContext', 'timeInfo', 'custom',
|
||||
'return `' + templateStr + '`');
|
||||
}
|
||||
const memoryDB = require('./memory-db');
|
||||
const { buildFleetTools } = require('./fleet-tools');
|
||||
|
||||
class AiManager {
|
||||
constructor() {
|
||||
this._provider = null;
|
||||
this._pollTimer = null;
|
||||
this._polling = false;
|
||||
this._faceBot = null;
|
||||
this._messages = [];
|
||||
this._allTools = [];
|
||||
this._memoryDB = memoryDB;
|
||||
this._active = false;
|
||||
this._config = null;
|
||||
this._consecutiveFailures = 0;
|
||||
this._backoffUntil = 0;
|
||||
this._lastSentMessages = [];
|
||||
this._messageListener = null;
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
this._expectingTradeWindow = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager on a face bot. Idempotent — subsequent calls
|
||||
* with different bots are no-ops. Call shutdown() first to transfer.
|
||||
*/
|
||||
async init(faceBot, configOverride) {
|
||||
if (this._active) {
|
||||
if (this._faceBot === faceBot) {
|
||||
console.log('AiManager: already running on', faceBot.name);
|
||||
return;
|
||||
}
|
||||
console.log('AiManager: already running on', this._faceBot.name,
|
||||
'— shutdown first before re-initting on', faceBot.name);
|
||||
return;
|
||||
}
|
||||
|
||||
this._faceBot = faceBot;
|
||||
this._config = { ...conf.ai, ...configOverride };
|
||||
|
||||
// Override runtime-tunable AI settings from DB (config file provides defaults)
|
||||
const settings = require('../settings/manager');
|
||||
this._config.provider = settings.get('ai.provider');
|
||||
this._config.model = settings.get('ai.model');
|
||||
this._config.temperature = settings.get('ai.temperature');
|
||||
this._config.topP = settings.get('ai.topP');
|
||||
this._config.topK = settings.get('ai.topK');
|
||||
this._config.interval = settings.get('ai.interval');
|
||||
this._config.timeout = settings.get('ai.timeout');
|
||||
this._config.promptName = settings.get('ai.promptName');
|
||||
this._config.enableNativeTools = settings.get('ai.enableNativeTools');
|
||||
this._config.baseUrl = settings.get('ai.baseUrl');
|
||||
this._config.key = settings.get('ai.key');
|
||||
this._config.faceBot = settings.get('ai.faceBot');
|
||||
this._config.storageBot = settings.get('ai.storageBot');
|
||||
|
||||
// Initialize memory DB (first init creates DB, subsequent are no-ops)
|
||||
await this._memoryDB.initialize('./storage/ai-memory.db', faceBot.name);
|
||||
|
||||
// Build fleet-wide tools
|
||||
this._allTools = buildFleetTools(this._config, this._memoryDB);
|
||||
console.log(`AiManager: ${this._allTools.length} fleet tools built`);
|
||||
|
||||
// Create the ONE provider
|
||||
const prompt = await this._buildPrompt();
|
||||
this._provider = ProviderFactory.create({
|
||||
...this._config,
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Set tool schemas if provider supports native function calling AND enabled in config.
|
||||
// Most 9B models crash on native tool schemas (Ollama 500: "XML syntax error").
|
||||
// When disabled, the LLM sees tools described in the prompt text and we parse
|
||||
// tool calls from the JSON response via _extractToolCalls fallback.
|
||||
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
|
||||
this._provider.setTools(this._getToolsSchema());
|
||||
console.log('AiManager: native tool schemas set on provider');
|
||||
} else if (this._config.enableNativeTools) {
|
||||
console.log('AiManager: provider does not support tools, using text-based fallback');
|
||||
} else {
|
||||
console.log('AiManager: native tools disabled via config, using text-based tool descriptions');
|
||||
}
|
||||
|
||||
await this._provider.start();
|
||||
console.log(`AiManager: provider started (${this._config.provider}, model=${this._config.model})`);
|
||||
|
||||
// Set up message listener on the face bot
|
||||
this._setupMessageListener();
|
||||
|
||||
// Start poll timer
|
||||
this._active = true;
|
||||
this._startPolling();
|
||||
console.log(`AiManager: running on ${faceBot.name}, interval=${this._config.interval}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the system prompt without restarting the provider.
|
||||
* Used by the .ai chat command to change personality on the fly.
|
||||
*/
|
||||
async reloadPrompt(promptName, prompCustom) {
|
||||
if (!this._active || !this._provider) return false;
|
||||
if (promptName !== undefined) this._config.promptName = promptName;
|
||||
if (prompCustom !== undefined) this._config.prompCustom = prompCustom;
|
||||
const prompt = await this._buildPrompt();
|
||||
if (typeof this._provider.setPrompt === 'function') {
|
||||
this._provider.setPrompt(prompt);
|
||||
} else {
|
||||
await this._provider.close();
|
||||
this._provider = ProviderFactory.create({ ...this._config, prompt });
|
||||
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
|
||||
this._provider.setTools(this._getToolsSchema());
|
||||
}
|
||||
await this._provider.start();
|
||||
}
|
||||
console.log(`AiManager: prompt reloaded — ${this._config.promptName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this._active = false;
|
||||
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
if (this._messageListener) {
|
||||
this._messageListener();
|
||||
this._messageListener = null;
|
||||
}
|
||||
|
||||
if (this._provider) {
|
||||
try { await this._provider.close(); } catch (e) { /* ignore */ }
|
||||
this._provider = null;
|
||||
}
|
||||
|
||||
this._messages = [];
|
||||
this._faceBot = null;
|
||||
console.log('AiManager: shut down');
|
||||
}
|
||||
|
||||
get isActive() { return this._active; }
|
||||
get faceBotName() { return this._faceBot ? this._faceBot.name : null; }
|
||||
|
||||
/**
|
||||
* Called by SettingsManager when an ai.* setting is changed.
|
||||
* Updates the in-memory config immediately. Provider re-creation
|
||||
* happens on the next poll cycle if model/provider/key changed.
|
||||
*/
|
||||
onSettingChanged(key, newValue) {
|
||||
if (!this._config) return;
|
||||
const shortKey = key.replace('ai.', '');
|
||||
console.log(`AiManager: setting changed — ${key} = ${JSON.stringify(newValue)}`);
|
||||
this._config[shortKey] = newValue;
|
||||
|
||||
if (['promptName', 'prompCustom', 'prompts'].includes(shortKey)) {
|
||||
this.reloadPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let other bots/plugins inject a system notification into the face bot's
|
||||
* message queue so the LLM knows about trade completions, errors, etc.
|
||||
* Call this whenever a fleet bot does something the LLM should know about.
|
||||
*/
|
||||
notifySystemEvent(text) {
|
||||
if (!this._active) return;
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
this._messages.push({
|
||||
type: 'system',
|
||||
text: `[SYSTEM] ${text}`,
|
||||
timestamp,
|
||||
timeAgo: this._getTimeAgo(timestamp),
|
||||
});
|
||||
console.log(`AiManager: system event — ${text}`);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Message listener
|
||||
// ========================================
|
||||
|
||||
_setupMessageListener() {
|
||||
this._messageListener = this._faceBot.on('message', (message, type) => {
|
||||
const msgText = message.toString();
|
||||
|
||||
// Log ALL messages the face bot receives (for debugging)
|
||||
const cleanText = msgText.replace(/\n/g, '\\n').substring(0, 300);
|
||||
console.log(`[MC→${this._faceBot.name}] (${type || 'unknown'}) ${cleanText}`);
|
||||
|
||||
if (type === 'game_info') return;
|
||||
|
||||
// Skip messages from the face bot itself
|
||||
if (msgText.startsWith('<')) {
|
||||
const firstBracket = msgText.split('>')[0];
|
||||
const userMatch = firstBracket.match(/^<\[?.*?\]?\s*(\w+)>$/);
|
||||
if (userMatch) {
|
||||
const speakerName = userMatch[1];
|
||||
if (speakerName === this._faceBot.bot.entity.username) return;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
this._messages.push({
|
||||
type: 'message',
|
||||
text: msgText,
|
||||
timestamp,
|
||||
timeAgo: this._getTimeAgo(timestamp),
|
||||
});
|
||||
});
|
||||
|
||||
// Monitor trade windows for feedback loop
|
||||
this._faceBot.bot.on('windowOpen', (window) => {
|
||||
if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) {
|
||||
this._expectingTradeWindow = false;
|
||||
console.log('AiManager: trade window detected, capturing state');
|
||||
this._setupTradeWindow(window);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Polling
|
||||
// ========================================
|
||||
|
||||
_startPolling() {
|
||||
const intervalMs = (this._config.interval || 10) * 1000;
|
||||
this._pollTimer = setInterval(() => this._pollCycle(), intervalMs);
|
||||
}
|
||||
|
||||
async _pollCycle() {
|
||||
if (!this._active || Date.now() < this._backoffUntil) return;
|
||||
if (this._polling) return;
|
||||
this._polling = true;
|
||||
|
||||
try {
|
||||
// Snapshot and reset
|
||||
const currentMessages = [...this._messages];
|
||||
this._messages = [];
|
||||
|
||||
const hasData = currentMessages.some(m => typeof m === 'object' && m.text);
|
||||
const hasTrade = !!this._tradeWindowState;
|
||||
if (!hasData && !hasTrade) return;
|
||||
|
||||
// Build request data
|
||||
const requestData = {
|
||||
botName: this._faceBot.name,
|
||||
messages: currentMessages,
|
||||
currentTime: new Date().toLocaleString('sv-SE'),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
};
|
||||
|
||||
// Refresh the system prompt with current memories and online players.
|
||||
// Ollama re-sends the system prompt on every request, so new
|
||||
// memories take effect immediately. (Gemini bakes the prompt into
|
||||
// session history and only picks this up on provider restart.)
|
||||
try {
|
||||
if (typeof this._provider.setPrompt === 'function') {
|
||||
this._provider.setPrompt(await this._buildPrompt());
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('AiManager: prompt refresh failed:', e.message);
|
||||
}
|
||||
|
||||
console.log(`AiManager: poll cycle — ${currentMessages.length} messages`);
|
||||
let result;
|
||||
try {
|
||||
result = await this._provider.chat(JSON.stringify(requestData));
|
||||
} catch (error) {
|
||||
console.log('AiManager: API error:', error.message);
|
||||
this._consecutiveFailures++;
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000);
|
||||
this._backoffUntil = Date.now() + backoffMs;
|
||||
console.log(`AiManager: backoff ${backoffMs}ms (#${this._consecutiveFailures})`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._consecutiveFailures = 0;
|
||||
this._backoffUntil = 0;
|
||||
|
||||
// Check for tool calls
|
||||
const requestingPlayer = this._getLastSpeaker(currentMessages);
|
||||
const toolCalls = this._extractToolCalls(result);
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
const seen = new Set();
|
||||
const uniqueCalls = toolCalls.filter(tc => {
|
||||
const key = `${tc.name}:${JSON.stringify(tc.args || {})}`;
|
||||
if (seen.has(key)) { console.log('AiManager: deduplicating tool call:', key); return false; }
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
console.log(`AiManager: ${uniqueCalls.length} tool calls — ${uniqueCalls.map(c => c.name).join(', ')}`);
|
||||
|
||||
// Execute tools
|
||||
const toolResults = [];
|
||||
for (const tc of uniqueCalls) {
|
||||
try {
|
||||
const toolResult = await this._executeTool(tc.name, tc.args || {}, requestingPlayer);
|
||||
toolResults.push({ name: tc.name, result: toolResult, success: true });
|
||||
} catch (execError) {
|
||||
console.error('AiManager: tool execution error:', execError);
|
||||
toolResults.push({ name: tc.name, error: execError.message, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Follow-up: send tool results back to LLM for natural language response
|
||||
if (toolResults.length > 0) {
|
||||
// Build current fleet status for context
|
||||
const fleetStatus = this._getLiveFleetStatus();
|
||||
|
||||
const followupMsg = JSON.stringify({
|
||||
toolResults,
|
||||
fleetStatus,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
instruction: `Tool results and fleet status above.
|
||||
- Fleet bots listed as ONLINE can be interacted with directly by players via /trade.
|
||||
- If ${this._config.storageBot} is ONLINE, the player can trade with them without you doing anything.
|
||||
- Reply with ONE brief message (max 150 chars) as a plain JSON array: [{"text":"...","delay":0}].
|
||||
- Be natural and casual. If the tool failed, apologize naturally. NEVER mention "tool", "bot", or "AI".`
|
||||
});
|
||||
|
||||
try {
|
||||
const followupResult = await this._provider.chat(followupMsg);
|
||||
const responseText = this._provider.getResponse(followupResult);
|
||||
await this._processResponse(responseText);
|
||||
} catch (e) {
|
||||
console.error('AiManager: follow-up chat error:', e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool calls — normal chat response
|
||||
const responseText = this._provider.getResponse(result);
|
||||
await this._processResponse(responseText);
|
||||
|
||||
} catch (error) {
|
||||
console.error('AiManager: poll cycle error:', error);
|
||||
} finally {
|
||||
this._polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tool execution
|
||||
// ========================================
|
||||
|
||||
async _executeTool(toolName, params, from) {
|
||||
const tool = this._allTools.find(t => t.name === toolName);
|
||||
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
||||
console.log(`AiManager: executing ${toolName}`, params);
|
||||
return tool.execute(params, from);
|
||||
}
|
||||
|
||||
_extractToolCalls(result) {
|
||||
// Gemini native function calls
|
||||
if (result.response && typeof result.response.functionCalls === 'function') {
|
||||
const calls = result.response.functionCalls();
|
||||
if (calls && calls.length > 0) {
|
||||
return calls.map(call => ({ name: call.name, args: call.args }));
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama native tool_calls
|
||||
if (result.tool_calls && result.tool_calls.length > 0) {
|
||||
return result.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {},
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: parse JSON from response text
|
||||
const responseText = result.response ? result.response.text() : null;
|
||||
if (responseText) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
if (parsed.tool_call) {
|
||||
return [{ name: parsed.tool_call.name, args: parsed.tool_call.args || parsed.tool_call.arguments || {} }];
|
||||
}
|
||||
if (Array.isArray(parsed.tool_calls)) {
|
||||
return parsed.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {},
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, no tool calls
|
||||
}
|
||||
|
||||
// Final fallback: detect tool names embedded in chat text
|
||||
// This catches models that output [{"text":"storage_find stone","delay":0}]
|
||||
const detectedCalls = this._detectTextToolCalls(responseText);
|
||||
if (detectedCalls) return detectedCalls;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when the LLM embeds tool names in chat message text instead of
|
||||
* using the proper tool_call format. Parses JSON arrays like
|
||||
* [{"text":"storage_find stone","delay":0}] and converts to tool calls.
|
||||
*/
|
||||
_detectTextToolCalls(responseText) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
|
||||
// Handle JSON array of chat messages: [{"text":"storage_find stone","delay":0}]
|
||||
if (Array.isArray(parsed)) {
|
||||
const toolNames = this._allTools.map(t => t.name);
|
||||
const toolCalls = [];
|
||||
|
||||
for (const msg of parsed) {
|
||||
const text = (msg.text || '').trim();
|
||||
if (!text || text === '_' || text.length < 3) continue;
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
if (text === toolName || text.startsWith(toolName + ' ')) {
|
||||
const argsStr = text.substring(toolName.length).trim();
|
||||
const args = this._parseTextArgs(argsStr, toolName);
|
||||
console.log(`AiManager: detected text tool call in chat array: ${toolName}`, args);
|
||||
toolCalls.push({ name: toolName, args });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls.length > 0 ? toolCalls : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON — check if the raw text starts with a known tool name
|
||||
const text = (responseText || '').trim();
|
||||
if (text.length < 3 || text.startsWith('{') || text.startsWith('[')) return null;
|
||||
|
||||
const toolNames = this._allTools.map(t => t.name);
|
||||
for (const toolName of toolNames) {
|
||||
if (text === toolName || text.startsWith(toolName + ' ')) {
|
||||
const argsStr = text.substring(toolName.length).trim();
|
||||
const args = this._parseTextArgs(argsStr, toolName);
|
||||
console.log(`AiManager: detected text tool call in raw text: ${toolName}`, args);
|
||||
return [{ name: toolName, args }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse space-separated text args into a structured object based on the
|
||||
* tool's parameter definitions. Handles formats like:
|
||||
* "stone" → {itemName: "stone"}
|
||||
* "stone 64 wmantly" → {itemName: "stone", count: 64, playerName: "wmantly"}
|
||||
*/
|
||||
_parseTextArgs(argsStr, toolName) {
|
||||
const tool = this._allTools.find(t => t.name === toolName);
|
||||
if (!tool || !tool.parameters || tool.parameters.length === 0) return {};
|
||||
|
||||
const parts = argsStr.split(/\s+/);
|
||||
const params = tool.parameters;
|
||||
const args = {};
|
||||
|
||||
for (let i = 0; i < Math.min(parts.length, params.length); i++) {
|
||||
const value = parts[i];
|
||||
const paramType = params[i].type || 'string';
|
||||
|
||||
if (paramType === 'number') {
|
||||
const num = parseInt(value, 10);
|
||||
args[params[i].name] = isNaN(num) ? value : num;
|
||||
} else {
|
||||
args[params[i].name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
_getToolsSchema() {
|
||||
return this._allTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: (t.parameters || []).reduce((acc, p) => {
|
||||
acc[p.name] = { type: p.type || 'string', description: p.description || '' };
|
||||
return acc;
|
||||
}, {}),
|
||||
required: (t.parameters || []).filter(p => p.required).map(p => p.name),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Prompt building
|
||||
// ========================================
|
||||
|
||||
async _buildPrompt() {
|
||||
const settings = require('../settings/manager');
|
||||
const allPrompts = settings.get('ai.prompts') || {};
|
||||
|
||||
const promptName = this._config.promptName || 'asshole';
|
||||
let templateStr = allPrompts[promptName];
|
||||
|
||||
let promptFn;
|
||||
if (templateStr) {
|
||||
try {
|
||||
promptFn = compilePrompt(templateStr);
|
||||
} catch (e) {
|
||||
console.warn(`AiManager: failed to compile prompt '${promptName}', falling back to config:`, e.message);
|
||||
promptFn = conf.ai.prompts[promptName];
|
||||
}
|
||||
} else {
|
||||
console.warn(`AiManager: prompt '${promptName}' not found in DB or config, falling back to asshole`);
|
||||
templateStr = allPrompts['asshole'];
|
||||
if (templateStr) {
|
||||
try { promptFn = compilePrompt(templateStr); }
|
||||
catch (e) { promptFn = conf.ai.prompts['asshole']; }
|
||||
} else {
|
||||
promptFn = conf.ai.prompts['asshole'];
|
||||
}
|
||||
this._config.promptName = 'asshole';
|
||||
}
|
||||
|
||||
if (!promptFn) {
|
||||
console.warn(`AiManager: prompt '${promptName}' not found, falling back to asshole`);
|
||||
return conf.ai.prompts['asshole'](
|
||||
this._faceBot.bot.entity.username,
|
||||
this._config.interval,
|
||||
'', '', '', '', '',
|
||||
);
|
||||
}
|
||||
|
||||
const fleetContext = this._getFleetContext();
|
||||
const rawToolsDocs = this._getToolsDocumentation();
|
||||
const toolsDocs = fleetContext + (rawToolsDocs ? '\n' + rawToolsDocs : '');
|
||||
|
||||
let currentPlayers = '';
|
||||
try {
|
||||
const players = this._faceBot.getPlayers();
|
||||
currentPlayers = Object.values(players)
|
||||
.map(p => `<[${p.lvl}] ${p.username}>`)
|
||||
.join('\n');
|
||||
} catch (e) {
|
||||
currentPlayers = '(players unavailable)';
|
||||
}
|
||||
|
||||
const timeInfo = this._getCurrentTimeInfo();
|
||||
const memoryContext = await this._getMemoryContext();
|
||||
|
||||
return promptFn(
|
||||
this._faceBot.bot.entity.username,
|
||||
this._config.interval,
|
||||
currentPlayers,
|
||||
toolsDocs,
|
||||
memoryContext,
|
||||
timeInfo,
|
||||
this._config.prompCustom || '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the memory block for the system prompt: bot directives,
|
||||
* general memories, and stored facts about every player currently online.
|
||||
*/
|
||||
async _getMemoryContext() {
|
||||
let context = '';
|
||||
try {
|
||||
const general = await this._memoryDB.getMemoryContext(this._faceBot.name);
|
||||
if (general) context += general;
|
||||
|
||||
// Player memories for everyone online right now (skip fleet bots)
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const botUsernames = new Set(
|
||||
Object.values(CJbot.bots)
|
||||
.map(b => b.bot?.entity?.username)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const onlinePlayers = Object.keys(this._faceBot.bot.players || {})
|
||||
.filter(name => !botUsernames.has(name));
|
||||
|
||||
const playerMems = await this._memoryDB.getPlayerMemoriesForPrompt(onlinePlayers);
|
||||
if (playerMems) {
|
||||
context += 'WHAT YOU KNOW ABOUT PLAYERS CURRENTLY ONLINE (from past conversations — use it naturally, do not recite it):\n'
|
||||
+ playerMems + '\n';
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('AiManager: memory context build failed:', e.message);
|
||||
}
|
||||
return context.trim();
|
||||
}
|
||||
|
||||
_getFleetContext() {
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const botNames = Object.keys(CJbot.bots);
|
||||
const onlineBots = botNames.filter(name => CJbot.bots[name].isReady);
|
||||
const offlineBots = botNames.filter(name => !CJbot.bots[name].isReady);
|
||||
|
||||
const fleetBots = botNames.map(name => {
|
||||
const b = CJbot.bots[name];
|
||||
const status = b.isReady ? 'online' : 'offline (on-demand)';
|
||||
if (name === this._faceBot.name) return `- ${name} (YOU — always online)`;
|
||||
if (name === this._config.storageBot) return `- ${name} (storage, ${status})`;
|
||||
return `- ${name} (${status})`;
|
||||
}).join('\n');
|
||||
|
||||
const nativeToolsNote = this._config.enableNativeTools ? '' : `
|
||||
TOOL CALL FORMAT (CRITICAL — you MUST use this exact format):
|
||||
When you need to call a tool, respond with a SINGLE JSON object:
|
||||
{"tool_call": {"name": "tool_name", "args": {"param1": "value1", "param2": "value2"}}}
|
||||
|
||||
Example: to search for stone, respond with:
|
||||
{"tool_call": {"name": "storage_find", "args": {"itemName": "stone"}}}
|
||||
|
||||
NEVER put tool names in chat text messages. NEVER type "storage_find stone" as a chat message.
|
||||
Use the {"tool_call": ...} format above instead.
|
||||
|
||||
For normal chat (no tools needed), respond with the array format:
|
||||
[{"text": "your message", "delay": 0}]
|
||||
`;
|
||||
|
||||
return `FLEET BOTS YOU CAN CONTROL:
|
||||
${fleetBots}
|
||||
${nativeToolsNote}
|
||||
CRITICAL: Every other name in chat is a PLAYER. Do NOT call bot_activate, bot_status, or any fleet tool on player names. Only use those tools for the fleet bots listed above.
|
||||
|
||||
MEMORY (IMPORTANT — this is how you remember people between sessions):
|
||||
- When a player tells you a fact worth keeping — about themselves OR another player (base location, current project, who they trust, preferences, grudges, play schedule) — call remember_player RIGHT THEN. Don't wait to be asked to remember.
|
||||
- Keys are short snake_case: base_location, current_project, trust, friend_of, notes, warned_about.
|
||||
- One fact per key. Writing to an existing key overwrites it — use that to update stale facts.
|
||||
- What you already know about online players is in your context under "WHAT YOU KNOW ABOUT PLAYERS". For players not online, use recall_player.
|
||||
- If a fact turns out wrong, call forget_player to remove that key.
|
||||
- Standing orders from owners ("always greet new players", "stop mentioning X") are directives: use set_directive.
|
||||
|
||||
CHARACTER RULES (YOU WILL BE REMOVED IF YOU VIOLATE):
|
||||
- You are a human player. NEVER say "bot", "AI", "tool", "fleet", or "activate."
|
||||
- NEVER type server commands (/msg, /trade, /invite, /help, /w, /tell) in public chat. If you need to message someone, use your tools instead.
|
||||
- NEVER suggest commands to other players ("type /msg X", "do /trade Y"). That's robotic.
|
||||
- Keep responses under 120 chars. Casual, brief, natural.
|
||||
- ez is "a friend who handles my storage" if asked. Not a bot.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight fleet status snapshot for post-tool-execution follow-up.
|
||||
* Tells the LLM which bots are online and where they are so it doesn't
|
||||
* say incorrect things like "he can't find you" when the bot is already there.
|
||||
*/
|
||||
_getLiveFleetStatus() {
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const statuses = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
if (bot.isReady && bot.bot?.entity) {
|
||||
const e = bot.bot.entity;
|
||||
statuses.push({ bot: name, status: 'ONLINE', pos: `(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})` });
|
||||
} else {
|
||||
statuses.push({ bot: name, status: 'OFFLINE' });
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
_getToolsDocumentation() {
|
||||
if (this._allTools.length === 0) return '';
|
||||
|
||||
const byCategory = {};
|
||||
for (const t of this._allTools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(t);
|
||||
}
|
||||
|
||||
let doc = '';
|
||||
for (const [cat, tools] of Object.entries(byCategory)) {
|
||||
doc += `## ${cat}\n`;
|
||||
for (const t of tools) {
|
||||
doc += `- **${t.name}**: ${t.description}`;
|
||||
if (t.parameters && t.parameters.length > 0) {
|
||||
doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`;
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Response processing
|
||||
// ========================================
|
||||
|
||||
async _processResponse(responseText) {
|
||||
if (!responseText) return;
|
||||
|
||||
const cleaned = this._stripMarkdownFences(responseText);
|
||||
|
||||
// Try JSON array [{text, delay}]
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const message of parsed) {
|
||||
const msgText = (message.text || '').trim();
|
||||
console.log('AiManager: toSay delay=', message.delay, msgText);
|
||||
|
||||
if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue;
|
||||
|
||||
const dedupeKey = msgText.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('AiManager: skipping duplicate:', msgText);
|
||||
continue;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this._faceBot.sayAiSafe(msgText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (jsonError) {
|
||||
// Not JSON, fall through
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
const text = cleaned.trim();
|
||||
if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return;
|
||||
if (text.startsWith('{') || text.startsWith('```')) {
|
||||
console.log('AiManager: skipping raw JSON/code block');
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupeKey = text.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('AiManager: skipping duplicate plain-text:', text);
|
||||
return;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this._faceBot.sayAiSafe(text);
|
||||
}
|
||||
|
||||
_stripMarkdownFences(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
let cleaned = text.trim();
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
|
||||
cleaned = cleaned.replace(/\n?```\s*$/, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Trade window
|
||||
// ========================================
|
||||
|
||||
_setupTradeWindow(window) {
|
||||
this._tradeWindow = window;
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
|
||||
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
|
||||
for (const slot of customerSlots) {
|
||||
window.on(`updateSlot:${slot}`, () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.on('updateSlot:53', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
window.on('updateSlot:37', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
|
||||
this._faceBot.bot.once('windowClose', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_captureTradeState(window) {
|
||||
const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
|
||||
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
|
||||
|
||||
const readSlots = (slots) => slots
|
||||
.map(s => window.slots[s])
|
||||
.filter(Boolean)
|
||||
.map(item => ({ name: item.name, count: item.count }));
|
||||
|
||||
return {
|
||||
botItems: readSlots(botSlots),
|
||||
customerItems: readSlots(customerSlots),
|
||||
customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'),
|
||||
botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
_getLastSpeaker(messages) {
|
||||
if (!messages || !Array.isArray(messages)) return 'ai';
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg || msg.type !== 'message') continue;
|
||||
const text = msg.text || '';
|
||||
const match = text.match(/^<\[?.*?\]?\s+(\w+)>/);
|
||||
if (match && match[1] && match[1] !== this._faceBot.bot.entity.username) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
_getTimeAgo(timestamp) {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const diffMs = now - past;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return past.toLocaleDateString();
|
||||
}
|
||||
|
||||
_getCurrentTimeInfo() {
|
||||
const now = new Date();
|
||||
return {
|
||||
iso: now.toISOString().replace('T', ' ').substring(0, 19),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
human: now.toLocaleString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _instance = null;
|
||||
|
||||
module.exports = {
|
||||
getInstance() {
|
||||
if (!_instance) _instance = new AiManager();
|
||||
return _instance;
|
||||
},
|
||||
AiManager,
|
||||
};
|
||||
@@ -23,8 +23,8 @@ class AIMemoryDB {
|
||||
*/
|
||||
async initialize(dbPath = './storage/ai-memory.db', botName = 'default') {
|
||||
if (this.db) {
|
||||
this.botName = botName;
|
||||
return; // Already initialized
|
||||
// DB already initialized — don't overwrite botName (singleton shared across bots)
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(dbPath);
|
||||
@@ -122,7 +122,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE AND memory_key = ?
|
||||
`, [playerName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
@@ -137,7 +137,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE
|
||||
`, [playerName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
@@ -158,7 +158,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
DELETE FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE AND memory_key = ?
|
||||
`, [playerName, key], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
@@ -188,12 +188,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Set a directive for this bot
|
||||
*/
|
||||
async setDirective(key, value) {
|
||||
async setDirective(botName, 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) => {
|
||||
`, [botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
@@ -203,12 +203,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get a specific directive
|
||||
*/
|
||||
async getDirective(key) {
|
||||
async getDirective(botName, 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) => {
|
||||
`, [botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.directive_value : null);
|
||||
});
|
||||
@@ -218,12 +218,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all directives for this bot
|
||||
*/
|
||||
async getAllDirectives() {
|
||||
async getAllDirectives(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT directive_key, directive_value FROM bot_directives
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const directives = {};
|
||||
@@ -243,12 +243,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Set a general memory (not player-specific)
|
||||
*/
|
||||
async setGeneralMemory(key, value) {
|
||||
async setGeneralMemory(botName, 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) => {
|
||||
`, [botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
@@ -258,12 +258,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get a general memory
|
||||
*/
|
||||
async getGeneralMemory(key) {
|
||||
async getGeneralMemory(botName, 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) => {
|
||||
`, [botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
});
|
||||
@@ -273,12 +273,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all general memories
|
||||
*/
|
||||
async getAllGeneralMemories() {
|
||||
async getAllGeneralMemories(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM general_memories
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const memories = {};
|
||||
@@ -294,13 +294,13 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all general memories with timestamps
|
||||
*/
|
||||
async getAllGeneralMemoriesWithTimestamps() {
|
||||
async getAllGeneralMemoriesWithTimestamps(botName) {
|
||||
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) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
@@ -313,13 +313,13 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all directives as formatted string with timestamps
|
||||
*/
|
||||
async getDirectivesSummary() {
|
||||
async getDirectivesSummary(botName) {
|
||||
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) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
if (rows.length === 0) {
|
||||
@@ -361,9 +361,9 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get full memory context for prompt injection
|
||||
*/
|
||||
async getMemoryContext() {
|
||||
const directives = await this.getDirectivesSummary();
|
||||
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps();
|
||||
async getMemoryContext(botName) {
|
||||
const directives = await this.getDirectivesSummary(botName);
|
||||
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps(botName);
|
||||
|
||||
let context = '';
|
||||
|
||||
|
||||
@@ -99,6 +99,15 @@ class GeminiProvider {
|
||||
|
||||
setPrompt(prompt) {
|
||||
this.config.prompt = prompt;
|
||||
// The live session carries the prompt as its first history entry —
|
||||
// update it in place so prompt changes (memory refresh, personality
|
||||
// swaps) apply without restarting the session
|
||||
try {
|
||||
const history = this.session?.params?.history;
|
||||
if (history && history[0] && history[0].role === 'user') {
|
||||
history[0].parts = [{ text: prompt }];
|
||||
}
|
||||
} catch (e) { /* session not started yet */ }
|
||||
}
|
||||
|
||||
getResponse(result) {
|
||||
|
||||
@@ -138,6 +138,13 @@ class OllamaProvider {
|
||||
content: rawContent
|
||||
});
|
||||
|
||||
// Cap history — unbounded growth eventually overflows num_ctx,
|
||||
// which silently truncates the system prompt (memories, tool docs)
|
||||
const maxHistory = this.config.maxHistory || 30;
|
||||
if (this.messages.length > maxHistory) {
|
||||
this.messages.splice(0, this.messages.length - maxHistory);
|
||||
}
|
||||
|
||||
// The text() closure strips markdown code fences so consumers
|
||||
// (processResponse, getToolCalls) get clean content.
|
||||
const result = {
|
||||
|
||||
+183
-205
@@ -2,224 +2,202 @@
|
||||
|
||||
const express = require('express');
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const { getInstance } = require('./manager');
|
||||
|
||||
function createRouter() {
|
||||
const router = express.Router();
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/api/ai/status', (req, res) => {
|
||||
try {
|
||||
const result = {};
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai) continue;
|
||||
const config = ai.__getConfig();
|
||||
result[name] = {
|
||||
connected: bot.isReady,
|
||||
provider: config.provider || 'unknown',
|
||||
model: config.model || 'unknown',
|
||||
interval: ai.intervalLength,
|
||||
promptName: ai.promptName || 'unknown',
|
||||
active: !!ai._active,
|
||||
};
|
||||
}
|
||||
res.json({ bots: result });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/status:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
// ---- AI Status ----
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
router.get('/api/ai/status', (req, res) => {
|
||||
try {
|
||||
const manager = getInstance();
|
||||
const result = {};
|
||||
const faceName = manager.faceBotName;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const isFace = name === faceName && manager.isActive;
|
||||
if (!isFace && !bot.plunginsLoaded['Ai']) continue;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
result[name] = {
|
||||
connected: bot.isReady,
|
||||
provider: manager.isActive ? (manager._config?.provider || 'unknown') : 'unknown',
|
||||
model: manager.isActive ? (manager._config?.model || 'unknown') : 'unknown',
|
||||
interval: manager.isActive ? (manager._config?.interval || 10) : 10,
|
||||
promptName: isFace && manager._config ? (manager._config.promptName || 'unknown') : 'unknown',
|
||||
active: isFace && manager.isActive,
|
||||
isFace,
|
||||
};
|
||||
}
|
||||
res.json({ bots: result });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/status:', 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 });
|
||||
}
|
||||
});
|
||||
// ---- Bot list (for UI) ----
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
router.get('/api/ai/bots', (req, res) => {
|
||||
try {
|
||||
const manager = getInstance();
|
||||
const faceName = manager.faceBotName;
|
||||
const bots = [];
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const isFace = name === faceName && manager.isActive;
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
bots.push({
|
||||
name,
|
||||
hasAI: isFace || !!ai,
|
||||
hasMemory: isFace || !!(ai && ai.memoryDB),
|
||||
isFace,
|
||||
});
|
||||
}
|
||||
res.json({ bots });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/bots:', 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 });
|
||||
}
|
||||
});
|
||||
// ---- Player Memories (shared across all bots) ----
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
router.get('/api/ai/memories/players', async (req, res) => {
|
||||
try {
|
||||
const manager = getInstance();
|
||||
const players = manager.isActive
|
||||
? await manager._memoryDB.getAllKnownPlayers()
|
||||
: [];
|
||||
res.json({ players });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/players:', 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 });
|
||||
}
|
||||
});
|
||||
router.get('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { playerName } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const memories = await manager._memoryDB.getAllPlayerMemories(playerName);
|
||||
res.json({ player: playerName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
router.post('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { playerName } = req.params;
|
||||
const { key, value } = req.body;
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
await manager._memoryDB.setPlayerMemory(playerName, key, value);
|
||||
res.json({ success: true, player: playerName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/api/ai/memories/:botName/:playerName/:key', async (req, res) => {
|
||||
try {
|
||||
const { playerName, key } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
await manager._memoryDB.deletePlayerMemory(playerName, key);
|
||||
res.json({ success: true, player: playerName, key });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories DELETE:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Bot Directives ----
|
||||
|
||||
router.get('/api/ai/directives/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const directives = await manager._memoryDB.getAllDirectives(botName);
|
||||
res.json({ bot: botName, directives });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
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 manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
await manager._memoryDB.setDirective(botName, key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- General Memories ----
|
||||
|
||||
router.get('/api/ai/general-memories/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const memories = await manager._memoryDB.getAllGeneralMemories(botName);
|
||||
res.json({ bot: botName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
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 manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
await manager._memoryDB.setGeneralMemory(botName, key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
const webUI = {
|
||||
|
||||
Reference in New Issue
Block a user