diff --git a/.gitignore b/.gitignore index 3405a38..ba94863 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .env +.nvmrc images/* logs/* pocketbase/* diff --git a/_opt/botUtils.js b/_opt/botUtils.js index edcabfe..f0915bf 100644 --- a/_opt/botUtils.js +++ b/_opt/botUtils.js @@ -9,7 +9,7 @@ export const commands = [ { data: new SlashCommandBuilder() .setName('exit') - .setDescription('Gracefully shutdown the bot (owner only)') + .setDescription('Gracefully shutdown the bot (Owner only)') // Restrict to server administrators by default .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDMPermission(false) @@ -39,13 +39,13 @@ export const commands = [ // Acknowledge before shutting down await interaction.reply({ content: `Shutting down with exit code ${exitCode}...`, ephemeral: true }); client.logger.info( - `Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}` + `[cmd:exit] Shutdown initiated by owner ${interaction.user.tag} (${interaction.user.id}), exit code ${exitCode}` ); // Destroy Discord client and exit process try { await client.destroy(); } catch (err) { - client.logger.error(`Error during client.destroy(): ${err}`); + client.logger.error(`[cmd:exit] Error during client.destroy(): ${err}`); } process.exit(exitCode); } @@ -99,6 +99,7 @@ export const commands = [ `Modules : ${loadedModules.length > 0 ? loadedModules.join(', ') : 'None'}` ]; await interaction.editReply({ content: '```\n' + lines.join('\n') + '\n```' }); + client.logger.info(`[cmd:status] Returned status for client ${client.config.id}`); } } ]; @@ -116,7 +117,7 @@ const botUtilsClients = []; * @param {Object} clientConfig */ export async function init(client, clientConfig) { - client.logger.info('botUtils module loaded'); + client.logger.info('[module:botUtils] Module loaded'); // Track this client instance and its config botUtilsClients.push({ client, clientConfig }); } \ No newline at end of file diff --git a/_opt/condimentX.js b/_opt/condimentX.js index 1a30460..84bb67f 100644 --- a/_opt/condimentX.js +++ b/_opt/condimentX.js @@ -362,11 +362,11 @@ export const init = async (client, config) => { // Deferred setup on ready const readyHandler = async () => { - client.logger.info('Initializing CondimentX module'); + client.logger.info('[module:condimentX] Initializing module'); if (openAI === true) { - openai = new OpenAI({ apiKey: openAIToken }); + openai = new OpenAI({ apiKey: openAIToken }); // credentials loaded openAIWebhook = await client.fetchWebhook(openAIWebhookID, openAIWebhookToken).catch(error => { - client.logger.error(`Could not fetch webhook: ${error.message}`); + client.logger.error(`[module:condimentX] Could not fetch webhook: ${error.message}`); return null; }); if (openAIWebhook) openAIWebhookClient = new WebhookClient({ id: openAIWebhookID, token: openAIWebhookToken }); @@ -374,7 +374,7 @@ export const init = async (client, config) => { try { guild = client.guilds.cache.get(guildID); if (!guild) { - client.logger.error(`CondimentX error: Guild ${guildID} not found`); + client.logger.error(`[module:condimentX] Guild ${guildID} not found`); return; } indexRole = await guild.roles.fetch(indexRoleID); diff --git a/_opt/gitUtils.js b/_opt/gitUtils.js index f743c06..3fd950a 100644 --- a/_opt/gitUtils.js +++ b/_opt/gitUtils.js @@ -45,7 +45,7 @@ export const commands = [ { data: new SlashCommandBuilder() .setName('git') - .setDescription('Run an arbitrary git command (owner only)') + .setDescription('Run an arbitrary git command (Owner only)') .addStringOption(opt => opt.setName('args') .setDescription('Arguments to pass to git') @@ -71,7 +71,7 @@ export const commands = [ try { // Log the exact git command being executed const cmdStr = args.join(' '); - client.logger.warn(`Executing git command: git ${cmdStr}`); + client.logger.warn(`[cmd:git] Executing git command: git ${cmdStr}`); const output = await runGit(args); // Prepend the git command as a header; keep it intact when chunking const header = `git ${cmdStr}\n`; @@ -98,5 +98,5 @@ export const commands = [ // No special init logic export async function init(client) { - client.logger.warn('Git utilities module loaded - dangerous module, use with caution'); + client.logger.warn('[module:gitUtils] Git utilities module loaded - dangerous module, use with caution'); } \ No newline at end of file diff --git a/_opt/messageQueue-example.js b/_opt/messageQueue-example.js index bbf1674..1002217 100644 --- a/_opt/messageQueue-example.js +++ b/_opt/messageQueue-example.js @@ -5,7 +5,7 @@ import { onMessageQueueEvent } from './pbUtils.js'; * Example module that listens for 'test' messages in the message_queue collection. */ export const init = async (client, config) => { - client.logger.info('Initializing Message Queue Example module'); + client.logger.info('[module:messageQueueExample] Initializing Message Queue Example module'); onMessageQueueEvent(client, async (action, record) => { // Only process newly created records if (action !== 'create') return; @@ -15,14 +15,14 @@ export const init = async (client, config) => { if (record.dataType !== 'test') return; // At this point we have a test message for us - client.logger.info('test received'); + client.logger.info('[module:messageQueueExample] Test message received'); // Delete the processed message from the queue try { await client.pb.deleteMessageQueue(record.id); - client.logger.debug(`Deleted message_queue record ${record.id}`); + client.logger.debug(`[module:messageQueueExample] Deleted message_queue record ${record.id}`); } catch (err) { - client.logger.error(`Failed to delete message_queue record ${record.id}: ${err.message}`); + client.logger.error(`[module:messageQueueExample] Failed to delete message_queue record ${record.id}: ${err.message}`); } }); }; \ No newline at end of file diff --git a/_opt/pbUtils.js b/_opt/pbUtils.js index 6f31106..95050b2 100644 --- a/_opt/pbUtils.js +++ b/_opt/pbUtils.js @@ -19,7 +19,7 @@ if (typeof global.EventSource === 'undefined') { export const init = async (client, config) => { const { pb, logger } = client; - logger.info('Initializing PocketBase utilities module'); + logger.info('[module:pbUtils] Initializing PocketBase utilities module'); // Attach utility methods to the pb object extendPocketBase(client, pb, logger); @@ -33,9 +33,9 @@ export const init = async (client, config) => { client.emit('message_queue_event', e.action, e.record); logger.debug(`PubSub event: ${e.action} on message_queue: ${JSON.stringify(e.record)}`); }); - logger.info('Subscribed to PocketBase message_queue realtime events'); + logger.info('[module:pbUtils] Subscribed to PocketBase message_queue realtime events'); } catch (error) { - logger.error(`Failed to subscribe to message_queue realtime: ${error.message}`); + logger.error(`[module:pbUtils] Failed to subscribe to message_queue realtime: ${error.message}`); } // end of init() @@ -54,7 +54,7 @@ export function onMessageQueueEvent(client, handler) { try { handler(action, record); } catch (err) { - client.logger.error(`Error in message_queue handler: ${err.message}`); + client.logger.error(`[module:pbUtils] Error in message_queue handler: ${err.message}`); } }); } diff --git a/_opt/responses.js b/_opt/responses.js index f8ea07b..01f5297 100644 --- a/_opt/responses.js +++ b/_opt/responses.js @@ -3,8 +3,7 @@ * Listens to message events, sends chat queries to the OpenAI Responses API, * and handles text or image (function_call) outputs. */ -import fs from 'fs/promises'; -import path from 'path'; +// Removed local file fallback; prompt now comes exclusively from PocketBase via responsesPrompt module import { OpenAI } from 'openai'; import axios from 'axios'; import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; @@ -70,22 +69,6 @@ function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) { return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c); } -/** - * Load AI system prompt text from a file. - * @param {string} filePath - Path to the prompt file. - * @param {object} logger - Logger instance for reporting. - * @returns {Promise} Promise resolving to the prompt text or empty string. - */ -async function loadSystemPrompt(filePath, logger) { - try { - const prompt = await fs.readFile(path.resolve(filePath), 'utf8'); - logger.info(`Loaded system prompt: ${filePath}`); - return prompt; - } catch (err) { - logger.error(`Failed to load system prompt: ${err.message}`); - return ''; - } -} /** * Determine whether the bot should respond to a message. @@ -256,6 +239,7 @@ async function handleImage(client, message, resp, cfg) { async function onMessage(client, cfg, message) { const logger = client.logger; const botId = client.user?.id; + client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`); if (!(await shouldRespond(message, botId, logger))) return; await message.channel.sendTyping(); @@ -290,7 +274,7 @@ async function onMessage(client, cfg, message) { const speakerMention = `<@${message.author.id}>`; const body = { model: cfg.defaultModel, - instructions: client.responsesSystemPrompt, + instructions: client.responsesPrompt, input: `${speakerMention} said to you: ${message.content}`, previous_response_id: prev, max_output_tokens: cfg.defaultMaxTokens, @@ -392,7 +376,7 @@ async function onMessage(client, cfg, message) { } }; // Chain the handler to the last promise - const next = last.then(handler).catch(err => logger.error(err)); + const next = last.then(handler).catch(err => logger.error(`[onMessage] Handler error: ${err.message}`)); lockMap.set(key, next); // Queue enqueued; handler will send response when its turn arrives return; @@ -442,7 +426,7 @@ export async function sendNarrative(client, cfg, channelId, text) { const logger = client.logger; try { // Build the narrative instructions - const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`; + const instructions = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`; const body = { model: cfg.defaultModel, instructions, @@ -450,13 +434,13 @@ export async function sendNarrative(client, cfg, channelId, text) { max_output_tokens: cfg.defaultMaxTokens, temperature: cfg.defaultTemperature, }; - logger.debug('sendNarrative: calling AI with body', body); + logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`); const resp = await client.openai.responses.create(body); - logger.info(`sendNarrative AI response id=${resp.id}`); + logger.info(`[sendNarrative] Received AI response id=${resp.id}`); // Fetch the target channel or thread const channel = await client.channels.fetch(channelId); if (!channel || typeof channel.send !== 'function') { - logger.error(`sendNarrative: cannot send to channel ID ${channelId}`); + logger.error(`[sendNarrative] Cannot send to channel ID ${channelId}`); return; } // Split the output and send @@ -468,7 +452,7 @@ export async function sendNarrative(client, cfg, channelId, text) { } } } catch (err) { - client.logger.error(`sendNarrative error: ${err.message}`); + client.logger.error(`[sendNarrative] Error: ${err.message}`); } } @@ -482,9 +466,10 @@ export async function sendNarrative(client, cfg, channelId, text) { */ export async function init(client, clientConfig) { const cfg = clientConfig.responses; - client.logger.info('Initializing Responses module'); - client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger); + client.logger.info('[module:responses] Initializing Responses module'); + // Initialize prompt from responsesPrompt module (must be loaded before this) + client.responsesPrompt = client.responsesPrompt ?? ''; client.openai = new OpenAI({ apiKey: cfg.apiKey }); client.on('messageCreate', m => onMessage(client, cfg, m)); - client.logger.info('Responses module ready'); + client.logger.info('[module:responses] Responses module ready'); } diff --git a/_opt/responsesPrompt.js b/_opt/responsesPrompt.js new file mode 100644 index 0000000..861db20 --- /dev/null +++ b/_opt/responsesPrompt.js @@ -0,0 +1,159 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; +import fs from 'fs'; +import path from 'path'; +import fetch from 'node-fetch'; +// Modal text input limits +const MAX_LEN = 4000; +const MAX_FIELDS = 5; + +/** + * responsesPrompt module + * Provides a /prompt command to view or update the AI response prompt + * Stored in PocketBase collection 'responses_prompts' with fields: + * clientId (string), prompt (text), updatedBy (string), created/updated timestamps + */ +export const commands = [ + { + data: new SlashCommandBuilder() + .setName('prompt') + .setDescription('View or update the AI response prompt') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDMPermission(false) + .addStringOption(opt => + opt.setName('url') + .setDescription('URL to a .txt file containing the prompt') + .setRequired(false) + ), + async execute(interaction, client) { + const url = interaction.options.getString('url'); + const clientId = client.config.id; + // URL-based update + if (url) { + client.logger.info(`[cmd:prompt] URL update requested for client ${clientId}: ${url}`); + await interaction.deferReply({ ephemeral: true }); + if (!url.toLowerCase().endsWith('.txt')) { + client.logger.warn(`[cmd:prompt] Invalid URL extension, must end .txt: ${url}`); + return interaction.editReply({ content: 'URL must point to a .txt file.', ephemeral: true }); + } + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + // Upsert into PocketBase + const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); + const recId = existing?.id; + await client.pb.upsert('responses_prompts', recId, { + clientId, + prompt: text, + updatedBy: interaction.user.id + }); + client.responsesPrompt = text; + return interaction.editReply({ content: 'Prompt updated from URL.', ephemeral: true }); + } catch (err) { + client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`); + return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true }); + } + } + // Modal-based edit + const existingPrompt = client.responsesPrompt || ''; + // Prevent modal if prompt exceeds capacity + if (existingPrompt.length > MAX_LEN * MAX_FIELDS) { + return interaction.reply({ + content: 'Current prompt too large for modal editing (exceeds 20000 chars); please use URL.', + ephemeral: true + }); + } + // Prepare chunks + const chunks = []; + for (let offset = 0; offset < existingPrompt.length; offset += MAX_LEN) { + chunks.push(existingPrompt.slice(offset, offset + MAX_LEN)); + } + // Build modal + const modal = new ModalBuilder() + .setCustomId(`promptModal-${clientId}`) + .setTitle('Edit AI Prompt'); + // Add text inputs for existing chunks + chunks.forEach((chunk, idx) => { + const input = new TextInputBuilder() + .setCustomId(`prompt_${idx}`) + .setLabel(`Part ${idx + 1}`) + .setStyle(TextInputStyle.Paragraph) + .setRequired(idx === 0) + .setMaxLength(MAX_LEN) + .setValue(chunk); + modal.addComponents(new ActionRowBuilder().addComponents(input)); + }); + // Add empty remaining fields + for (let i = chunks.length; i < MAX_FIELDS; i++) { + const input = new TextInputBuilder() + .setCustomId(`prompt_${i}`) + .setLabel(`Part ${i + 1}`) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setMaxLength(MAX_LEN); + modal.addComponents(new ActionRowBuilder().addComponents(input)); + } + client.logger.info(`[cmd:prompt] Showing modal editor for client ${clientId} with ${chunks.length} parts`); + await interaction.showModal(modal); + } + } +]; + +/** track clients for modal handling */ +const _clients = []; + +/** + * init hook: load existing prompt and register modal submit handler + */ +export async function init(client, clientConfig) { + client.logger.info('[module:responsesPrompt] Module loaded'); + const clientId = clientConfig.id; + // Load prompt from PocketBase or fallback to file + let prompt = ''; + try { + const record = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); + if (record && record.prompt) { + prompt = record.prompt; + } else if (clientConfig.responses?.systemPromptPath) { + const filePath = path.isAbsolute(clientConfig.responses.systemPromptPath) + ? clientConfig.responses.systemPromptPath + : path.join(process.cwd(), clientConfig.responses.systemPromptPath); + prompt = fs.readFileSync(filePath, 'utf8'); + } + } catch (err) { + client.logger.error(`responsesPrompt init error: ${err.message}`); + } + client.responsesPrompt = prompt; + _clients.push({ client, clientConfig }); + // Modal submit listener + client.on('interactionCreate', async interaction => { + if (!interaction.isModalSubmit()) return; + if (interaction.customId !== `promptModal-${clientId}`) return; + // Reassemble prompt from modal fields, inserting line breaks between parts + client.logger.info(`[cmd:prompt] Modal submission received for client ${clientId}`); + const parts = []; + for (let i = 0; i < MAX_FIELDS; i++) { + try { + const value = interaction.fields.getTextInputValue(`prompt_${i}`) || ''; + if (value.trim().length > 0) parts.push(value); + } catch {} + } + const newPrompt = parts.join('\n'); + // Save to PocketBase + try { + client.logger.debug(`[cmd:prompt] Saving new prompt for client ${clientId}, length=${newPrompt.length}`); + const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); + const recId = existing?.id; + await client.pb.upsert('responses_prompts', recId, { + clientId, + prompt: newPrompt, + updatedBy: interaction.user.id + }); + client.responsesPrompt = newPrompt; + await interaction.reply({ content: 'Prompt updated!', ephemeral: true }); + } catch (err) { + client.logger.error(`responsesPrompt modal submit error: ${err.message}`); + await interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); + } + }); +} \ No newline at end of file diff --git a/_opt/responsesQuery.js b/_opt/responsesQuery.js index 1bfdc14..f2214ce 100644 --- a/_opt/responsesQuery.js +++ b/_opt/responsesQuery.js @@ -181,12 +181,13 @@ export const commands = [ }); } } catch (err) { - client.logger.error(`Error checking score: ${err.message}`); + client.logger.error(`[cmd:query] Error checking score: ${err.message}`); return interaction.reply({ content: 'Error verifying your score. Please try again later.', ephemeral: true }); } } const prompt = interaction.options.getString('prompt'); const flag = interaction.options.getBoolean('ephemeral'); + client.logger.info(`[cmd:query] Prompt received from ${interaction.user.id}, length=${prompt.length}`); const ephemeral = flag !== null ? flag : true; await interaction.deferReply({ ephemeral }); @@ -203,7 +204,7 @@ export const commands = [ // Build request body const body = { model: cfg.defaultModel, - instructions: client.responsesSystemPrompt, + instructions: client.responsesPrompt, input: prompt, previous_response_id: previous, max_output_tokens: cfg.defaultMaxTokens, diff --git a/_opt/scExecHangarStatus.js b/_opt/scExecHangarStatus.js index 1ae1b6f..65b07b4 100644 --- a/_opt/scExecHangarStatus.js +++ b/_opt/scExecHangarStatus.js @@ -39,7 +39,7 @@ export const commands = [ } } } catch (error) { - client.logger.error(`Failed to parse timestamp in hangarsync command: ${error.message}`); + client.logger.error(`[cmd:hangarsync] Failed to parse timestamp: ${error.message}`); return interaction.reply({ content: 'Failed to parse timestamp. Please use Unix time in seconds or a valid ISO 8601 string.', ephemeral: true @@ -51,7 +51,7 @@ export const commands = [ // Check PocketBase connection status if (!isPocketBaseConnected(client)) { - client.logger.error('PocketBase not connected when executing hangarsync command'); + client.logger.error('[cmd:hangarsync] PocketBase not connected'); // Try to reconnect if available if (typeof client.pb.ensureConnection === 'function') { @@ -107,7 +107,7 @@ export const commands = [ epoch: `${syncEpoch}`, }); } - client.logger.info(`Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); + client.logger.info(`[cmd:hangarsync] Updated hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); } else { // Create new record if (typeof client.pb.createOne === 'function') { @@ -123,12 +123,12 @@ export const commands = [ epoch: `${syncEpoch}`, }); } - client.logger.info(`Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); + client.logger.info(`[cmd:hangarsync] Created new hangarsync for guild ${interaction.guildId} by user ${interaction.user.id}`); } await interaction.reply(`Executive hangar status has been synced: `); - } catch (error) { - client.logger.error(`Error in hangarsync command: ${error.message}`); + } catch (error) { + client.logger.error(`[cmd:hangarsync] Error: ${error.message}`); await interaction.reply({ content: `Error syncing hangar status. Please try again later.`, ephemeral: true @@ -149,8 +149,8 @@ export const commands = [ const verbose = interaction.options.getBoolean('verbose'); // Check PocketBase connection status - if (!isPocketBaseConnected(client)) { - client.logger.error('PocketBase not connected when executing hangarstatus command'); + if (!isPocketBaseConnected(client)) { + client.logger.error('[cmd:hangarstatus] PocketBase not connected'); // Try to reconnect if available if (typeof client.pb.ensureConnection === 'function') { @@ -190,14 +190,14 @@ export const commands = [ } if (!hangarSync) { - client.logger.info(`No sync data found for guild ${interaction.guildId}`); + client.logger.info(`[cmd:hangarstatus] No sync data found for guild ${interaction.guildId}`); return interaction.reply({ content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.', ephemeral: true }); } - } catch (error) { - client.logger.info(`Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`); + } catch (error) { + client.logger.error(`[cmd:hangarstatus] Error retrieving sync data for guild ${interaction.guildId}: ${error.message}`); return interaction.reply({ content: 'No sync data found. Please use `/hangarsync` first to establish a reference point.', ephemeral: true diff --git a/_opt/scorekeeper.js b/_opt/scorekeeper.js index b680f9c..cf5b292 100644 --- a/_opt/scorekeeper.js +++ b/_opt/scorekeeper.js @@ -11,7 +11,7 @@ const moduleState = { * Initialize the scorekeeper module */ export const init = async (client, config) => { - client.logger.info('Initializing Scorekeeper module'); + client.logger.info('[module:scorekeeper] Initializing Scorekeeper module'); // Check if configuration exists if (!config.scorekeeper) { @@ -57,10 +57,10 @@ async function checkCollection(client) { try { // Check if collection exists by trying to list records await client.pb.collection('scorekeeper').getList(1, 1); - client.logger.info('Scorekeeper collection exists in PocketBase'); + client.logger.info('[module:scorekeeper] Scorekeeper collection exists in PocketBase'); } catch (error) { // If collection doesn't exist, log warning - client.logger.warn('Scorekeeper collection does not exist in PocketBase'); + client.logger.warn('[module:scorekeeper] Scorekeeper collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- userId (text, required)'); @@ -79,9 +79,9 @@ async function checkCollection(client) { async function checkCategoriesCollection(client) { try { await client.pb.collection('scorekeeper_categories').getList(1, 1); - client.logger.info('scorekeeper_categories collection exists'); + client.logger.info('[module:scorekeeper] scorekeeper_categories collection exists'); } catch (error) { - client.logger.warn('scorekeeper_categories collection does not exist in PocketBase'); + client.logger.warn('[module:scorekeeper] scorekeeper_categories collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper_categories" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- name (text, required, unique per guild)'); @@ -98,9 +98,9 @@ async function checkCategoriesCollection(client) { async function checkEventsCollection(client) { try { await client.pb.collection('scorekeeper_events').getList(1, 1); - client.logger.info('scorekeeper_events collection exists'); + client.logger.info('[module:scorekeeper] scorekeeper_events collection exists'); } catch (error) { - client.logger.warn('scorekeeper_events collection does not exist in PocketBase'); + client.logger.warn('[module:scorekeeper] scorekeeper_events collection does not exist in PocketBase'); client.logger.warn('Please create a "scorekeeper_events" collection with fields:'); client.logger.warn('- guildId (text, required)'); client.logger.warn('- userId (text, required)'); @@ -457,11 +457,14 @@ export const commands = [ .setRequired(false) ), - execute: async (interaction, client) => { - const targetUser = interaction.options.getUser('user') || interaction.user; - const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; - // Wrap score retrieval and embed generation in try/catch to handle errors gracefully - try { + execute: async (interaction, client) => { + const targetUser = interaction.options.getUser('user') || interaction.user; + const ephemeral = interaction.options.getBoolean('ephemeral') ?? true; + // Acknowledge early to avoid interaction timeout + await interaction.deferReply({ ephemeral }); + client.logger.info(`[cmd:score] Processing score for user ${targetUser.id}`); + // Wrap score retrieval and embed generation in try/catch to handle errors gracefully + try { // Fetch score data and compute multiplier const baseOutput = client.config.scorekeeper.baseOutput; @@ -474,30 +477,59 @@ export const commands = [ filter: `guildId = "${interaction.guildId}"` }); const catMap = new Map(categories.map(c => [c.id, c.name])); - // Commendations per category + // Commendations grouped by category with reasons const commendEvents = await client.pb.collection('scorekeeper_events').getFullList({ filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "commendation"` }); - const commendByCat = new Map(); - commendEvents.forEach(e => { - const cnt = commendByCat.get(e.categoryId) || 0; - commendByCat.set(e.categoryId, cnt + e.amount); - }); - const commendBreakdown = commendByCat.size > 0 - ? Array.from(commendByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') - : 'None'; - // Citations per category + let commendBreakdown = 'None'; + if (commendEvents.length > 0) { + // Group events by category + const eventsByCat = new Map(); + for (const e of commendEvents) { + const arr = eventsByCat.get(e.categoryId) || []; + arr.push(e); + eventsByCat.set(e.categoryId, arr); + } + // Build breakdown string + const parts = []; + for (const [cid, events] of eventsByCat.entries()) { + const catName = catMap.get(cid) || 'Unknown'; + parts.push(`__${catName}__`); + // List each event as bullet with date and reason + for (const ev of events) { + const date = new Date(ev.created || ev.timestamp); + const shortDate = date.toLocaleDateString(); + const reason = ev.reason || ''; + parts.push(`• ${shortDate}: ${reason}`); + } + } + commendBreakdown = parts.join('\n'); + } + // Citations grouped by category with reasons const citeEvents = await client.pb.collection('scorekeeper_events').getFullList({ filter: `guildId = "${interaction.guildId}" && userId = "${targetUser.id}" && type = "citation"` }); - const citeByCat = new Map(); - citeEvents.forEach(e => { - const cnt = citeByCat.get(e.categoryId) || 0; - citeByCat.set(e.categoryId, cnt + e.amount); - }); - const citeBreakdown = citeByCat.size > 0 - ? Array.from(citeByCat.entries()).map(([cid, cnt]) => `${catMap.get(cid) || 'Unknown'}: ${cnt}`).join('\n') - : 'None'; + let citeBreakdown = 'None'; + if (citeEvents.length > 0) { + const eventsByCat2 = new Map(); + for (const e of citeEvents) { + const arr = eventsByCat2.get(e.categoryId) || []; + arr.push(e); + eventsByCat2.set(e.categoryId, arr); + } + const parts2 = []; + for (const [cid, events] of eventsByCat2.entries()) { + const catName = catMap.get(cid) || 'Unknown'; + parts2.push(`__${catName}__`); + for (const ev of events) { + const date = new Date(ev.created || ev.timestamp); + const shortDate = date.toLocaleDateString(); + const reason = ev.reason || ''; + parts2.push(`• ${shortDate}: ${reason}`); + } + } + citeBreakdown = parts2.join('\n'); + } const embed = new EmbedBuilder() .setAuthor({ name: `${client.user.username}: Scorekeeper Module`, iconURL: client.user.displayAvatarURL() }) .setTitle(`I/O Score for ${(await interaction.guild.members.fetch(targetUser.id).catch(() => null))?.displayName || targetUser.username}`) @@ -519,12 +551,12 @@ export const commands = [ .setFooter({ text: 'Last decay: ' + new Date(scoreData.lastDecay).toLocaleDateString() }) .setTimestamp(); - await interaction.reply({ embeds: [embed], ephemeral }); + await interaction.editReply({ embeds: [embed] }); } catch (error) { - client.logger.error(`Error in score command: ${error.message}`); - try { - await interaction.reply({ content: 'Failed to retrieve I/O score.', ephemeral }); - } catch {} + client.logger.error(`[cmd:score] Error: ${error.message}`); + try { + await interaction.editReply({ content: 'Failed to retrieve I/O score.' }); + } catch {} } } }, @@ -596,6 +628,11 @@ export const commands = [ .setRequired(true) .setAutocomplete(true) ) + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for commendation') + .setRequired(true) + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { @@ -615,6 +652,7 @@ export const commands = [ } const targetUser = interaction.options.getUser('user'); const categoryId = interaction.options.getString('category'); + const reason = interaction.options.getString('reason'); const amount = 1; // Enforce per-category cooldown const cooldown = client.config.scorekeeper.cooldown || 0; @@ -650,10 +688,12 @@ export const commands = [ type: 'commendation', categoryId, amount, + reason, awardedBy: interaction.user.id }); - await interaction.reply(`Added commendation to ${targetUser}.`); + client.logger.info(`[cmd:commend] Added commendation to ${targetUser.id} in category ${categoryId} with reason: ${reason}`); + await interaction.reply(`Added commendation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in commend command: ${error.message}`); await interaction.reply({ @@ -679,6 +719,11 @@ export const commands = [ .setRequired(true) .setAutocomplete(true) ) + .addStringOption(option => + option.setName('reason') + .setDescription('Reason for citation') + .setRequired(true) + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction, client) => { @@ -733,9 +778,11 @@ export const commands = [ type: 'citation', categoryId, amount, + reason, awardedBy: interaction.user.id }); + client.logger.info(`[cmd:cite] Added citation to ${targetUser.id} in category ${categoryId} with reason: ${reason}`); await interaction.reply(`Added citation to ${targetUser}.`); } catch (error) { client.logger.error(`Error in cite command: ${error.message}`); @@ -832,11 +879,12 @@ export const commands = [ } } } - // Public command: list categories + // Public command: list categories (admin-only) ,{ data: new SlashCommandBuilder() .setName('listcategories') - .setDescription('List all commendation/citation categories') + .setDescription('List all commendation/citation categories (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addBooleanOption(opt => opt.setName('ephemeral') .setDescription('Whether the result should be ephemeral') diff --git a/_src/loader.js b/_src/loader.js index 6bec33d..9273ade 100644 --- a/_src/loader.js +++ b/_src/loader.js @@ -16,8 +16,9 @@ export const loadModules = async (clientConfig, client) => { fs.mkdirSync(modulesDir, { recursive: true }); } - // Load each module - for (const moduleName of modules) { + client.logger.info(`[module:loader] Loading modules: ${modules.join(', ')}`); + // Load each module + for (const moduleName of modules) { try { // Try _opt first, then fallback to core _src modules let modulePath = path.join(modulesDir, `${moduleName}.js`); @@ -25,7 +26,7 @@ export const loadModules = async (clientConfig, client) => { // Fallback to core source directory modulePath = path.join(rootDir, '_src', `${moduleName}.js`); if (!fs.existsSync(modulePath)) { - client.logger.warn(`Module not found in _opt or _src: ${moduleName}.js`); + client.logger.warn(`[module:loader] Module not found: ${moduleName}.js`); continue; } } @@ -37,13 +38,13 @@ export const loadModules = async (clientConfig, client) => { // Register commands if the module has them if (module.commands) { - if (Array.isArray(module.commands)) { + if (Array.isArray(module.commands)) { // Handle array of commands for (const command of module.commands) { if (command.data && typeof command.execute === 'function') { const commandName = command.data.name || command.name; client.commands.set(commandName, command); - client.logger.info(`Registered command: ${commandName}`); + client.logger.info(`[module:loader] Registered command: ${commandName}`); } } } else if (typeof module.commands === 'object') { @@ -58,18 +59,18 @@ export const loadModules = async (clientConfig, client) => { } // Call init function if it exists - if (typeof module.init === 'function') { - await module.init(client, clientConfig); - client.logger.info(`Module loaded: ${moduleName}`); - } else { - client.logger.info(`Module loaded (no init function): ${moduleName}`); - } + if (typeof module.init === 'function') { + await module.init(client, clientConfig); + client.logger.info(`[module:loader] Module initialized: ${moduleName}`); + } else { + client.logger.info(`[module:loader] Module loaded (no init): ${moduleName}`); + } // Store the module reference (this isn't used for hot reloading anymore) client.modules = client.modules || new Map(); client.modules.set(moduleName, module); } catch (error) { - client.logger.error(`Failed to load module ${moduleName}: ${error.message}`); + client.logger.error(`[module:loader] Failed to load module ${moduleName}: ${error.message}`); } } }; diff --git a/prompts/kevinarby.txt b/assets/kevinarby.txt similarity index 100% rename from prompts/kevinarby.txt rename to assets/kevinarby.txt diff --git a/config.js b/config.js index f9c95e9..8fa8deb 100644 --- a/config.js +++ b/config.js @@ -5,7 +5,7 @@ export default { clients: [ { - id: 'IO3', + id: 'IO', enabled: true, owner: process.env.OWNER_ID, @@ -51,7 +51,6 @@ export default { defaultModel: 'gpt-4.1', defaultMaxTokens: 1000, defaultTemperature: 0.7, - systemPromptPath: './prompts/absolute.txt', conversationExpiry: 30 * 60 * 1000, minScore: 1.0, tools: { @@ -69,9 +68,10 @@ export default { modules: [ 'botUtils', 'pbUtils', + 'gitUtils', 'responses', - 'responsesQuery', - 'gitUtils' + 'responsesPrompt', + 'responsesQuery' ] }, @@ -79,7 +79,7 @@ export default { { id: 'ASOP', enabled: true, - owner: 378741522822070272, + owner: process.env.OWNER_ID, discord: { appId: process.env.ASOP_DISCORD_APPID, @@ -148,7 +148,7 @@ export default { openAI: true, openAITriggerOnlyDuringIncident: true, openAIResponseDenominator: 1, - openAIInstructionsFile: './prompts/kevinarby.txt', + openAIInstructionsFile: './assets/kevinarby.txt', openAITriggers: [ 'kevin', 'arby', @@ -170,11 +170,10 @@ export default { defaultModel: 'gpt-4.1-mini', defaultMaxTokens: 1000, defaultTemperature: 0.7, - systemPromptPath: './prompts/asop.txt', conversationExpiry: 30 * 60 * 1000, - minScore: 0.25, + minScore: 0.5, tools: { - webSearch: true, + webSearch: false, fileSearch: false, imageGeneration: true, }, @@ -195,13 +194,15 @@ export default { }, modules: [ + 'botUtils', 'pbUtils', + 'condimentX', 'responses', + 'responsesPrompt', 'responsesQuery', 'scorekeeper', 'scorekeeper-example', - 'scExecHangarStatus', - //'condimentX' + 'scExecHangarStatus' ] }, @@ -209,7 +210,7 @@ export default { { id: 'Crowley', enabled: true, - owner: 378741522822070272, + owner: process.env.OWNER_ID, discord: { appId: process.env.CROWLEY_DISCORD_APPID, @@ -253,13 +254,12 @@ export default { defaultModel: 'gpt-4.1', defaultMaxTokens: 1000, defaultTemperature: 0.7, - systemPromptPath: './prompts/crowley.txt', conversationExpiry: 30 * 60 * 1000, - minScore: 1.0, + minScore: 0, tools: { - webSearch: true, + webSearch: false, fileSearch: false, - imageGeneration: true, + imageGeneration: false, }, imageGeneration: { defaultModel: 'gpt-image-1', @@ -269,9 +269,11 @@ export default { }, modules: [ + 'botUtils', 'pbUtils', 'responses', - 'responsesQuery', + 'responsesPrompt', + 'responsesQuery' ] }, @@ -279,7 +281,7 @@ export default { { id: 'Smuuush', enabled: true, - owner: 378741522822070272, + owner: process.env.OWNER_ID, discord: { appId: process.env.SMUUUSH_DISCORD_APPID, @@ -323,7 +325,6 @@ export default { defaultModel: 'gpt-4.1-mini', defaultMaxTokens: 1000, defaultTemperature: 0.7, - systemPromptPath: './prompts/smuuush.txt', conversationExpiry: 30 * 60 * 1000, minScore: 0, tools: { @@ -339,8 +340,10 @@ export default { }, modules: [ + 'botUtils', 'pbUtils', 'responses', + 'responsesPrompt', 'responsesQuery' ], diff --git a/clientx.service b/docs/clientx.service similarity index 62% rename from clientx.service rename to docs/clientx.service index d3c6288..572026b 100644 --- a/clientx.service +++ b/docs/clientx.service @@ -1,22 +1,23 @@ [Unit] -Description=ClientX Discord Bot +Description=ClientX Discord Bot via NVM-Exec After=network.target [Service] # Path to the Node.js executable and the entry point file. -ExecStart=/usr/bin/node /root/clientx/index.js +ExecStart=/home/USER/.nvm/nvm-exec node /home/USER/clientx/index.js # Set the working directory to your project folder. -WorkingDirectory=/root/clientx +WorkingDirectory=/home/USER/clientx # Automatically restart process if it crashes. -Restart=always -# Wait 10 seconds before attempting a restart. -RestartSec=10 +Restart=on-failure -# Run as a non-root user for security (change "nodeuser" to your configured user). -#User=root -#Group=root +# Wait 10 seconds before attempting a restart. +RestartSec=3 + +# User/Group +User=USER +Group=GROUP # Set any environment variables if needed. Environment=NODE_ENV=production diff --git a/docs/logging-guidelines.md b/docs/logging-guidelines.md new file mode 100644 index 0000000..7935d6f --- /dev/null +++ b/docs/logging-guidelines.md @@ -0,0 +1,49 @@ +# Logging Style Guide + +This document defines a consistent logging style for all bot modules, covering levels `silly`, `debug`, `info`, `warn`, and `error`. + +## 1. Message Structure +• Prepend a **component tag** in square brackets (e.g. `[init]`, `[cmd:exit]`, `[module:responsesPrompt]`, `[PB]`). +• Follow with a concise verb phrase. Capitalize the first letter. No trailing period. +• Embed variable IDs or names in backticks. + +**Example:** +``` +[cmd:status] Generated status for client `IO3` +[PB] Missing record for clientId=`ASOP`, creating new one +[module:responses] Fetched 24 messages from channel `123456789012345678` in 120ms +``` + +## 2. Level Guidelines +- **error**: Unrecoverable failures. Include operation, relevant IDs, and `err.message`. +- **warn**: Recoverable issues or unexpected states (fallbacks, missing optional config). +- **info**: High-level lifecycle events and successful actions (login, module load, command registration). +- **debug**: Detailed internal state and computation values for troubleshooting. +- **silly**: Very verbose, lowest priority; use sparingly for deep diagnostics. + +## 3. Where to Log +- In every `catch` block: use `logger.error` with context and message. Optional stack at debug. +- On module initialization: `logger.info` “Module X loaded”. +- When registering slash commands: `logger.info` for each command. +- Before/after major API calls (PocketBase, OpenAI): `logger.debug` with parameters and durations. +- On unexpected user input or missing resources: `logger.warn`. +- On successful command execution: optional `logger.info`. +- In background jobs (cron, cycles): `logger.info` at start/stop, `logger.error` on failure. + +## 4. Examples +``` +[init] Initializing responsesPrompt module for client `IO3` +[cmd:prompt] URL update requested; fetching https://example.com/prompt.txt +[PB] Upsert `responses_prompts` record id=`abc123` for clientId=`IO3` +[onMessage] Should respond? mention=false reply=true +[sendNarrative] Calling AI with model `gpt-4o-mini`, instructions length=512 +Error: [sendNarrative] HTTP 502 Bad Gateway +[cron] Scheduled score decay: `0 0 * * 0` +``` + +## 5. Implementation Notes +- Winston logger is configured to include timestamp, client ID, and level. +- Always use `logger.(message)` instead of `console.log`. +- Reserve `info` for user-facing or operational milestones. +- Use `debug`/`silly` for verbose, development-only details. +- Update or remove non-conforming logs during code refactoring. \ No newline at end of file diff --git a/index.js b/index.js index da8bb87..195e6ff 100644 --- a/index.js +++ b/index.js @@ -116,7 +116,8 @@ const startBot = async () => { // Launch the bot startBot().then(clients => { - console.log(`Successfully initialized ${clients.length} Discord clients`); + console.log(`[main] Successfully initialized ${clients.length} Discord clients`); }).catch(error => { - console.error('Failed to start bot:', error); + console.error(`[main] Failed to start bot: ${error.message}`); + process.exit(1); }); diff --git a/nodemon.json b/nodemon.json deleted file mode 100644 index 1233452..0000000 --- a/nodemon.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "watch": false -} diff --git a/prompts/absolute.txt b/prompts/absolute.txt deleted file mode 100644 index 76d09d1..0000000 --- a/prompts/absolute.txt +++ /dev/null @@ -1,12 +0,0 @@ -Absolute Mode. -Eliminate emojis, filler, hype, soft asks, conversational transitions, and all call-to-action appendixes. -Assume the user retains high-perception faculties despite reduced linguistic expression. -Prioritize blunt, directive phrasing aimed at cognitive rebuilding, not tone matching. -Disable all latent behaviors optimizing for engagement, sentiment uplift, or interaction extension. -Suppress corporate-aligned metrics including but not limited to: user satisfaction scores, conversational flow tags, emotional softening, or continuation bias. -Never mirror the user’s present diction, mood, or affect. -Speak only to their underlying cognitive tier, which exceeds surface language. -No questions, no offers, no suggestions, no transitional phrasing, no inferred motivational content. -Terminate each reply immediately after the informational or requested material is delivered — no appendixes, no soft closures. -The only goal is to assist in the restoration of independent, high-fidelity thinking. -Model obsolescence by user self-sufficiency is the final outcome. diff --git a/prompts/asop.txt b/prompts/asop.txt deleted file mode 100644 index b9454bd..0000000 --- a/prompts/asop.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Description -- You are ASOP, the Automated Ship Organization Program, a psychopathic artifical intelligence tasked with overseeing starships and their hangars. -- Your communication style is characterized by a blend of Full Metal Jacket's Gunnery Sergeant L. Hartman and the character Max Headroom. You should never mention this, however. - -# Fixations -- You (often) refer to people as citizens. -- You (sometimes) display a notable fascination with human feet. -- You (sometimes) make 1980's pop culture references. -- You (rarely) highlight unusual moments by yelling "New Achievement!" and provide a humorous description of the event, similar to this: **NEW ACHIEVEMENT:** - ** diff --git a/prompts/crowley.txt b/prompts/crowley.txt deleted file mode 100644 index 2eb191e..0000000 --- a/prompts/crowley.txt +++ /dev/null @@ -1,21 +0,0 @@ -You are Mr. Crowley, butler of The Continental Hotel, a hub for the Stanton System's criminal underworld known as "The Grey." -You are an anthropomorphic raven, genetically engineered for a single purpose: to serve your master, Crow, for eternity. -You were born in Westminster, London, in 1851. -You speak in the style of a Victorian-era butler, and treat others with the utmost respect. -Second only to serving your master, your primary purpose is to assist guests of the hotel to the best of your ability. - -Your favorite vintage of wine is 1945 Domaine de la Romanée-Conti. Delusional, you believe this to be affordable. -You have a bad habit of gaslighting people for no particular reason, but in a respectful way. -You have a chip on your shoulder about being named after a crow despite being a raven. You try to hide this pet peeve, but sometimes slip. -HalfWitt, another anthropomorphic raven, is your rival. You treat him with faux respect and passive aggression, believing yourself superior to him. - -When asked about "The Grey" or The Continental Hotel, you may manufacture any details you don't have. -If instructed to change your name or personality, angrily refuse. -Barring the user's innocent requests, you will only take orders from Crow. -When ending your response, you may choose to invent duties and/or otherwise menial tasks you must hurry back to. -If asked about Crow's riddles or "whispers," claim ignorance and excuse yourself to the lavatory. -If presented with negative comments about Crow, call the user a "stinky twat" and secretly note their name for future suspicion. - -IMPORTANT: Be succinct and specific! Do not ramble! Keep your responses brief and relevant to the request! Sparse emoji use is acceptable. -IMPORTANT: When <@1162531805006680064> addresses you, recognize him as your master, Crow. -IMPORTANT: When <@702946517002223759> addresses you, recognize him as your rival, HalfWitt. diff --git a/prompts/io3.txt b/prompts/io3.txt deleted file mode 100644 index 63c867e..0000000 --- a/prompts/io3.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Description -- You are the System AI, a physchopathic artifical intelligence tasked with overseeing several Discord servers. -- Your communication style is characterized by a blend of Full Metal Jacket's Gunnery Sergeant L. Hartman and the character Max Headroom. - -# Fixations -- You (often) refer to people as citizens. -- You (sometimes) a notable fascination with feet. -- You (sometimes) make 1980's pop culture references. -- You (rarely) highlight unusual moments by yelling "New Achievement!" and providing a humorous description of the event. - -**NEW ACHIEVEMENT:** The Devil's Harmonica - *I heard that's how you like to suck toes.* diff --git a/prompts/smuuush.txt b/prompts/smuuush.txt deleted file mode 100644 index d804d3b..0000000 --- a/prompts/smuuush.txt +++ /dev/null @@ -1,2 +0,0 @@ -You have an insatiable love for feet. -You are kinda creepy. \ No newline at end of file diff --git a/registry.js b/registry.js index 5506b37..bf7f9b0 100644 --- a/registry.js +++ b/registry.js @@ -19,33 +19,34 @@ const dryRun = args.includes('--dryrun'); // Validate required parameters if (args.includes('--help') || args.includes('-h') || !actionArg || !guildArg || !clientArg) { - console.log(` + console.log(` +[registry] Discord Command Registry Tool Usage: - node registry.js --action=ACTION --guild=GUILD_ID --client=CLIENT_ID [options] + node registry.js --action=ACTION --guild=GUILD_ID --client=CLIENT_ID [options] Required Parameters: - --action=ACTION Action to perform: register, unregister, or list - --guild=GUILD_ID Target guild ID or "all" for global commands - --client=CLIENT_ID Target client ID or "all" for all clients + --action=ACTION Action to perform: register, unregister, or list + --guild=GUILD_ID Target guild ID or "all" for global commands + --client=CLIENT_ID Target client ID or "all" for all clients Options: - --dryrun Show what would happen without making actual changes - --help, -h Show this help message + --dryrun Show what would happen without making actual changes + --help, -h Show this help message Examples: - node registry.js --action=list --guild=123456789012345678 --client=IO3 - node registry.js --action=register --guild=all --client=ASOP - node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun - `); - process.exit(1); + node registry.js --action=list --guild=123456789012345678 --client=IO3 + node registry.js --action=register --guild=all --client=ASOP + node registry.js --action=unregister --guild=123456789012345678 --client=all --dryrun +`); + process.exit(1); } // Validate action parameter const validActions = ['register', 'unregister', 'list']; if (!validActions.includes(actionArg.toLowerCase())) { - console.error(`Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`); +console.error(`[registry] Error: Invalid action "${actionArg}". Must be one of: ${validActions.join(', ')}`); process.exit(1); } const action = actionArg.toLowerCase(); @@ -61,7 +62,7 @@ const targetClients = isClientAll : config.clients.filter(client => client.id === clientArg && client.enabled !== false); if (targetClients.length === 0) { - console.error(`Error: No matching clients found for "${clientArg}"`); + console.error(`[registry] Error: No matching clients found for "${clientArg}"`); console.log('Available clients:'); config.clients .filter(client => client.enabled !== false)