From 45cd5dcab99e6b478691606520cf8104e809ce4f Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:03:51 -0500 Subject: [PATCH 01/20] feat: integrate mem0 for persistent user memory across conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/modules/memory.js — mem0 REST API client wrapper with addMemory, searchMemories, getMemories, deleteMemory, deleteAllMemories, buildMemoryContext, and extractAndStoreMemories - Add /memory command (view, forget, forget ) for user data control - Integrate memory into AI pipeline: pre-response context injection and post-response memory extraction (fire-and-forget) - Add mem0 health check on startup with graceful fallback (AI works without mem0) - Memory scoping: user_id=Discord ID, app_id=bills-bot - Config: memory.{enabled, maxContextMemories, autoExtract, extractModel} - 55 new tests (44 memory module + 11 command), all 668 tests passing - Update .env.example with MEM0_API_URL Closes #24 --- .env.example | 3 + config.json | 7 + src/commands/memory.js | 172 ++++++++++ src/index.js | 4 + src/modules/ai.js | 32 +- src/modules/events.js | 1 + src/modules/memory.js | 369 ++++++++++++++++++++++ tests/commands/memory.test.js | 324 +++++++++++++++++++ tests/index.test.js | 4 + tests/modules/ai.test.js | 112 +++++++ tests/modules/events.test.js | 3 +- tests/modules/memory.test.js | 570 ++++++++++++++++++++++++++++++++++ 12 files changed, 1598 insertions(+), 3 deletions(-) create mode 100644 src/commands/memory.js create mode 100644 src/modules/memory.js create mode 100644 tests/commands/memory.test.js create mode 100644 tests/modules/memory.test.js diff --git a/.env.example b/.env.example index adfb15ab..058f5377 100644 --- a/.env.example +++ b/.env.example @@ -30,5 +30,8 @@ DATABASE_URL=postgresql://user:password@host:5432/database # Optional: force SSL for DB connections if needed by your host # DATABASE_SSL=true +# mem0 API URL for user memory (optional — memory features disabled if unreachable) +MEM0_API_URL=http://localhost:8080 + # Logging level (optional: debug, info, warn, error) LOG_LEVEL=info diff --git a/config.json b/config.json index cc8b32d2..02c0ff45 100644 --- a/config.json +++ b/config.json @@ -63,6 +63,12 @@ } } }, + "memory": { + "enabled": true, + "maxContextMemories": 5, + "autoExtract": true, + "extractModel": null + }, "logging": { "level": "info", "fileOutput": true @@ -73,6 +79,7 @@ "usePermissions": true, "allowedCommands": { "ping": "everyone", + "memory": "everyone", "config": "admin", "warn": "admin", "kick": "admin", diff --git a/src/commands/memory.js b/src/commands/memory.js new file mode 100644 index 00000000..32a6b53c --- /dev/null +++ b/src/commands/memory.js @@ -0,0 +1,172 @@ +/** + * Memory Command + * Allows users to view and manage what the bot remembers about them. + * + * Subcommands: + * /memory view — Show all memories the bot has about you + * /memory forget — Clear all your memories + * /memory forget — Clear memories matching a topic + */ + +import { SlashCommandBuilder } from 'discord.js'; +import { info, warn } from '../logger.js'; +import { + deleteAllMemories, + deleteMemory, + getMemories, + isMemoryAvailable, + searchMemories, +} from '../modules/memory.js'; + +export const data = new SlashCommandBuilder() + .setName('memory') + .setDescription('Manage what the bot remembers about you') + .addSubcommand((sub) => + sub.setName('view').setDescription('View what the bot remembers about you'), + ) + .addSubcommand((sub) => + sub + .setName('forget') + .setDescription('Forget your memories (all or by topic)') + .addStringOption((opt) => + opt + .setName('topic') + .setDescription('Specific topic to forget (omit to forget everything)') + .setRequired(false), + ), + ); + +/** + * Execute the /memory command + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + const userId = interaction.user.id; + const username = interaction.user.username; + + if (!isMemoryAvailable()) { + await interaction.reply({ + content: + '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', + ephemeral: true, + }); + return; + } + + if (subcommand === 'view') { + await handleView(interaction, userId, username); + } else if (subcommand === 'forget') { + const topic = interaction.options.getString('topic'); + if (topic) { + await handleForgetTopic(interaction, userId, username, topic); + } else { + await handleForgetAll(interaction, userId, username); + } + } +} + +/** + * Handle /memory view — show all memories for the user + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} userId + * @param {string} username + */ +async function handleView(interaction, userId, username) { + await interaction.deferReply({ ephemeral: true }); + + const memories = await getMemories(userId); + + if (memories.length === 0) { + await interaction.editReply({ + content: + "🧠 I don't have any memories about you yet. Chat with me and I'll start remembering!", + }); + return; + } + + const memoryList = memories.map((m, i) => `${i + 1}. ${m.memory}`).join('\n'); + + // Truncate for Discord's 2000 char limit + const header = `🧠 **What I remember about ${username}:**\n\n`; + const maxContent = 2000 - header.length - 50; // padding + const truncated = + memoryList.length > maxContent + ? `${memoryList.substring(0, maxContent)}...\n\n*(...and more)*` + : memoryList; + + await interaction.editReply({ + content: `${header}${truncated}`, + }); + + info('Memory view command', { userId, username, count: memories.length }); +} + +/** + * Handle /memory forget (all) — delete all memories for the user + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} userId + * @param {string} username + */ +async function handleForgetAll(interaction, userId, username) { + await interaction.deferReply({ ephemeral: true }); + + const success = await deleteAllMemories(userId); + + if (success) { + await interaction.editReply({ + content: '🧹 Done! All your memories have been cleared. Fresh start!', + }); + info('All memories cleared', { userId, username }); + } else { + await interaction.editReply({ + content: '❌ Failed to clear memories. Please try again later.', + }); + warn('Failed to clear memories', { userId, username }); + } +} + +/** + * Handle /memory forget — delete memories matching a topic + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} userId + * @param {string} username + * @param {string} topic + */ +async function handleForgetTopic(interaction, userId, username, topic) { + await interaction.deferReply({ ephemeral: true }); + + // Search for memories matching the topic + const matches = await searchMemories(userId, topic, 10); + + if (matches.length === 0) { + await interaction.editReply({ + content: `🔍 No memories found matching "${topic}".`, + }); + return; + } + + // Get full memories with IDs so we can delete them + const allMemories = await getMemories(userId); + + // Find memories whose text matches the search results + let deletedCount = 0; + for (const match of matches) { + const found = allMemories.find((m) => m.memory === match.memory && m.id); + if (found) { + const deleted = await deleteMemory(found.id); + if (deleted) deletedCount++; + } + } + + if (deletedCount > 0) { + await interaction.editReply({ + content: `🧹 Forgot ${deletedCount} memor${deletedCount === 1 ? 'y' : 'ies'} related to "${topic}".`, + }); + info('Topic memories cleared', { userId, username, topic, count: deletedCount }); + } else { + await interaction.editReply({ + content: `❌ Found memories about "${topic}" but couldn't delete them. Please try again.`, + }); + } +} diff --git a/src/index.js b/src/index.js index db6be201..981220ce 100644 --- a/src/index.js +++ b/src/index.js @@ -28,6 +28,7 @@ import { } from './modules/ai.js'; import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; +import { checkMem0Health } from './modules/memory.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; @@ -288,6 +289,9 @@ async function startup() { // Start periodic conversation cleanup startConversationCleanup(); + // Check mem0 availability for user memory features + await checkMem0Health(); + // Register event handlers with live config reference registerEventHandlers(client, config, healthMonitor); diff --git a/src/modules/ai.js b/src/modules/ai.js index 6d28e226..1d7a285d 100644 --- a/src/modules/ai.js +++ b/src/modules/ai.js @@ -6,6 +6,7 @@ import { info, error as logError, warn as logWarn } from '../logger.js'; import { getConfig } from './config.js'; +import { buildMemoryContext, extractAndStoreMemories } from './memory.js'; // Conversation history per channel (in-memory cache) let conversationHistory = new Map(); @@ -368,12 +369,18 @@ async function runCleanup() { } /** - * Generate AI response using OpenClaw's chat completions endpoint + * Generate AI response using OpenClaw's chat completions endpoint. + * + * Memory integration: + * - Pre-response: searches mem0 for relevant user memories and appends them to the system prompt. + * - Post-response: fires off memory extraction (non-blocking) so new facts get persisted. + * * @param {string} channelId - Channel ID * @param {string} userMessage - User's message * @param {string} username - Username * @param {Object} config - Bot configuration * @param {Object} healthMonitor - Health monitor instance (optional) + * @param {string} [userId] - Discord user ID for memory scoping * @returns {Promise} AI response */ export async function generateResponse( @@ -382,16 +389,30 @@ export async function generateResponse( username, config, healthMonitor = null, + userId = null, ) { const history = await getHistoryAsync(channelId); - const systemPrompt = + let systemPrompt = config.ai?.systemPrompt || `You are Volvox Bot, a helpful and friendly Discord bot for the Volvox developer community. You're witty, knowledgeable about programming and tech, and always eager to help. Keep responses concise and Discord-friendly (under 2000 chars). You can use Discord markdown formatting.`; + // Pre-response: inject user memory context into system prompt + if (userId) { + try { + const memoryContext = await buildMemoryContext(userId, username, userMessage); + if (memoryContext) { + systemPrompt += memoryContext; + } + } catch (err) { + // Memory lookup failed — continue without it + logWarn('Memory context lookup failed', { userId, error: err.message }); + } + } + // Build messages array for OpenAI-compatible API const messages = [ { role: 'system', content: systemPrompt }, @@ -439,6 +460,13 @@ You can use Discord markdown formatting.`; addToHistory(channelId, 'user', `${username}: ${userMessage}`, username); addToHistory(channelId, 'assistant', reply); + // Post-response: extract and store memorable facts (fire-and-forget) + if (userId) { + extractAndStoreMemories(userId, username, userMessage, reply).catch((err) => { + logWarn('Memory extraction failed', { userId, error: err.message }); + }); + } + return reply; } catch (err) { logError('OpenClaw API error', { error: err.message }); diff --git a/src/modules/events.js b/src/modules/events.js index 88fbb157..ca53ff3d 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -129,6 +129,7 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { message.author.username, config, healthMonitor, + message.author.id, ); // Split long responses diff --git a/src/modules/memory.js b/src/modules/memory.js new file mode 100644 index 00000000..bca1c205 --- /dev/null +++ b/src/modules/memory.js @@ -0,0 +1,369 @@ +/** + * Memory Module + * Integrates mem0 for persistent user memory across conversations. + * + * Uses the mem0 REST API to store/search/retrieve user facts. + * All operations are scoped per-user (Discord ID) and namespaced + * with app_id="bills-bot" to isolate from other consumers. + * + * Graceful fallback: if mem0 is unavailable, all operations return + * safe defaults (empty arrays / false) so the AI pipeline continues. + */ + +import { debug, info, warn as logWarn } from '../logger.js'; +import { getConfig } from './config.js'; + +/** Default mem0 API base URL */ +const DEFAULT_MEM0_URL = 'http://localhost:8080'; + +/** App namespace — isolates memories from other mem0 consumers */ +const APP_ID = 'bills-bot'; + +/** Default maximum memories to inject into context */ +const DEFAULT_MAX_CONTEXT_MEMORIES = 5; + +/** HTTP request timeout in ms */ +const REQUEST_TIMEOUT_MS = 5000; + +/** Tracks whether mem0 is reachable (set by health check, cleared on errors) */ +let mem0Available = false; + +/** + * Get the mem0 base URL from environment + * @returns {string} Base URL (no trailing slash) + */ +export function getMem0Url() { + const url = process.env.MEM0_API_URL || DEFAULT_MEM0_URL; + return url.replace(/\/+$/, ''); +} + +/** + * Get memory config from bot config + * @returns {Object} Memory configuration with defaults applied + */ +export function getMemoryConfig() { + try { + const config = getConfig(); + return { + enabled: config?.memory?.enabled ?? true, + maxContextMemories: config?.memory?.maxContextMemories ?? DEFAULT_MAX_CONTEXT_MEMORIES, + autoExtract: config?.memory?.autoExtract ?? true, + extractModel: config?.memory?.extractModel ?? null, + }; + } catch { + return { + enabled: true, + maxContextMemories: DEFAULT_MAX_CONTEXT_MEMORIES, + autoExtract: true, + extractModel: null, + }; + } +} + +/** + * Check if memory feature is enabled and mem0 is available + * @returns {boolean} + */ +export function isMemoryAvailable() { + const memConfig = getMemoryConfig(); + return memConfig.enabled && mem0Available; +} + +/** + * Set the mem0 availability flag (for testing / health checks) + * @param {boolean} available + */ +export function _setMem0Available(available) { + mem0Available = available; +} + +/** + * Internal fetch wrapper with timeout and error handling. + * Returns null on failure instead of throwing. + * @param {string} path - API path (e.g. "/v1/memories/") + * @param {Object} options - Fetch options + * @returns {Promise} Parsed JSON response or null + */ +async function mem0Fetch(path, options = {}) { + const baseUrl = getMem0Url(); + const url = `${baseUrl}${path}`; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + clearTimeout(timeout); + + if (!response.ok) { + logWarn('mem0 API error', { + path, + status: response.status, + statusText: response.statusText, + }); + return null; + } + + const data = await response.json(); + return data; + } catch (err) { + if (err.name === 'AbortError') { + logWarn('mem0 request timed out', { path }); + } else { + debug('mem0 request failed', { path, error: err.message }); + } + // Mark as unavailable on network errors so subsequent calls skip faster + mem0Available = false; + return null; + } +} + +/** + * Run a health check against the mem0 API on startup. + * Sets the availability flag accordingly. + * @returns {Promise} true if mem0 is reachable + */ +export async function checkMem0Health() { + const memConfig = getMemoryConfig(); + if (!memConfig.enabled) { + info('Memory module disabled via config'); + mem0Available = false; + return false; + } + + const baseUrl = getMem0Url(); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const response = await fetch(`${baseUrl}/v1/memories/`, { + method: 'GET', + signal: controller.signal, + headers: { 'Content-Type': 'application/json' }, + }); + + clearTimeout(timeout); + + if (response.ok || response.status === 404) { + // 404 is acceptable — some mem0 deployments return 404 on GET /v1/memories/ with no params + mem0Available = true; + info('mem0 health check passed', { url: baseUrl }); + return true; + } + + logWarn('mem0 health check failed', { status: response.status }); + mem0Available = false; + return false; + } catch (err) { + logWarn('mem0 unreachable — memory features disabled', { + url: baseUrl, + error: err.message, + }); + mem0Available = false; + return false; + } +} + +/** + * Add a memory for a user. + * @param {string} userId - Discord user ID + * @param {string} text - The memory text to store + * @param {Object} [metadata] - Optional metadata + * @returns {Promise} true if stored successfully + */ +export async function addMemory(userId, text, metadata = {}) { + if (!isMemoryAvailable()) return false; + + const body = { + messages: [{ role: 'user', content: text }], + user_id: userId, + app_id: APP_ID, + metadata, + }; + + const result = await mem0Fetch('/v1/memories/', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (result) { + debug('Memory added', { userId, textPreview: text.substring(0, 100) }); + return true; + } + + return false; +} + +/** + * Search memories relevant to a query for a given user. + * @param {string} userId - Discord user ID + * @param {string} query - Search query + * @param {number} [limit] - Max results (defaults to config maxContextMemories) + * @returns {Promise>} Matching memories + */ +export async function searchMemories(userId, query, limit) { + if (!isMemoryAvailable()) return []; + + const memConfig = getMemoryConfig(); + const maxResults = limit ?? memConfig.maxContextMemories; + + const body = { + query, + user_id: userId, + app_id: APP_ID, + limit: maxResults, + }; + + const result = await mem0Fetch('/v1/memories/search/', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!result) return []; + + // mem0 returns { results: [...] } or an array directly depending on version + const memories = Array.isArray(result) ? result : result.results || []; + + return memories.map((m) => ({ + memory: m.memory || m.text || m.content || '', + score: m.score ?? null, + })); +} + +/** + * Get all memories for a user. + * @param {string} userId - Discord user ID + * @returns {Promise>} All user memories + */ +export async function getMemories(userId) { + if (!isMemoryAvailable()) return []; + + const result = await mem0Fetch( + `/v1/memories/?user_id=${encodeURIComponent(userId)}&app_id=${encodeURIComponent(APP_ID)}`, + { + method: 'GET', + }, + ); + + if (!result) return []; + + // mem0 returns { results: [...] } or array + const memories = Array.isArray(result) ? result : result.results || []; + + return memories.map((m) => ({ + id: m.id || '', + memory: m.memory || m.text || m.content || '', + })); +} + +/** + * Delete all memories for a user. + * @param {string} userId - Discord user ID + * @returns {Promise} true if deleted successfully + */ +export async function deleteAllMemories(userId) { + if (!isMemoryAvailable()) return false; + + const result = await mem0Fetch( + `/v1/memories/?user_id=${encodeURIComponent(userId)}&app_id=${encodeURIComponent(APP_ID)}`, + { + method: 'DELETE', + }, + ); + + if (result !== null) { + info('All memories deleted for user', { userId }); + return true; + } + + return false; +} + +/** + * Delete a specific memory by ID. + * @param {string} memoryId - Memory ID to delete + * @returns {Promise} true if deleted successfully + */ +export async function deleteMemory(memoryId) { + if (!isMemoryAvailable()) return false; + + const result = await mem0Fetch(`/v1/memories/${encodeURIComponent(memoryId)}/`, { + method: 'DELETE', + }); + + if (result !== null) { + debug('Memory deleted', { memoryId }); + return true; + } + + return false; +} + +/** + * Build a context string from user memories to inject into the system prompt. + * @param {string} userId - Discord user ID + * @param {string} username - Display name + * @param {string} query - The user's current message (for relevance search) + * @returns {Promise} Context string or empty string + */ +export async function buildMemoryContext(userId, username, query) { + if (!isMemoryAvailable()) return ''; + + const memories = await searchMemories(userId, query); + + if (memories.length === 0) return ''; + + const memoryLines = memories.map((m) => `- ${m.memory}`).join('\n'); + + return `\n\nWhat you know about ${username}:\n${memoryLines}`; +} + +/** + * Analyze a conversation exchange and extract memorable facts to store. + * Uses the AI to identify new personal info worth remembering. + * @param {string} userId - Discord user ID + * @param {string} username - Display name + * @param {string} userMessage - What the user said + * @param {string} assistantReply - What the bot replied + * @returns {Promise} true if any memories were stored + */ +export async function extractAndStoreMemories(userId, username, userMessage, assistantReply) { + if (!isMemoryAvailable()) return false; + + const memConfig = getMemoryConfig(); + if (!memConfig.autoExtract) return false; + + const body = { + messages: [ + { role: 'user', content: `${username}: ${userMessage}` }, + { role: 'assistant', content: assistantReply }, + ], + user_id: userId, + app_id: APP_ID, + }; + + const result = await mem0Fetch('/v1/memories/', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (result) { + debug('Memory extraction completed', { + userId, + username, + messagePreview: userMessage.substring(0, 80), + }); + return true; + } + + return false; +} diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js new file mode 100644 index 00000000..18a0a8dc --- /dev/null +++ b/tests/commands/memory.test.js @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock discord.js +vi.mock('discord.js', () => { + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + this._subcommands = []; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommand(fn) { + const sub = new MockSubcommandBuilder(); + fn(sub); + this._subcommands.push(sub); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + class MockSubcommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + this._options = []; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addStringOption(fn) { + const opt = new MockStringOption(); + fn(opt); + this._options.push(opt); + return this; + } + } + + class MockStringOption { + constructor() { + this.name = ''; + this.description = ''; + this.required = false; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + setRequired(req) { + this.required = req; + return this; + } + } + + return { SlashCommandBuilder: MockSlashCommandBuilder }; +}); + +// Mock memory module +vi.mock('../../src/modules/memory.js', () => ({ + isMemoryAvailable: vi.fn(() => true), + getMemories: vi.fn(() => Promise.resolve([])), + deleteAllMemories: vi.fn(() => Promise.resolve(true)), + searchMemories: vi.fn(() => Promise.resolve([])), + deleteMemory: vi.fn(() => Promise.resolve(true)), +})); + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +import { data, execute } from '../../src/commands/memory.js'; +import { + deleteAllMemories, + deleteMemory, + getMemories, + isMemoryAvailable, + searchMemories, +} from '../../src/modules/memory.js'; + +/** + * Create a mock interaction for memory command tests. + * @param {Object} options - Override options + * @returns {Object} Mock interaction + */ +function createMockInteraction({ + subcommand = 'view', + topic = null, + userId = '123456', + username = 'testuser', +} = {}) { + return { + options: { + getSubcommand: () => subcommand, + getString: (name) => (name === 'topic' ? topic : null), + }, + user: { id: userId, username }, + reply: vi.fn(), + deferReply: vi.fn(), + editReply: vi.fn(), + }; +} + +describe('memory command', () => { + beforeEach(() => { + vi.clearAllMocks(); + isMemoryAvailable.mockReturnValue(true); + getMemories.mockResolvedValue([]); + deleteAllMemories.mockResolvedValue(true); + searchMemories.mockResolvedValue([]); + deleteMemory.mockResolvedValue(true); + }); + + describe('data export', () => { + it('should export command data with name "memory"', () => { + expect(data.name).toBe('memory'); + expect(data.description).toBeTruthy(); + }); + }); + + describe('unavailable state', () => { + it('should reply with unavailable message when memory is not available', async () => { + isMemoryAvailable.mockReturnValue(false); + const interaction = createMockInteraction(); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('unavailable'), + ephemeral: true, + }), + ); + }); + }); + + describe('/memory view', () => { + it('should show empty message when no memories exist', async () => { + getMemories.mockResolvedValue([]); + const interaction = createMockInteraction({ subcommand: 'view' }); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("don't have any memories"), + }), + ); + }); + + it('should display formatted memories', async () => { + getMemories.mockResolvedValue([ + { id: 'mem-1', memory: 'Loves Rust' }, + { id: 'mem-2', memory: 'Works at Google' }, + ]); + const interaction = createMockInteraction({ subcommand: 'view' }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Loves Rust'), + }), + ); + expect(interaction.editReply.mock.calls[0][0].content).toContain('Works at Google'); + expect(interaction.editReply.mock.calls[0][0].content).toContain( + 'What I remember about testuser', + ); + }); + + it('should truncate long memory lists', async () => { + // Create many long memories to exceed 2000 chars + const memories = Array.from({ length: 50 }, (_, i) => ({ + id: `mem-${i}`, + memory: `This is a long memory entry number ${i} with lots of detail about the user's preferences and interests that takes up space`, + })); + getMemories.mockResolvedValue(memories); + const interaction = createMockInteraction({ subcommand: 'view' }); + + await execute(interaction); + + const content = interaction.editReply.mock.calls[0][0].content; + expect(content.length).toBeLessThanOrEqual(2000); + expect(content).toContain('...and more'); + }); + }); + + describe('/memory forget (all)', () => { + it('should delete all memories and confirm', async () => { + deleteAllMemories.mockResolvedValue(true); + const interaction = createMockInteraction({ subcommand: 'forget' }); + + await execute(interaction); + + expect(deleteAllMemories).toHaveBeenCalledWith('123456'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('cleared'), + }), + ); + }); + + it('should show error when deletion fails', async () => { + deleteAllMemories.mockResolvedValue(false); + const interaction = createMockInteraction({ subcommand: 'forget' }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed'), + }), + ); + }); + }); + + describe('/memory forget ', () => { + it('should search and delete matching memories', async () => { + searchMemories.mockResolvedValue([{ memory: 'User is learning Rust', score: 0.95 }]); + getMemories.mockResolvedValue([ + { id: 'mem-1', memory: 'User is learning Rust' }, + { id: 'mem-2', memory: 'User works at Google' }, + ]); + deleteMemory.mockResolvedValue(true); + + const interaction = createMockInteraction({ + subcommand: 'forget', + topic: 'Rust', + }); + + await execute(interaction); + + expect(searchMemories).toHaveBeenCalledWith('123456', 'Rust', 10); + expect(deleteMemory).toHaveBeenCalledWith('mem-1'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('1 memory'), + }), + ); + }); + + it('should handle no matching memories', async () => { + searchMemories.mockResolvedValue([]); + const interaction = createMockInteraction({ + subcommand: 'forget', + topic: 'nonexistent', + }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('No memories found'), + }), + ); + }); + + it('should handle deletion failure for matched memories', async () => { + searchMemories.mockResolvedValue([{ memory: 'Test memory', score: 0.9 }]); + getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Test memory' }]); + deleteMemory.mockResolvedValue(false); + + const interaction = createMockInteraction({ + subcommand: 'forget', + topic: 'test', + }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("couldn't delete"), + }), + ); + }); + + it('should report correct count for multiple deletions', async () => { + searchMemories.mockResolvedValue([ + { memory: 'Rust project A', score: 0.95 }, + { memory: 'Rust project B', score: 0.9 }, + ]); + getMemories.mockResolvedValue([ + { id: 'mem-1', memory: 'Rust project A' }, + { id: 'mem-2', memory: 'Rust project B' }, + ]); + deleteMemory.mockResolvedValue(true); + + const interaction = createMockInteraction({ + subcommand: 'forget', + topic: 'Rust', + }); + + await execute(interaction); + + expect(deleteMemory).toHaveBeenCalledTimes(2); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('2 memories'), + }), + ); + }); + }); +}); diff --git a/tests/index.test.js b/tests/index.test.js index eb753db9..6d958658 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -142,6 +142,10 @@ vi.mock('../src/modules/events.js', () => ({ registerEventHandlers: mocks.events.registerEventHandlers, })); +vi.mock('../src/modules/memory.js', () => ({ + checkMem0Health: vi.fn().mockResolvedValue(false), +})); + vi.mock('../src/modules/moderation.js', () => ({ startTempbanScheduler: mocks.moderation.startTempbanScheduler, stopTempbanScheduler: mocks.moderation.stopTempbanScheduler, diff --git a/tests/modules/ai.test.js b/tests/modules/ai.test.js index ca430eb2..a30ecbb3 100644 --- a/tests/modules/ai.test.js +++ b/tests/modules/ai.test.js @@ -10,6 +10,12 @@ vi.mock('../../src/modules/config.js', () => ({ })), })); +// Mock memory module +vi.mock('../../src/modules/memory.js', () => ({ + buildMemoryContext: vi.fn(() => Promise.resolve('')), + extractAndStoreMemories: vi.fn(() => Promise.resolve(false)), +})); + import { _setPoolGetter, addToHistory, @@ -23,6 +29,7 @@ import { stopConversationCleanup, } from '../../src/modules/ai.js'; import { getConfig } from '../../src/modules/config.js'; +import { buildMemoryContext, extractAndStoreMemories } from '../../src/modules/memory.js'; // Mock logger vi.mock('../../src/logger.js', () => ({ @@ -224,6 +231,111 @@ describe('ai module', () => { const fetchCall = globalThis.fetch.mock.calls[0]; expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); }); + + it('should inject memory context into system prompt when userId is provided', async () => { + buildMemoryContext.mockResolvedValue('\n\nWhat you know about testuser:\n- Loves Rust'); + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'I know you love Rust!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: { systemPrompt: 'You are a bot.' } }; + await generateResponse( + 'ch1', + 'What do you know about me?', + 'testuser', + config, + null, + 'user-123', + ); + + expect(buildMemoryContext).toHaveBeenCalledWith( + 'user-123', + 'testuser', + 'What do you know about me?', + ); + + // Verify the system prompt includes memory context + const fetchCall = globalThis.fetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.messages[0].content).toContain('What you know about testuser'); + expect(body.messages[0].content).toContain('Loves Rust'); + }); + + it('should not inject memory context when userId is null', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'OK' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: { systemPrompt: 'You are a bot.' } }; + await generateResponse('ch1', 'Hi', 'user', config, null, null); + + expect(buildMemoryContext).not.toHaveBeenCalled(); + }); + + it('should fire memory extraction after response when userId is provided', async () => { + extractAndStoreMemories.mockResolvedValue(true); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Nice!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + await generateResponse('ch1', "I'm learning Rust", 'testuser', config, null, 'user-123'); + + // extractAndStoreMemories is fire-and-forget, wait for it + await vi.waitFor(() => { + expect(extractAndStoreMemories).toHaveBeenCalledWith( + 'user-123', + 'testuser', + "I'm learning Rust", + 'Nice!', + ); + }); + }); + + it('should continue working when memory context lookup fails', async () => { + buildMemoryContext.mockRejectedValue(new Error('mem0 down')); + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Still working!' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: { systemPrompt: 'You are a bot.' } }; + const reply = await generateResponse('ch1', 'Hi', 'user', config, null, 'user-123'); + + expect(reply).toBe('Still working!'); + }); + + it('should not call memory extraction when userId is not provided', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'OK' } }], + }), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse); + + const config = { ai: {} }; + await generateResponse('ch1', 'Hi', 'user', config); + + expect(extractAndStoreMemories).not.toHaveBeenCalled(); + }); }); describe('cleanup scheduler', () => { diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index 3823c447..151e0355 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -398,7 +398,7 @@ describe('events module', () => { getOrCreateThread.mockResolvedValueOnce({ thread: mockThread, isNew: true }); const message = { - author: { bot: false, username: 'user' }, + author: { bot: false, id: 'author-123', username: 'user' }, guild: { id: 'g1' }, content: '<@bot-user-id> hello from channel', channel: { @@ -424,6 +424,7 @@ describe('events module', () => { 'user', config, null, + 'author-123', ); }); diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js new file mode 100644 index 00000000..f7a536e8 --- /dev/null +++ b/tests/modules/memory.test.js @@ -0,0 +1,570 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config module +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(() => ({ + memory: { + enabled: true, + maxContextMemories: 5, + autoExtract: true, + extractModel: null, + }, + })), +})); + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +import { getConfig } from '../../src/modules/config.js'; +import { + _setMem0Available, + addMemory, + buildMemoryContext, + checkMem0Health, + deleteAllMemories, + deleteMemory, + extractAndStoreMemories, + getMem0Url, + getMemories, + getMemoryConfig, + isMemoryAvailable, + searchMemories, +} from '../../src/modules/memory.js'; + +describe('memory module', () => { + /** @type {ReturnType} */ + let fetchSpy; + + beforeEach(() => { + _setMem0Available(false); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + vi.clearAllMocks(); + // Reset config mock to defaults + getConfig.mockReturnValue({ + memory: { + enabled: true, + maxContextMemories: 5, + autoExtract: true, + extractModel: null, + }, + }); + // Restore env + delete process.env.MEM0_API_URL; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + delete process.env.MEM0_API_URL; + }); + + describe('getMem0Url', () => { + it('should return default URL when env not set', () => { + expect(getMem0Url()).toBe('http://localhost:8080'); + }); + + it('should return env URL when set', () => { + process.env.MEM0_API_URL = 'https://mem0.example.com'; + expect(getMem0Url()).toBe('https://mem0.example.com'); + }); + + it('should strip trailing slashes', () => { + process.env.MEM0_API_URL = 'https://mem0.example.com///'; + expect(getMem0Url()).toBe('https://mem0.example.com'); + }); + }); + + describe('getMemoryConfig', () => { + it('should return config values from bot config', () => { + const config = getMemoryConfig(); + expect(config.enabled).toBe(true); + expect(config.maxContextMemories).toBe(5); + expect(config.autoExtract).toBe(true); + expect(config.extractModel).toBeNull(); + }); + + it('should return defaults when config is missing', () => { + getConfig.mockReturnValue({}); + const config = getMemoryConfig(); + expect(config.enabled).toBe(true); + expect(config.maxContextMemories).toBe(5); + expect(config.autoExtract).toBe(true); + }); + + it('should return defaults when getConfig throws', () => { + getConfig.mockImplementation(() => { + throw new Error('not loaded'); + }); + const config = getMemoryConfig(); + expect(config.enabled).toBe(true); + expect(config.maxContextMemories).toBe(5); + }); + + it('should respect custom config values', () => { + getConfig.mockReturnValue({ + memory: { + enabled: false, + maxContextMemories: 10, + autoExtract: false, + extractModel: 'custom-model', + }, + }); + const config = getMemoryConfig(); + expect(config.enabled).toBe(false); + expect(config.maxContextMemories).toBe(10); + expect(config.autoExtract).toBe(false); + expect(config.extractModel).toBe('custom-model'); + }); + }); + + describe('isMemoryAvailable', () => { + it('should return false when mem0 is not available', () => { + _setMem0Available(false); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should return true when enabled and available', () => { + _setMem0Available(true); + expect(isMemoryAvailable()).toBe(true); + }); + + it('should return false when disabled in config', () => { + _setMem0Available(true); + getConfig.mockReturnValue({ memory: { enabled: false } }); + expect(isMemoryAvailable()).toBe(false); + }); + }); + + describe('checkMem0Health', () => { + it('should mark as available when health check passes (200)', async () => { + fetchSpy.mockResolvedValue({ ok: true, status: 200 }); + + const result = await checkMem0Health(); + expect(result).toBe(true); + expect(isMemoryAvailable()).toBe(true); + }); + + it('should mark as available when health check returns 404', async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 404 }); + + const result = await checkMem0Health(); + expect(result).toBe(true); + expect(isMemoryAvailable()).toBe(true); + }); + + it('should mark as unavailable on network error', async () => { + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await checkMem0Health(); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should mark as unavailable on 500 error', async () => { + fetchSpy.mockResolvedValue({ ok: false, status: 500 }); + + const result = await checkMem0Health(); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should return false when memory disabled in config', async () => { + getConfig.mockReturnValue({ memory: { enabled: false } }); + + const result = await checkMem0Health(); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('addMemory', () => { + it('should return false when memory unavailable', async () => { + _setMem0Available(false); + const result = await addMemory('user123', 'I love Rust'); + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should POST to mem0 and return true on success', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 'mem-1' }), + }); + + const result = await addMemory('user123', 'I love Rust'); + expect(result).toBe(true); + + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toContain('/v1/memories/'); + expect(opts.method).toBe('POST'); + + const body = JSON.parse(opts.body); + expect(body.user_id).toBe('user123'); + expect(body.app_id).toBe('bills-bot'); + expect(body.messages[0].content).toBe('I love Rust'); + }); + + it('should return false on API error', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); + }); + + it('should return false on network error and mark unavailable', async () => { + _setMem0Available(true); + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should pass optional metadata', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: 'mem-1' }), + }); + + await addMemory('user123', 'test', { source: 'chat' }); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.metadata).toEqual({ source: 'chat' }); + }); + }); + + describe('searchMemories', () => { + it('should return empty array when unavailable', async () => { + _setMem0Available(false); + const result = await searchMemories('user123', 'Rust'); + expect(result).toEqual([]); + }); + + it('should search and return formatted memories', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { memory: 'User is learning Rust', score: 0.95 }, + { memory: 'User works at Google', score: 0.8 }, + ], + }), + }); + + const result = await searchMemories('user123', 'What language?'); + expect(result).toEqual([ + { memory: 'User is learning Rust', score: 0.95 }, + { memory: 'User works at Google', score: 0.8 }, + ]); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.user_id).toBe('user123'); + expect(body.app_id).toBe('bills-bot'); + expect(body.limit).toBe(5); + }); + + it('should handle array response format', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ memory: 'User loves TypeScript', score: 0.9 }]), + }); + + const result = await searchMemories('user123', 'languages'); + expect(result).toEqual([{ memory: 'User loves TypeScript', score: 0.9 }]); + }); + + it('should respect custom limit parameter', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }), + }); + + await searchMemories('user123', 'test', 3); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.limit).toBe(3); + }); + + it('should return empty array on API error', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Error', + }); + + const result = await searchMemories('user123', 'test'); + expect(result).toEqual([]); + }); + + it('should handle text/content field variants', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { text: 'via text field' }, + { content: 'via content field' }, + { memory: 'via memory field' }, + ], + }), + }); + + const result = await searchMemories('user123', 'test'); + expect(result[0].memory).toBe('via text field'); + expect(result[1].memory).toBe('via content field'); + expect(result[2].memory).toBe('via memory field'); + }); + }); + + describe('getMemories', () => { + it('should return empty array when unavailable', async () => { + _setMem0Available(false); + const result = await getMemories('user123'); + expect(result).toEqual([]); + }); + + it('should GET all memories for a user', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { id: 'mem-1', memory: 'Loves Rust' }, + { id: 'mem-2', memory: 'Works at Google' }, + ], + }), + }); + + const result = await getMemories('user123'); + expect(result).toEqual([ + { id: 'mem-1', memory: 'Loves Rust' }, + { id: 'mem-2', memory: 'Works at Google' }, + ]); + + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toContain('user_id=user123'); + expect(url).toContain('app_id=bills-bot'); + expect(opts.method).toBe('GET'); + }); + + it('should handle array response format', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([{ id: 'mem-1', memory: 'Test' }]), + }); + + const result = await getMemories('user123'); + expect(result).toEqual([{ id: 'mem-1', memory: 'Test' }]); + }); + + it('should return empty array on API error', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await getMemories('user123'); + expect(result).toEqual([]); + }); + }); + + describe('deleteAllMemories', () => { + it('should return false when unavailable', async () => { + _setMem0Available(false); + const result = await deleteAllMemories('user123'); + expect(result).toBe(false); + }); + + it('should DELETE all memories and return true', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ deleted: true }), + }); + + const result = await deleteAllMemories('user123'); + expect(result).toBe(true); + + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toContain('user_id=user123'); + expect(url).toContain('app_id=bills-bot'); + expect(opts.method).toBe('DELETE'); + }); + + it('should return false on API error', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Error', + }); + + const result = await deleteAllMemories('user123'); + expect(result).toBe(false); + }); + }); + + describe('deleteMemory', () => { + it('should return false when unavailable', async () => { + _setMem0Available(false); + const result = await deleteMemory('mem-1'); + expect(result).toBe(false); + }); + + it('should DELETE a specific memory by ID', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ deleted: true }), + }); + + const result = await deleteMemory('mem-42'); + expect(result).toBe(true); + + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toContain('/v1/memories/mem-42/'); + expect(opts.method).toBe('DELETE'); + }); + + it('should return false on API error', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await deleteMemory('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('buildMemoryContext', () => { + it('should return empty string when unavailable', async () => { + _setMem0Available(false); + const result = await buildMemoryContext('user123', 'testuser', 'hello'); + expect(result).toBe(''); + }); + + it('should return formatted context string with memories', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { memory: 'User is learning Rust', score: 0.95 }, + { memory: 'User works at Google', score: 0.8 }, + ], + }), + }); + + const result = await buildMemoryContext('user123', 'testuser', 'tell me about Rust'); + expect(result).toContain('What you know about testuser'); + expect(result).toContain('- User is learning Rust'); + expect(result).toContain('- User works at Google'); + }); + + it('should return empty string when no memories found', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }), + }); + + const result = await buildMemoryContext('user123', 'testuser', 'random query'); + expect(result).toBe(''); + }); + }); + + describe('extractAndStoreMemories', () => { + it('should return false when unavailable', async () => { + _setMem0Available(false); + const result = await extractAndStoreMemories('user123', 'testuser', 'hello', 'hi'); + expect(result).toBe(false); + }); + + it('should return false when autoExtract is disabled', async () => { + _setMem0Available(true); + getConfig.mockReturnValue({ + memory: { enabled: true, autoExtract: false }, + }); + + const result = await extractAndStoreMemories('user123', 'testuser', 'hello', 'hi'); + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should POST conversation to mem0 for extraction', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [{ id: 'mem-1' }] }), + }); + + const result = await extractAndStoreMemories( + 'user123', + 'testuser', + "I'm learning Rust", + 'Rust is awesome! What project are you working on?', + ); + expect(result).toBe(true); + + const body = JSON.parse(fetchSpy.mock.calls[0][1].body); + expect(body.user_id).toBe('user123'); + expect(body.app_id).toBe('bills-bot'); + expect(body.messages).toHaveLength(2); + expect(body.messages[0].role).toBe('user'); + expect(body.messages[0].content).toContain("I'm learning Rust"); + expect(body.messages[1].role).toBe('assistant'); + }); + + it('should return false on API failure', async () => { + _setMem0Available(true); + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Error', + }); + + const result = await extractAndStoreMemories('user123', 'testuser', 'hi', 'hello'); + expect(result).toBe(false); + }); + }); + + describe('timeout handling', () => { + it('should handle fetch abort on timeout', async () => { + _setMem0Available(true); + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + fetchSpy.mockRejectedValue(abortError); + + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); + // Should mark as unavailable + expect(isMemoryAvailable()).toBe(false); + }); + }); +}); From cf6d8d1832280039f7a0cca33a81e67531317421 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:18:47 -0500 Subject: [PATCH 02/20] feat: rewrite mem0 integration to use official SDK with graph memory - Replace raw HTTP fetch calls with mem0ai SDK (v2.2.2) - Switch from self-hosted mem0 REST API to hosted platform (api.mem0.ai) - Enable graph memory (enable_graph: true) on all add/search/getAll calls - Add graph relation context to buildMemoryContext() for richer AI prompts - Update searchMemories() to return { memories, relations } with graph data - Add formatRelations() helper for readable relation context - Replace MEM0_API_URL env var with MEM0_API_KEY for hosted platform auth - Update health check to verify API key config and SDK client initialization - Add _setClient() test helper for SDK mock injection - Rewrite all tests to mock SDK instead of global fetch - Update /memory forget command to destructure new searchMemories format Closes #24 --- .env.example | 5 +- package.json | 1 + src/commands/memory.js | 2 +- src/modules/memory.js | 325 ++++++++++----------- tests/commands/memory.test.js | 27 +- tests/modules/memory.test.js | 536 ++++++++++++++++++++-------------- 6 files changed, 506 insertions(+), 390 deletions(-) diff --git a/.env.example b/.env.example index 058f5377..87e9ad21 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,9 @@ DATABASE_URL=postgresql://user:password@host:5432/database # Optional: force SSL for DB connections if needed by your host # DATABASE_SSL=true -# mem0 API URL for user memory (optional — memory features disabled if unreachable) -MEM0_API_URL=http://localhost:8080 +# mem0 API key for user memory (optional — memory features disabled without key) +# Get your API key from https://app.mem0.ai — uses the hosted platform with graph memory +MEM0_API_KEY=your_mem0_api_key # Logging level (optional: debug, info, warn, error) LOG_LEVEL=info diff --git a/package.json b/package.json index 53a63827..83ca08d9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "discord.js": "^14.25.1", "dotenv": "^17.2.4", + "mem0ai": "^2.2.2", "pg": "^8.18.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0" diff --git a/src/commands/memory.js b/src/commands/memory.js index 32a6b53c..c86f929c 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -137,7 +137,7 @@ async function handleForgetTopic(interaction, userId, username, topic) { await interaction.deferReply({ ephemeral: true }); // Search for memories matching the topic - const matches = await searchMemories(userId, topic, 10); + const { memories: matches } = await searchMemories(userId, topic, 10); if (matches.length === 0) { await interaction.editReply({ diff --git a/src/modules/memory.js b/src/modules/memory.js index bca1c205..64e50010 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -2,7 +2,9 @@ * Memory Module * Integrates mem0 for persistent user memory across conversations. * - * Uses the mem0 REST API to store/search/retrieve user facts. + * Uses the official mem0ai SDK with the hosted platform (api.mem0.ai) + * and graph memory enabled for entity relationship tracking. + * * All operations are scoped per-user (Discord ID) and namespaced * with app_id="bills-bot" to isolate from other consumers. * @@ -10,31 +12,40 @@ * safe defaults (empty arrays / false) so the AI pipeline continues. */ +import MemoryClient from 'mem0ai'; import { debug, info, warn as logWarn } from '../logger.js'; import { getConfig } from './config.js'; -/** Default mem0 API base URL */ -const DEFAULT_MEM0_URL = 'http://localhost:8080'; - /** App namespace — isolates memories from other mem0 consumers */ const APP_ID = 'bills-bot'; /** Default maximum memories to inject into context */ const DEFAULT_MAX_CONTEXT_MEMORIES = 5; -/** HTTP request timeout in ms */ -const REQUEST_TIMEOUT_MS = 5000; - /** Tracks whether mem0 is reachable (set by health check, cleared on errors) */ let mem0Available = false; +/** Singleton MemoryClient instance */ +let client = null; + /** - * Get the mem0 base URL from environment - * @returns {string} Base URL (no trailing slash) + * Get or create the mem0 client instance. + * Returns null if the API key is not configured. + * @returns {MemoryClient|null} */ -export function getMem0Url() { - const url = process.env.MEM0_API_URL || DEFAULT_MEM0_URL; - return url.replace(/\/+$/, ''); +function getClient() { + if (client) return client; + + const apiKey = process.env.MEM0_API_KEY; + if (!apiKey) return null; + + try { + client = new MemoryClient({ apiKey }); + return client; + } catch (err) { + logWarn('Failed to create mem0 client', { error: err.message }); + return null; + } } /** @@ -78,58 +89,17 @@ export function _setMem0Available(available) { } /** - * Internal fetch wrapper with timeout and error handling. - * Returns null on failure instead of throwing. - * @param {string} path - API path (e.g. "/v1/memories/") - * @param {Object} options - Fetch options - * @returns {Promise} Parsed JSON response or null + * Set the mem0 client instance (for testing) + * @param {object|null} newClient */ -async function mem0Fetch(path, options = {}) { - const baseUrl = getMem0Url(); - const url = `${baseUrl}${path}`; - - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - - const response = await fetch(url, { - ...options, - signal: controller.signal, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - clearTimeout(timeout); - - if (!response.ok) { - logWarn('mem0 API error', { - path, - status: response.status, - statusText: response.statusText, - }); - return null; - } - - const data = await response.json(); - return data; - } catch (err) { - if (err.name === 'AbortError') { - logWarn('mem0 request timed out', { path }); - } else { - debug('mem0 request failed', { path, error: err.message }); - } - // Mark as unavailable on network errors so subsequent calls skip faster - mem0Available = false; - return null; - } +export function _setClient(newClient) { + client = newClient; } /** - * Run a health check against the mem0 API on startup. - * Sets the availability flag accordingly. - * @returns {Promise} true if mem0 is reachable + * Run a health check against the mem0 platform on startup. + * Verifies the API key is configured and the SDK client can be created. + * @returns {Promise} true if mem0 is ready */ export async function checkMem0Health() { const memConfig = getMemoryConfig(); @@ -139,35 +109,25 @@ export async function checkMem0Health() { return false; } - const baseUrl = getMem0Url(); + const apiKey = process.env.MEM0_API_KEY; + if (!apiKey) { + logWarn('MEM0_API_KEY not set — memory features disabled'); + mem0Available = false; + return false; + } try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - - const response = await fetch(`${baseUrl}/v1/memories/`, { - method: 'GET', - signal: controller.signal, - headers: { 'Content-Type': 'application/json' }, - }); - - clearTimeout(timeout); - - if (response.ok || response.status === 404) { - // 404 is acceptable — some mem0 deployments return 404 on GET /v1/memories/ with no params - mem0Available = true; - info('mem0 health check passed', { url: baseUrl }); - return true; + const c = getClient(); + if (!c) { + mem0Available = false; + return false; } - logWarn('mem0 health check failed', { status: response.status }); - mem0Available = false; - return false; + mem0Available = true; + info('mem0 health check passed (API key configured, SDK client initialized)'); + return true; } catch (err) { - logWarn('mem0 unreachable — memory features disabled', { - url: baseUrl, - error: err.message, - }); + logWarn('mem0 health check failed', { error: err.message }); mem0Available = false; return false; } @@ -175,6 +135,7 @@ export async function checkMem0Health() { /** * Add a memory for a user. + * Graph memory is enabled to automatically build entity relationships. * @param {string} userId - Discord user ID * @param {string} text - The memory text to store * @param {Object} [metadata] - Optional metadata @@ -183,60 +144,67 @@ export async function checkMem0Health() { export async function addMemory(userId, text, metadata = {}) { if (!isMemoryAvailable()) return false; - const body = { - messages: [{ role: 'user', content: text }], - user_id: userId, - app_id: APP_ID, - metadata, - }; - - const result = await mem0Fetch('/v1/memories/', { - method: 'POST', - body: JSON.stringify(body), - }); + try { + const c = getClient(); + if (!c) return false; + + const messages = [{ role: 'user', content: text }]; + await c.add(messages, { + user_id: userId, + app_id: APP_ID, + metadata, + enable_graph: true, + }); - if (result) { debug('Memory added', { userId, textPreview: text.substring(0, 100) }); return true; + } catch (err) { + logWarn('Failed to add memory', { userId, error: err.message }); + mem0Available = false; + return false; } - - return false; } /** * Search memories relevant to a query for a given user. + * Returns both regular memory results and graph relations. * @param {string} userId - Discord user ID * @param {string} query - Search query * @param {number} [limit] - Max results (defaults to config maxContextMemories) - * @returns {Promise>} Matching memories + * @returns {Promise<{memories: Array<{memory: string, score?: number}>, relations: Array}>} */ export async function searchMemories(userId, query, limit) { - if (!isMemoryAvailable()) return []; + if (!isMemoryAvailable()) return { memories: [], relations: [] }; const memConfig = getMemoryConfig(); const maxResults = limit ?? memConfig.maxContextMemories; - const body = { - query, - user_id: userId, - app_id: APP_ID, - limit: maxResults, - }; - - const result = await mem0Fetch('/v1/memories/search/', { - method: 'POST', - body: JSON.stringify(body), - }); + try { + const c = getClient(); + if (!c) return { memories: [], relations: [] }; + + const result = await c.search(query, { + user_id: userId, + app_id: APP_ID, + limit: maxResults, + enable_graph: true, + }); - if (!result) return []; + // SDK returns { results: [...], relations: [...] } with graph enabled + const rawMemories = Array.isArray(result) ? result : result?.results || []; + const relations = result?.relations || []; - // mem0 returns { results: [...] } or an array directly depending on version - const memories = Array.isArray(result) ? result : result.results || []; + const memories = rawMemories.map((m) => ({ + memory: m.memory || m.text || m.content || '', + score: m.score ?? null, + })); - return memories.map((m) => ({ - memory: m.memory || m.text || m.content || '', - score: m.score ?? null, - })); + return { memories, relations }; + } catch (err) { + logWarn('Failed to search memories', { userId, error: err.message }); + mem0Available = false; + return { memories: [], relations: [] }; + } } /** @@ -247,22 +215,27 @@ export async function searchMemories(userId, query, limit) { export async function getMemories(userId) { if (!isMemoryAvailable()) return []; - const result = await mem0Fetch( - `/v1/memories/?user_id=${encodeURIComponent(userId)}&app_id=${encodeURIComponent(APP_ID)}`, - { - method: 'GET', - }, - ); + try { + const c = getClient(); + if (!c) return []; - if (!result) return []; + const result = await c.getAll({ + user_id: userId, + app_id: APP_ID, + enable_graph: true, + }); - // mem0 returns { results: [...] } or array - const memories = Array.isArray(result) ? result : result.results || []; + const memories = Array.isArray(result) ? result : result?.results || []; - return memories.map((m) => ({ - id: m.id || '', - memory: m.memory || m.text || m.content || '', - })); + return memories.map((m) => ({ + id: m.id || '', + memory: m.memory || m.text || m.content || '', + })); + } catch (err) { + logWarn('Failed to get memories', { userId, error: err.message }); + mem0Available = false; + return []; + } } /** @@ -273,19 +246,17 @@ export async function getMemories(userId) { export async function deleteAllMemories(userId) { if (!isMemoryAvailable()) return false; - const result = await mem0Fetch( - `/v1/memories/?user_id=${encodeURIComponent(userId)}&app_id=${encodeURIComponent(APP_ID)}`, - { - method: 'DELETE', - }, - ); + try { + const c = getClient(); + if (!c) return false; - if (result !== null) { + await c.deleteAll({ user_id: userId, app_id: APP_ID }); info('All memories deleted for user', { userId }); return true; + } catch (err) { + logWarn('Failed to delete all memories', { userId, error: err.message }); + return false; } - - return false; } /** @@ -296,20 +267,35 @@ export async function deleteAllMemories(userId) { export async function deleteMemory(memoryId) { if (!isMemoryAvailable()) return false; - const result = await mem0Fetch(`/v1/memories/${encodeURIComponent(memoryId)}/`, { - method: 'DELETE', - }); + try { + const c = getClient(); + if (!c) return false; - if (result !== null) { + await c.delete(memoryId); debug('Memory deleted', { memoryId }); return true; + } catch (err) { + logWarn('Failed to delete memory', { memoryId, error: err.message }); + return false; } +} - return false; +/** + * Format graph relations into a readable context string. + * @param {Array<{source: string, source_type: string, relationship: string, target: string, target_type: string}>} relations + * @returns {string} Formatted relations string or empty string + */ +export function formatRelations(relations) { + if (!relations || relations.length === 0) return ''; + + const lines = relations.map((r) => `- ${r.source} → ${r.relationship} → ${r.target}`); + + return `\nRelationships:\n${lines.join('\n')}`; } /** * Build a context string from user memories to inject into the system prompt. + * Includes both regular memories and graph relations for richer context. * @param {string} userId - Discord user ID * @param {string} username - Display name * @param {string} query - The user's current message (for relevance search) @@ -318,18 +304,29 @@ export async function deleteMemory(memoryId) { export async function buildMemoryContext(userId, username, query) { if (!isMemoryAvailable()) return ''; - const memories = await searchMemories(userId, query); + const { memories, relations } = await searchMemories(userId, query); + + if (memories.length === 0 && (!relations || relations.length === 0)) return ''; + + let context = ''; - if (memories.length === 0) return ''; + if (memories.length > 0) { + const memoryLines = memories.map((m) => `- ${m.memory}`).join('\n'); + context += `\n\nWhat you know about ${username}:\n${memoryLines}`; + } - const memoryLines = memories.map((m) => `- ${m.memory}`).join('\n'); + const relationsContext = formatRelations(relations); + if (relationsContext) { + context += relationsContext; + } - return `\n\nWhat you know about ${username}:\n${memoryLines}`; + return context; } /** * Analyze a conversation exchange and extract memorable facts to store. - * Uses the AI to identify new personal info worth remembering. + * Uses mem0's AI to identify new personal info worth remembering. + * Graph memory is enabled to automatically build entity relationships. * @param {string} userId - Discord user ID * @param {string} username - Display name * @param {string} userMessage - What the user said @@ -342,28 +339,30 @@ export async function extractAndStoreMemories(userId, username, userMessage, ass const memConfig = getMemoryConfig(); if (!memConfig.autoExtract) return false; - const body = { - messages: [ + try { + const c = getClient(); + if (!c) return false; + + const messages = [ { role: 'user', content: `${username}: ${userMessage}` }, { role: 'assistant', content: assistantReply }, - ], - user_id: userId, - app_id: APP_ID, - }; + ]; - const result = await mem0Fetch('/v1/memories/', { - method: 'POST', - body: JSON.stringify(body), - }); + await c.add(messages, { + user_id: userId, + app_id: APP_ID, + enable_graph: true, + }); - if (result) { debug('Memory extraction completed', { userId, username, messagePreview: userMessage.substring(0, 80), }); return true; + } catch (err) { + logWarn('Memory extraction failed', { userId, error: err.message }); + mem0Available = false; + return false; } - - return false; } diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js index 18a0a8dc..d0c5ce2a 100644 --- a/tests/commands/memory.test.js +++ b/tests/commands/memory.test.js @@ -77,7 +77,7 @@ vi.mock('../../src/modules/memory.js', () => ({ isMemoryAvailable: vi.fn(() => true), getMemories: vi.fn(() => Promise.resolve([])), deleteAllMemories: vi.fn(() => Promise.resolve(true)), - searchMemories: vi.fn(() => Promise.resolve([])), + searchMemories: vi.fn(() => Promise.resolve({ memories: [], relations: [] })), deleteMemory: vi.fn(() => Promise.resolve(true)), })); @@ -127,7 +127,7 @@ describe('memory command', () => { isMemoryAvailable.mockReturnValue(true); getMemories.mockResolvedValue([]); deleteAllMemories.mockResolvedValue(true); - searchMemories.mockResolvedValue([]); + searchMemories.mockResolvedValue({ memories: [], relations: [] }); deleteMemory.mockResolvedValue(true); }); @@ -237,7 +237,10 @@ describe('memory command', () => { describe('/memory forget ', () => { it('should search and delete matching memories', async () => { - searchMemories.mockResolvedValue([{ memory: 'User is learning Rust', score: 0.95 }]); + searchMemories.mockResolvedValue({ + memories: [{ memory: 'User is learning Rust', score: 0.95 }], + relations: [], + }); getMemories.mockResolvedValue([ { id: 'mem-1', memory: 'User is learning Rust' }, { id: 'mem-2', memory: 'User works at Google' }, @@ -261,7 +264,7 @@ describe('memory command', () => { }); it('should handle no matching memories', async () => { - searchMemories.mockResolvedValue([]); + searchMemories.mockResolvedValue({ memories: [], relations: [] }); const interaction = createMockInteraction({ subcommand: 'forget', topic: 'nonexistent', @@ -277,7 +280,10 @@ describe('memory command', () => { }); it('should handle deletion failure for matched memories', async () => { - searchMemories.mockResolvedValue([{ memory: 'Test memory', score: 0.9 }]); + searchMemories.mockResolvedValue({ + memories: [{ memory: 'Test memory', score: 0.9 }], + relations: [], + }); getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Test memory' }]); deleteMemory.mockResolvedValue(false); @@ -296,10 +302,13 @@ describe('memory command', () => { }); it('should report correct count for multiple deletions', async () => { - searchMemories.mockResolvedValue([ - { memory: 'Rust project A', score: 0.95 }, - { memory: 'Rust project B', score: 0.9 }, - ]); + searchMemories.mockResolvedValue({ + memories: [ + { memory: 'Rust project A', score: 0.95 }, + { memory: 'Rust project B', score: 0.9 }, + ], + relations: [], + }); getMemories.mockResolvedValue([ { id: 'mem-1', memory: 'Rust project A' }, { id: 'mem-2', memory: 'Rust project B' }, diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index f7a536e8..777bb06d 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Mock mem0ai SDK +vi.mock('mem0ai', () => { + const MockMemoryClient = vi.fn(); + return { default: MockMemoryClient }; +}); + // Mock config module vi.mock('../../src/modules/config.js', () => ({ getConfig: vi.fn(() => ({ @@ -22,6 +28,7 @@ vi.mock('../../src/logger.js', () => ({ import { getConfig } from '../../src/modules/config.js'; import { + _setClient, _setMem0Available, addMemory, buildMemoryContext, @@ -29,20 +36,33 @@ import { deleteAllMemories, deleteMemory, extractAndStoreMemories, - getMem0Url, + formatRelations, getMemories, getMemoryConfig, isMemoryAvailable, searchMemories, } from '../../src/modules/memory.js'; -describe('memory module', () => { - /** @type {ReturnType} */ - let fetchSpy; +/** + * Create a mock mem0 client with all SDK methods stubbed. + * @param {Object} overrides - Method overrides + * @returns {Object} Mock client + */ +function createMockClient(overrides = {}) { + return { + add: vi.fn().mockResolvedValue({ results: [{ id: 'mem-1' }] }), + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), + getAll: vi.fn().mockResolvedValue({ results: [] }), + delete: vi.fn().mockResolvedValue({ message: 'Memory deleted' }), + deleteAll: vi.fn().mockResolvedValue({ message: 'Memories deleted' }), + ...overrides, + }; +} +describe('memory module', () => { beforeEach(() => { _setMem0Available(false); - fetchSpy = vi.spyOn(globalThis, 'fetch'); + _setClient(null); vi.clearAllMocks(); // Reset config mock to defaults getConfig.mockReturnValue({ @@ -53,29 +73,13 @@ describe('memory module', () => { extractModel: null, }, }); - // Restore env - delete process.env.MEM0_API_URL; + // Set up env for tests + delete process.env.MEM0_API_KEY; }); afterEach(() => { - fetchSpy.mockRestore(); - delete process.env.MEM0_API_URL; - }); - - describe('getMem0Url', () => { - it('should return default URL when env not set', () => { - expect(getMem0Url()).toBe('http://localhost:8080'); - }); - - it('should return env URL when set', () => { - process.env.MEM0_API_URL = 'https://mem0.example.com'; - expect(getMem0Url()).toBe('https://mem0.example.com'); - }); - - it('should strip trailing slashes', () => { - process.env.MEM0_API_URL = 'https://mem0.example.com///'; - expect(getMem0Url()).toBe('https://mem0.example.com'); - }); + _setClient(null); + delete process.env.MEM0_API_KEY; }); describe('getMemoryConfig', () => { @@ -140,45 +144,43 @@ describe('memory module', () => { }); describe('checkMem0Health', () => { - it('should mark as available when health check passes (200)', async () => { - fetchSpy.mockResolvedValue({ ok: true, status: 200 }); + it('should mark as available when API key is set and client can be created', async () => { + process.env.MEM0_API_KEY = 'test-api-key'; + const mockClient = createMockClient(); + _setClient(mockClient); const result = await checkMem0Health(); expect(result).toBe(true); expect(isMemoryAvailable()).toBe(true); }); - it('should mark as available when health check returns 404', async () => { - fetchSpy.mockResolvedValue({ ok: false, status: 404 }); - - const result = await checkMem0Health(); - expect(result).toBe(true); - expect(isMemoryAvailable()).toBe(true); - }); - - it('should mark as unavailable on network error', async () => { - fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); - + it('should mark as unavailable when API key is not set', async () => { const result = await checkMem0Health(); expect(result).toBe(false); expect(isMemoryAvailable()).toBe(false); }); - it('should mark as unavailable on 500 error', async () => { - fetchSpy.mockResolvedValue({ ok: false, status: 500 }); + it('should return false when memory disabled in config', async () => { + getConfig.mockReturnValue({ memory: { enabled: false } }); const result = await checkMem0Health(); expect(result).toBe(false); expect(isMemoryAvailable()).toBe(false); }); - it('should return false when memory disabled in config', async () => { - getConfig.mockReturnValue({ memory: { enabled: false } }); + it('should mark as unavailable when client creation fails', async () => { + process.env.MEM0_API_KEY = 'test-api-key'; + // Don't set a client — getClient will try to create one + // Since the MemoryClient constructor is mocked (noop), it won't throw, + // but we can test the error path by ensuring getClient returns null + _setClient(null); + // The mocked MemoryClient constructor returns undefined (vi.fn()), + // so getClient() will return the mocked instance. Let's test that path. const result = await checkMem0Health(); - expect(result).toBe(false); - expect(isMemoryAvailable()).toBe(false); - expect(fetchSpy).not.toHaveBeenCalled(); + // The mocked constructor returns an object (vi.fn() return value), + // so this should succeed + expect(result).toBe(true); }); }); @@ -187,44 +189,30 @@ describe('memory module', () => { _setMem0Available(false); const result = await addMemory('user123', 'I love Rust'); expect(result).toBe(false); - expect(fetchSpy).not.toHaveBeenCalled(); }); - it('should POST to mem0 and return true on success', async () => { + it('should call client.add with correct params and return true', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ id: 'mem-1' }), - }); + const mockClient = createMockClient(); + _setClient(mockClient); const result = await addMemory('user123', 'I love Rust'); expect(result).toBe(true); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toContain('/v1/memories/'); - expect(opts.method).toBe('POST'); - - const body = JSON.parse(opts.body); - expect(body.user_id).toBe('user123'); - expect(body.app_id).toBe('bills-bot'); - expect(body.messages[0].content).toBe('I love Rust'); - }); - - it('should return false on API error', async () => { - _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', + expect(mockClient.add).toHaveBeenCalledWith([{ role: 'user', content: 'I love Rust' }], { + user_id: 'user123', + app_id: 'bills-bot', + metadata: {}, + enable_graph: true, }); - - const result = await addMemory('user123', 'test'); - expect(result).toBe(false); }); - it('should return false on network error and mark unavailable', async () => { + it('should return false on SDK error and mark unavailable', async () => { _setMem0Available(true); - fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + const mockClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('API error')), + }); + _setClient(mockClient); const result = await addMemory('user123', 'test'); expect(result).toBe(false); @@ -233,104 +221,130 @@ describe('memory module', () => { it('should pass optional metadata', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ id: 'mem-1' }), - }); + const mockClient = createMockClient(); + _setClient(mockClient); await addMemory('user123', 'test', { source: 'chat' }); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.metadata).toEqual({ source: 'chat' }); + expect(mockClient.add).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ metadata: { source: 'chat' } }), + ); + }); + + it('should return false when client is null', async () => { + _setMem0Available(true); + _setClient(null); + // No API key set, so getClient returns null + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); }); }); describe('searchMemories', () => { - it('should return empty array when unavailable', async () => { + it('should return empty results when unavailable', async () => { _setMem0Available(false); const result = await searchMemories('user123', 'Rust'); - expect(result).toEqual([]); + expect(result).toEqual({ memories: [], relations: [] }); }); - it('should search and return formatted memories', async () => { + it('should search and return formatted memories with relations', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - results: [ - { memory: 'User is learning Rust', score: 0.95 }, - { memory: 'User works at Google', score: 0.8 }, - ], - }), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ + results: [ + { memory: 'User is learning Rust', score: 0.95 }, + { memory: 'User works at Google', score: 0.8 }, + ], + relations: [ + { + source: 'User', + source_type: 'person', + relationship: 'works at', + target: 'Google', + target_type: 'organization', + }, + ], + }), }); + _setClient(mockClient); const result = await searchMemories('user123', 'What language?'); - expect(result).toEqual([ + expect(result.memories).toEqual([ { memory: 'User is learning Rust', score: 0.95 }, { memory: 'User works at Google', score: 0.8 }, ]); - - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.user_id).toBe('user123'); - expect(body.app_id).toBe('bills-bot'); - expect(body.limit).toBe(5); + expect(result.relations).toHaveLength(1); + expect(result.relations[0].relationship).toBe('works at'); + + expect(mockClient.search).toHaveBeenCalledWith('What language?', { + user_id: 'user123', + app_id: 'bills-bot', + limit: 5, + enable_graph: true, + }); }); it('should handle array response format', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve([{ memory: 'User loves TypeScript', score: 0.9 }]), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue([{ memory: 'User loves TypeScript', score: 0.9 }]), }); + _setClient(mockClient); const result = await searchMemories('user123', 'languages'); - expect(result).toEqual([{ memory: 'User loves TypeScript', score: 0.9 }]); + expect(result.memories).toEqual([{ memory: 'User loves TypeScript', score: 0.9 }]); + expect(result.relations).toEqual([]); }); it('should respect custom limit parameter', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ results: [] }), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), }); + _setClient(mockClient); await searchMemories('user123', 'test', 3); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.limit).toBe(3); + expect(mockClient.search).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 3 })); }); - it('should return empty array on API error', async () => { + it('should return empty results on SDK error and mark unavailable', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Error', + const mockClient = createMockClient({ + search: vi.fn().mockRejectedValue(new Error('API error')), }); + _setClient(mockClient); const result = await searchMemories('user123', 'test'); - expect(result).toEqual([]); + expect(result).toEqual({ memories: [], relations: [] }); + expect(isMemoryAvailable()).toBe(false); }); it('should handle text/content field variants', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - results: [ - { text: 'via text field' }, - { content: 'via content field' }, - { memory: 'via memory field' }, - ], - }), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ + results: [ + { text: 'via text field' }, + { content: 'via content field' }, + { memory: 'via memory field' }, + ], + }), }); + _setClient(mockClient); + + const result = await searchMemories('user123', 'test'); + expect(result.memories[0].memory).toBe('via text field'); + expect(result.memories[1].memory).toBe('via content field'); + expect(result.memories[2].memory).toBe('via memory field'); + }); + it('should return empty results when client is null', async () => { + _setMem0Available(true); + _setClient(null); const result = await searchMemories('user123', 'test'); - expect(result[0].memory).toBe('via text field'); - expect(result[1].memory).toBe('via content field'); - expect(result[2].memory).toBe('via memory field'); + expect(result).toEqual({ memories: [], relations: [] }); }); }); @@ -341,18 +355,17 @@ describe('memory module', () => { expect(result).toEqual([]); }); - it('should GET all memories for a user', async () => { + it('should call client.getAll and return formatted memories', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - results: [ - { id: 'mem-1', memory: 'Loves Rust' }, - { id: 'mem-2', memory: 'Works at Google' }, - ], - }), + const mockClient = createMockClient({ + getAll: vi.fn().mockResolvedValue({ + results: [ + { id: 'mem-1', memory: 'Loves Rust' }, + { id: 'mem-2', memory: 'Works at Google' }, + ], + }), }); + _setClient(mockClient); const result = await getMemories('user123'); expect(result).toEqual([ @@ -360,33 +373,41 @@ describe('memory module', () => { { id: 'mem-2', memory: 'Works at Google' }, ]); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toContain('user_id=user123'); - expect(url).toContain('app_id=bills-bot'); - expect(opts.method).toBe('GET'); + expect(mockClient.getAll).toHaveBeenCalledWith({ + user_id: 'user123', + app_id: 'bills-bot', + enable_graph: true, + }); }); it('should handle array response format', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve([{ id: 'mem-1', memory: 'Test' }]), + const mockClient = createMockClient({ + getAll: vi.fn().mockResolvedValue([{ id: 'mem-1', memory: 'Test' }]), }); + _setClient(mockClient); const result = await getMemories('user123'); expect(result).toEqual([{ id: 'mem-1', memory: 'Test' }]); }); - it('should return empty array on API error', async () => { + it('should return empty array on SDK error and mark unavailable', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', + const mockClient = createMockClient({ + getAll: vi.fn().mockRejectedValue(new Error('API error')), }); + _setClient(mockClient); const result = await getMemories('user123'); expect(result).toEqual([]); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should return empty array when client is null', async () => { + _setMem0Available(true); + _setClient(null); + const result = await getMemories('user123'); + expect(result).toEqual([]); }); }); @@ -397,33 +418,37 @@ describe('memory module', () => { expect(result).toBe(false); }); - it('should DELETE all memories and return true', async () => { + it('should call client.deleteAll and return true', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ deleted: true }), - }); + const mockClient = createMockClient(); + _setClient(mockClient); const result = await deleteAllMemories('user123'); expect(result).toBe(true); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toContain('user_id=user123'); - expect(url).toContain('app_id=bills-bot'); - expect(opts.method).toBe('DELETE'); + expect(mockClient.deleteAll).toHaveBeenCalledWith({ + user_id: 'user123', + app_id: 'bills-bot', + }); }); - it('should return false on API error', async () => { + it('should return false on SDK error', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Error', + const mockClient = createMockClient({ + deleteAll: vi.fn().mockRejectedValue(new Error('API error')), }); + _setClient(mockClient); const result = await deleteAllMemories('user123'); expect(result).toBe(false); }); + + it('should return false when client is null', async () => { + _setMem0Available(true); + _setClient(null); + const result = await deleteAllMemories('user123'); + expect(result).toBe(false); + }); }); describe('deleteMemory', () => { @@ -433,32 +458,66 @@ describe('memory module', () => { expect(result).toBe(false); }); - it('should DELETE a specific memory by ID', async () => { + it('should call client.delete with the memory ID', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ deleted: true }), - }); + const mockClient = createMockClient(); + _setClient(mockClient); const result = await deleteMemory('mem-42'); expect(result).toBe(true); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toContain('/v1/memories/mem-42/'); - expect(opts.method).toBe('DELETE'); + expect(mockClient.delete).toHaveBeenCalledWith('mem-42'); }); - it('should return false on API error', async () => { + it('should return false on SDK error', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', + const mockClient = createMockClient({ + delete: vi.fn().mockRejectedValue(new Error('Not found')), }); + _setClient(mockClient); const result = await deleteMemory('nonexistent'); expect(result).toBe(false); }); + + it('should return false when client is null', async () => { + _setMem0Available(true); + _setClient(null); + const result = await deleteMemory('mem-1'); + expect(result).toBe(false); + }); + }); + + describe('formatRelations', () => { + it('should return empty string for null/undefined/empty relations', () => { + expect(formatRelations(null)).toBe(''); + expect(formatRelations(undefined)).toBe(''); + expect(formatRelations([])).toBe(''); + }); + + it('should format relations as readable lines', () => { + const relations = [ + { + source: 'Joseph', + source_type: 'person', + relationship: 'works as', + target: 'software engineer', + target_type: 'role', + }, + { + source: 'Joseph', + source_type: 'person', + relationship: 'lives in', + target: 'New York', + target_type: 'location', + }, + ]; + + const result = formatRelations(relations); + expect(result).toContain('Relationships:'); + expect(result).toContain('Joseph → works as → software engineer'); + expect(result).toContain('Joseph → lives in → New York'); + }); }); describe('buildMemoryContext', () => { @@ -468,35 +527,85 @@ describe('memory module', () => { expect(result).toBe(''); }); - it('should return formatted context string with memories', async () => { + it('should return formatted context string with memories and relations', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - results: [ - { memory: 'User is learning Rust', score: 0.95 }, - { memory: 'User works at Google', score: 0.8 }, - ], - }), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ + results: [ + { memory: 'User is learning Rust', score: 0.95 }, + { memory: 'User works at Google', score: 0.8 }, + ], + relations: [ + { + source: 'testuser', + source_type: 'person', + relationship: 'works at', + target: 'Google', + target_type: 'organization', + }, + ], + }), }); + _setClient(mockClient); const result = await buildMemoryContext('user123', 'testuser', 'tell me about Rust'); expect(result).toContain('What you know about testuser'); expect(result).toContain('- User is learning Rust'); expect(result).toContain('- User works at Google'); + expect(result).toContain('Relationships:'); + expect(result).toContain('testuser → works at → Google'); }); - it('should return empty string when no memories found', async () => { + it('should return empty string when no memories or relations found', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ results: [] }), + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), }); + _setClient(mockClient); const result = await buildMemoryContext('user123', 'testuser', 'random query'); expect(result).toBe(''); }); + + it('should return context with only relations when no memories found', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ + results: [], + relations: [ + { + source: 'testuser', + source_type: 'person', + relationship: 'likes', + target: 'programming', + target_type: 'interest', + }, + ], + }), + }); + _setClient(mockClient); + + const result = await buildMemoryContext('user123', 'testuser', 'hobbies'); + expect(result).toContain('Relationships:'); + expect(result).toContain('testuser → likes → programming'); + expect(result).not.toContain('What you know about'); + }); + + it('should return context with only memories when no relations found', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ + results: [{ memory: 'Likes cats', score: 0.9 }], + relations: [], + }), + }); + _setClient(mockClient); + + const result = await buildMemoryContext('user123', 'testuser', 'pets'); + expect(result).toContain('What you know about testuser'); + expect(result).toContain('- Likes cats'); + expect(result).not.toContain('Relationships:'); + }); }); describe('extractAndStoreMemories', () => { @@ -508,21 +617,21 @@ describe('memory module', () => { it('should return false when autoExtract is disabled', async () => { _setMem0Available(true); + const mockClient = createMockClient(); + _setClient(mockClient); getConfig.mockReturnValue({ memory: { enabled: true, autoExtract: false }, }); const result = await extractAndStoreMemories('user123', 'testuser', 'hello', 'hi'); expect(result).toBe(false); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockClient.add).not.toHaveBeenCalled(); }); - it('should POST conversation to mem0 for extraction', async () => { + it('should call client.add with conversation messages and graph enabled', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ results: [{ id: 'mem-1' }] }), - }); + const mockClient = createMockClient(); + _setClient(mockClient); const result = await extractAndStoreMemories( 'user123', @@ -532,39 +641,36 @@ describe('memory module', () => { ); expect(result).toBe(true); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.user_id).toBe('user123'); - expect(body.app_id).toBe('bills-bot'); - expect(body.messages).toHaveLength(2); - expect(body.messages[0].role).toBe('user'); - expect(body.messages[0].content).toContain("I'm learning Rust"); - expect(body.messages[1].role).toBe('assistant'); + expect(mockClient.add).toHaveBeenCalledWith( + [ + { role: 'user', content: "testuser: I'm learning Rust" }, + { role: 'assistant', content: 'Rust is awesome! What project are you working on?' }, + ], + { + user_id: 'user123', + app_id: 'bills-bot', + enable_graph: true, + }, + ); }); - it('should return false on API failure', async () => { + it('should return false on SDK failure and mark unavailable', async () => { _setMem0Available(true); - fetchSpy.mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Error', + const mockClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('API error')), }); + _setClient(mockClient); const result = await extractAndStoreMemories('user123', 'testuser', 'hi', 'hello'); expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); }); - }); - describe('timeout handling', () => { - it('should handle fetch abort on timeout', async () => { + it('should return false when client is null', async () => { _setMem0Available(true); - const abortError = new Error('The operation was aborted'); - abortError.name = 'AbortError'; - fetchSpy.mockRejectedValue(abortError); - - const result = await addMemory('user123', 'test'); + _setClient(null); + const result = await extractAndStoreMemories('user123', 'testuser', 'hi', 'hello'); expect(result).toBe(false); - // Should mark as unavailable - expect(isMemoryAvailable()).toBe(false); }); }); }); From cf102422ab03c7c525e040753d02d689cb1e2ac4 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:31:45 -0500 Subject: [PATCH 03/20] fix: use splitMessage utility, IDs from search, and parallel deletions in memory command - Use splitMessage() utility instead of manual substring truncation for Discord's 2000-char limit, properly handling multi-byte characters (#1) - Include memory IDs in searchMemories results and use them directly in handleForgetTopic instead of fragile text equality matching (#2) - Parallelize deleteMemory calls with Promise.allSettled instead of sequential for loop (#3) - Verify deferReply is called in all forget test variants (#7) --- pnpm-lock.yaml | 3212 +++++++++++++++++++++++++++++++-- src/commands/memory.js | 38 +- src/modules/memory.js | 1 + tests/commands/memory.test.js | 37 +- tests/modules/memory.test.js | 12 +- 5 files changed, 3154 insertions(+), 146 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bd24130..5cd80304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: dotenv: specifier: ^17.2.4 version: 17.2.4 + mem0ai: + specifier: ^2.2.2 + version: 2.2.2(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260214.0)(@google/genai@1.41.0)(@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.14.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.95.3)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.18.0)(redis@4.7.1)(sqlite3@5.1.7)(ws@8.19.0) pg: specifier: ^8.18.0 version: 8.18.0 @@ -39,6 +42,72 @@ importers: packages: + '@anthropic-ai/sdk@0.40.1': + resolution: {integrity: sha512-DJMWm8lTEM9Lk/MSFL+V+ugF7jKOn0M2Ujvb5fN8r2nY14aHbGPZ1k6sgjL+tpJ3VuOGJNG+4R83jEpOuYPv8w==} + + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-client@1.10.1': + resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} + engines: {node: '>=20.0.0'} + + '@azure/core-http-compat@2.3.2': + resolution: {integrity: sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.22.2': + resolution: {integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==} + engines: {node: '>=20.0.0'} + + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} + + '@azure/identity@4.13.0': + resolution: {integrity: sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} + + '@azure/msal-browser@4.28.2': + resolution: {integrity: sha512-6vYUMvs6kJxJgxaCmHn/F8VxjLHNh7i9wzfwPGf8kyBJ8Gg2yvBXx175Uev8LdrD1F5C4o7qHa2CC4IrhGE1XQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@15.14.2': + resolution: {integrity: sha512-n8RBJEUmd5QotoqbZfd+eGBkzuFI1KX6jw2b3WcpSyGjwmzoeI/Jb99opIBPHpb8y312NB+B6+FGi2ZVSR8yfA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@3.8.7': + resolution: {integrity: sha512-a+Xnrae+uwLnlw68bplS1X4kuJ9F/7K6afuMFyRkNIskhjgDezl5Fhrx+1pmAlDmC0VaaAxjRQMp1OmcqVwkIg==} + engines: {node: '>=16'} + + '@azure/search-documents@12.2.0': + resolution: {integrity: sha512-4+Qw+qaGqnkdUCq/vEFzk/bkROogTvdbPb1fmI8poxNfDDN1q2WHxBmhI7CYwesrBj1yXC4i5E0aISBxZqZi0g==} + engines: {node: '>=20.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -117,6 +186,12 @@ packages: cpu: [x64] os: [win32] + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@cloudflare/workers-types@4.20260214.0': + resolution: {integrity: sha512-qb8rgbAdJR4BAPXolXhFL/wuGtecHLh1veOyZ1mK6QqWuCdI3vK1biKC0i3lzmzdLR/DZvsN3mNtpUE8zpWGEg==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -308,6 +383,34 @@ packages: cpu: [x64] os: [win32] + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@google/genai@1.41.0': + resolution: {integrity: sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -318,6 +421,94 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@langchain/core@0.3.80': + resolution: {integrity: sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==} + engines: {node: '>=18'} + + '@mistralai/mistralai@1.14.0': + resolution: {integrity: sha512-6zaj2f2LCd37cRpBvCgctkDbXtYBlAC85p+u4uU/726zjtsI+sdVH34qRzkm9iE3tRb8BoaiI0/P7TD+uMvLLQ==} + + '@npmcli/fs@1.1.1': + resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + + '@npmcli/move-file@1.1.2': + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@qdrant/js-client-rest@1.13.0': + resolution: {integrity: sha512-bewMtnXlGvhhnfXsp0sLoLXOGvnrCM15z9lNlG0Snp021OedNAnRtKkerjk5vkOcbQWUmJHXYCuxDfcT93aSkA==} + engines: {node: '>=18.0.0', pnpm: '>=8'} + peerDependencies: + typescript: '>=4.7' + + '@qdrant/openapi-typescript-fetch@1.2.6': + resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==} + engines: {node: '>=18.0.0', pnpm: '>=8'} + + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -468,12 +659,46 @@ packages: resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@sevinf/maybe@0.5.0': + resolution: {integrity: sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/auth-js@2.95.3': + resolution: {integrity: sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.95.3': + resolution: {integrity: sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.95.3': + resolution: {integrity: sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.95.3': + resolution: {integrity: sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.95.3': + resolution: {integrity: sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.95.3': + resolution: {integrity: sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==} + engines: {node: '>=20.0.0'} + + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -483,15 +708,61 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@25.2.0': resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/pg@8.11.0': + resolution: {integrity: sha512-sDAlRiBNthGjNFfvt0k6mtotoVYVQ63pA8R4EMWka7crawSR60waVYR0HAgmPRs/e2YaeJTD/43OoZ3PFw80pw==} + + '@types/phoenix@1.6.7': + resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/sqlite3@3.1.11': + resolution: {integrity: sha512-KYF+QgxAnnAh7DWPdNDroxkDI3/MspH1NMx6m/N/6fT1G6+jvsw4/ZePt8R8cr7ta58aboeTfYFBDxTJ5yv15w==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typespec/ts-http-runtime@0.3.3': + resolution: {integrity: sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==} + engines: {node: '>=20.0.0'} + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -534,6 +805,57 @@ packages: resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -544,14 +866,109 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base-64@0.1.0: + resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + cacache@15.3.0: + resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} + engines: {node: '>= 10'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cloudflare@4.5.0: + resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} engines: {node: '>=12.20'} @@ -560,10 +977,89 @@ packages: resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} engines: {node: '>=18'} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + color@5.0.3: resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} engines: {node: '>=18'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + console-table-printer@2.15.0: + resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + digest-fetch@1.3.0: + resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} + discord-api-types@0.38.38: resolution: {integrity: sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==} @@ -575,24 +1071,95 @@ packages: resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -608,93 +1175,628 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-stream-rotator@0.6.1: resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} - kuler@2.0.0: - resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - logform@2.7.0: - resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} - engines: {node: '>= 12.0.0'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} - magic-bytes.js@1.13.0: - resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} + groq-sdk@0.3.0: + resolution: {integrity: sha512-Cdgjh4YoSBE2X4S9sxPGXaAy1dlN4bRtAaDZ3cnq+XsxhhN9WSBeHF64l7LWwuD5ntmw7YC5Vf4Ff1oHCg1LOg==} - moment@2.30.1: - resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + langsmith@0.3.87: + resolution: {integrity: sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-fetch-happen@9.1.0: + resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} + engines: {node: '>= 10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + + mem0ai@2.2.2: + resolution: {integrity: sha512-gMvz80j+/UmpVaH73e2ohn6eVIuxq/56GYcI0li7N6JlVC2yf36SBNaCjsOzkMKFRgv2iOgLMEQWlxq2KokB8Q==} + engines: {node: '>=18'} + peerDependencies: + '@anthropic-ai/sdk': ^0.40.1 + '@azure/identity': ^4.0.0 + '@azure/search-documents': ^12.0.0 + '@cloudflare/workers-types': ^4.20250504.0 + '@google/genai': ^1.2.0 + '@langchain/core': ^0.3.44 + '@mistralai/mistralai': ^1.5.2 + '@qdrant/js-client-rest': 1.13.0 + '@supabase/supabase-js': ^2.49.1 + '@types/jest': 29.5.14 + '@types/pg': 8.11.0 + '@types/sqlite3': 3.1.11 + cloudflare: ^4.2.0 + groq-sdk: 0.3.0 + neo4j-driver: ^5.28.1 + ollama: ^0.5.14 + pg: 8.11.3 + redis: ^4.6.13 + sqlite3: 5.1.7 + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@1.4.1: + resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} + engines: {node: '>=8'} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo4j-driver-bolt-connection@5.28.3: + resolution: {integrity: sha512-wqHBYcU0FVRDmdsoZ+Fk0S/InYmu9/4BT6fPYh45Jimg/J7vQBUcdkiHGU7nop7HRb1ZgJmL305mJb6g5Bv35Q==} + + neo4j-driver-core@5.28.3: + resolution: {integrity: sha512-Jk+hAmjFmO5YzVH/U7FyKXigot9zmIfLz6SZQy0xfr4zfTE/S8fOYFOGqKQTHBE86HHOWH2RbTslbxIb+XtU2g==} + + neo4j-driver@5.28.3: + resolution: {integrity: sha512-k7c0wEh3HoONv1v5AyLp9/BDAbYHJhz2TZvzWstSEU3g3suQcXmKEaYBfrK2UMzxcy3bCT0DrnfRbzsOW5G/Ag==} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp@8.4.1: + resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} + engines: {node: '>= 10.12.0'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ollama@0.5.18: + resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -708,6 +1810,10 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + pg-pool@3.11.0: resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} peerDependencies: @@ -720,6 +1826,10 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg-types@4.1.0: + resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} + engines: {node: '>=10'} + pg@8.18.0: resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} engines: {node: '>= 16.0.0'} @@ -735,6 +1845,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -747,27 +1861,111 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + postgres-bytea@1.0.1: resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} engines: {node: '>=0.10.0'} + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -775,14 +1973,60 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-wcswidth@1.1.2: + resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@6.2.1: + resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} + engines: {node: '>= 10'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -791,22 +2035,65 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sqlite3@5.1.7: + resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + + ssri@8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -825,6 +2112,13 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -835,6 +2129,17 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -842,9 +2147,27 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + unique-filename@1.1.1: + resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} + + unique-slug@2.0.2: + resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -919,42 +2242,222 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + winston-daily-rotate-file@5.0.0: resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} engines: {node: '>=8'} peerDependencies: winston: ^3 - winston-transport@4.9.0: - resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} - engines: {node: '>= 12.0.0'} + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@anthropic-ai/sdk@0.40.1(encoding@0.1.13)': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.22.2)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + + '@azure/core-paging@1.6.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.22.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.3.1': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 4.28.2 + '@azure/msal-node': 3.8.7 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.3.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.3 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color - winston@3.19.0: - resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} - engines: {node: '>= 12.0.0'} + '@azure/msal-browser@4.28.2': + dependencies: + '@azure/msal-common': 15.14.2 - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true + '@azure/msal-common@15.14.2': {} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} + '@azure/msal-node@3.8.7': + dependencies: + '@azure/msal-common': 15.14.2 + jsonwebtoken: 9.0.3 + uuid: 8.3.2 -snapshots: + '@azure/search-documents@12.2.0': + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-http-compat': 2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.22.2) + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 '@babel/helper-string-parser@7.27.1': {} @@ -1006,6 +2509,10 @@ snapshots: '@biomejs/cli-win32-x64@2.3.14': optional: true + '@cfworker/json-schema@4.1.1': {} + + '@cloudflare/workers-types@4.20260214.0': {} + '@colors/colors@1.6.0': {} '@dabh/diagnostics@2.0.8': @@ -1141,6 +2648,46 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@gar/promisify@1.1.3': + optional: true + + '@google/genai@1.41.0': + dependencies: + google-auth-library: 10.5.0 + p-retry: 7.1.1 + protobufjs: 7.5.4 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.2.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1150,6 +2697,108 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))': + dependencies: + '@cfworker/json-schema': 4.1.1 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.3.87(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 10.0.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + + '@mistralai/mistralai@1.14.0': + dependencies: + ws: 8.19.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@npmcli/fs@1.1.1': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.7.4 + optional: true + + '@npmcli/move-file@1.1.2': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@qdrant/js-client-rest@1.13.0(typescript@5.9.3)': + dependencies: + '@qdrant/openapi-typescript-fetch': 1.2.6 + '@sevinf/maybe': 0.5.0 + typescript: 5.9.3 + undici: 6.23.0 + + '@qdrant/openapi-typescript-fetch@1.2.6': {} + + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -1234,6 +2883,10 @@ snapshots: '@sapphire/snowflake@3.5.3': {} + '@sevinf/maybe@0.5.0': {} + + '@sinclair/typebox@0.27.10': {} + '@so-ric/colorspace@1.1.6': dependencies: color: 5.0.3 @@ -1241,6 +2894,47 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.95.3': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.95.3': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.95.3': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.95.3': + dependencies: + '@types/phoenix': 1.6.7 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.95.3': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.95.3': + dependencies: + '@supabase/auth-js': 2.95.3 + '@supabase/functions-js': 2.95.3 + '@supabase/postgrest-js': 2.95.3 + '@supabase/realtime-js': 2.95.3 + '@supabase/storage-js': 2.95.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tootallnate/once@1.1.2': + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1250,16 +2944,72 @@ snapshots: '@types/estree@1.0.8': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.2.0 + form-data: 4.0.5 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@25.2.0': dependencies: undici-types: 7.16.0 + '@types/pg@8.11.0': + dependencies: + '@types/node': 25.2.0 + pg-protocol: 1.11.0 + pg-types: 4.1.0 + + '@types/phoenix@1.6.7': {} + + '@types/retry@0.12.0': {} + + '@types/sqlite3@3.1.11': + dependencies: + '@types/node': 25.2.0 + + '@types/stack-utils@2.0.3': {} + '@types/triple-beam@1.3.5': {} + '@types/uuid@10.0.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.2.0 + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typespec/ts-http-runtime@0.3.3': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -1274,74 +3024,308 @@ snapshots: tinyrainbow: 3.0.3 vitest: 4.0.18(@types/node@25.2.0) - '@vitest/expect@4.0.18': + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + '@vladfrangu/async_event_emitter@2.4.7': {} + + abbrev@1.1.1: + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + optional: true + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + aproba@2.1.0: + optional: true + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.7.7: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + base-64@0.1.0: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + optional: true + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - chai: 6.2.2 - tinyrainbow: 3.0.3 + base64-js: 1.5.1 + ieee754: 1.2.1 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.0))': + buffer@6.0.3: dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@25.2.0) + base64-js: 1.5.1 + ieee754: 1.2.1 - '@vitest/pretty-format@4.0.18': + bundle-name@4.1.0: dependencies: - tinyrainbow: 3.0.3 + run-applescript: 7.1.0 - '@vitest/runner@4.0.18': + cacache@15.3.0: dependencies: - '@vitest/utils': 4.0.18 - pathe: 2.0.3 + '@npmcli/fs': 1.1.1 + '@npmcli/move-file': 1.1.2 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 7.2.3 + infer-owner: 1.0.4 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 8.0.1 + tar: 6.2.1 + unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird + optional: true - '@vitest/snapshot@4.0.18': + call-bind-apply-helpers@1.0.2: dependencies: - '@vitest/pretty-format': 4.0.18 - magic-string: 0.30.21 - pathe: 2.0.3 + es-errors: 1.3.0 + function-bind: 1.1.2 - '@vitest/spy@4.0.18': {} + camelcase@6.3.0: {} - '@vitest/utils@4.0.18': + chai@6.2.2: {} + + chalk@4.1.2: dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + ansi-styles: 4.3.0 + supports-color: 7.2.0 - '@vladfrangu/async_event_emitter@2.4.7': {} + charenc@0.0.2: {} - assertion-error@2.0.1: {} + chownr@1.1.4: {} - ast-v8-to-istanbul@0.3.11: + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + clean-stack@2.2.0: + optional: true + + cloudflare@4.5.0(encoding@0.1.13): dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding - async@3.2.6: {} + cluster-key-slot@1.1.2: {} - chai@6.2.2: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 color-convert@3.1.3: dependencies: color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} color-string@2.1.4: dependencies: color-name: 2.1.0 + color-support@1.1.3: + optional: true + color@5.0.3: dependencies: color-convert: 3.1.3 color-string: 2.1.4 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: + optional: true + + console-control-strings@1.1.0: + optional: true + + console-table-printer@2.15.0: + dependencies: + simple-wcswidth: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypt@0.0.2: {} + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + + delegates@1.0.0: + optional: true + + detect-libc@2.1.2: {} + + diff-sequences@29.6.3: {} + + digest-fetch@1.3.0: + dependencies: + base-64: 0.1.0 + md5: 2.3.0 + discord-api-types@0.38.38: {} discord.js@14.25.1: @@ -1365,10 +3349,56 @@ snapshots: dotenv@17.2.4: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + enabled@2.0.0: {} + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: + optional: true + + err-code@2.0.3: + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1398,12 +3428,32 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + expand-template@2.0.3: {} + expect-type@1.3.0: {} + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fdir@6.5.0(picomatch@4.0.3): @@ -1412,23 +3462,283 @@ snapshots: fecha@4.2.3: {} + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-stream-rotator@0.6.1: dependencies: moment: 2.30.1 + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + fn.name@1.1.0: {} + follow-redirects@1.15.11: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data-encoder@1.7.2: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fs-constants@1.0.0: {} + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: + optional: true + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generic-pool@3.9.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + github-from-package@0.0.0: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + groq-sdk@0.3.0(encoding@0.1.13): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + digest-fetch: 1.3.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + web-streams-polyfill: 3.3.3 + transitivePeerDependencies: + - encoding + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: + optional: true + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + html-escaper@2.0.2: {} + http-cache-semantics@4.2.0: + optional: true + + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iceberg-js@0.8.1: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ieee754@1.2.1: {} + + imurmurhash@0.1.4: + optional: true + + indent-string@4.0.0: + optional: true + + infer-owner@1.0.4: + optional: true + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + inherits@2.0.4: {} + ini@1.3.8: {} + + ip-address@10.1.0: + optional: true + + is-buffer@1.1.6: {} + + is-docker@3.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-lambda@1.0.1: + optional: true + + is-network-error@1.3.0: {} + + is-number@7.0.0: {} + is-stream@2.0.1: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -1437,14 +3747,116 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.2.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kuler@2.0.0: {} + + langsmith@0.3.87(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)): + dependencies: + '@types/uuid': 10.0.0 + chalk: 4.1.2 + console-table-printer: 2.15.0 + p-queue: 6.6.2 + semver: 7.7.4 + uuid: 10.0.0 + optionalDependencies: + openai: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) + + lodash.includes@4.3.0: {} - js-tokens@10.0.0: {} + lodash.isboolean@3.0.3: {} - kuler@2.0.0: {} + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} lodash.snakecase@4.1.1: {} @@ -1459,6 +3871,15 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + long@5.3.2: {} + + lru-cache@10.4.3: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + optional: true + magic-bytes.js@1.13.0: {} magic-string@0.30.21: @@ -1475,20 +3896,291 @@ snapshots: dependencies: semver: 7.7.4 + make-fetch-happen@9.1.0: + dependencies: + agentkeepalive: 4.6.0 + cacache: 15.3.0 + http-cache-semantics: 4.2.0 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 1.4.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 6.2.1 + ssri: 8.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + math-intrinsics@1.1.0: {} + + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + + mem0ai@2.2.2(@anthropic-ai/sdk@0.40.1(encoding@0.1.13))(@azure/identity@4.13.0)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260214.0)(@google/genai@1.41.0)(@langchain/core@0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)))(@mistralai/mistralai@1.14.0)(@qdrant/js-client-rest@1.13.0(typescript@5.9.3))(@supabase/supabase-js@2.95.3)(@types/jest@29.5.14)(@types/pg@8.11.0)(@types/sqlite3@3.1.11)(cloudflare@4.5.0(encoding@0.1.13))(encoding@0.1.13)(groq-sdk@0.3.0(encoding@0.1.13))(neo4j-driver@5.28.3)(ollama@0.5.18)(pg@8.18.0)(redis@4.7.1)(sqlite3@5.1.7)(ws@8.19.0): + dependencies: + '@anthropic-ai/sdk': 0.40.1(encoding@0.1.13) + '@azure/identity': 4.13.0 + '@azure/search-documents': 12.2.0 + '@cloudflare/workers-types': 4.20260214.0 + '@google/genai': 1.41.0 + '@langchain/core': 0.3.80(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76)) + '@mistralai/mistralai': 1.14.0 + '@qdrant/js-client-rest': 1.13.0(typescript@5.9.3) + '@supabase/supabase-js': 2.95.3 + '@types/jest': 29.5.14 + '@types/pg': 8.11.0 + '@types/sqlite3': 3.1.11 + axios: 1.7.7 + cloudflare: 4.5.0(encoding@0.1.13) + groq-sdk: 0.3.0(encoding@0.1.13) + neo4j-driver: 5.28.3 + ollama: 0.5.18 + openai: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) + pg: 8.18.0 + redis: 4.7.1 + sqlite3: 5.1.7 + uuid: 9.0.1 + zod: 3.25.76 + transitivePeerDependencies: + - debug + - encoding + - ws + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-response@3.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + optional: true + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-fetch@1.4.1: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + moment@2.30.1: {} ms@2.1.3: {} + mustache@4.2.0: {} + nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + + negotiator@0.6.4: + optional: true + + neo4j-driver-bolt-connection@5.28.3: + dependencies: + buffer: 6.0.3 + neo4j-driver-core: 5.28.3 + string_decoder: 1.3.0 + + neo4j-driver-core@5.28.3: {} + + neo4j-driver@5.28.3: + dependencies: + neo4j-driver-bolt-connection: 5.28.3 + neo4j-driver-core: 5.28.3 + rxjs: 7.8.2 + + node-abi@3.87.0: + dependencies: + semver: 7.7.4 + + node-addon-api@7.1.1: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp@8.4.1: + dependencies: + env-paths: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 9.1.0 + nopt: 5.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + optional: true + object-hash@3.0.0: {} + obuf@1.1.2: {} + obug@2.1.1: {} + ollama@0.5.18: + dependencies: + whatwg-fetch: 3.6.20 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + one-time@1.0.0: dependencies: fn.name: 1.1.0 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + transitivePeerDependencies: + - encoding + + p-finally@1.0.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + optional: true + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + package-json-from-dist@1.0.1: {} + + path-is-absolute@1.0.1: + optional: true + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + pathe@2.0.3: {} pg-cloudflare@1.3.0: @@ -1498,6 +4190,8 @@ snapshots: pg-int8@1.0.1: {} + pg-numeric@1.0.2: {} + pg-pool@3.11.0(pg@8.18.0): dependencies: pg: 8.18.0 @@ -1512,6 +4206,16 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pg-types@4.1.0: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.4 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + pg@8.18.0: dependencies: pg-connection-string: 2.11.0 @@ -1528,6 +4232,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} postcss@8.5.6: @@ -1538,20 +4244,116 @@ snapshots: postgres-array@2.0.0: {} + postgres-array@3.0.4: {} + postgres-bytea@1.0.1: {} + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + postgres-date@1.0.7: {} + postgres-date@2.1.0: {} + postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + promise-inflight@1.0.1: + optional: true + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + optional: true + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 25.2.0 + long: 5.3.2 + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-is@18.3.1: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + + retry@0.12.0: + optional: true + + retry@0.13.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + optional: true + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -1583,32 +4385,152 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + run-applescript@7.1.0: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: + optional: true + semver@7.7.4: {} + set-blocking@2.0.0: + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: + optional: true + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-wcswidth@1.1.2: {} + + slash@3.0.0: {} + + smart-buffer@4.2.0: + optional: true + + socks-proxy-agent@6.2.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + optional: true + source-map-js@1.2.1: {} split2@4.2.0: {} + sqlite3@5.1.7: + dependencies: + bindings: 1.5.0 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + tar: 6.2.1 + optionalDependencies: + node-gyp: 8.4.1 + transitivePeerDependencies: + - bluebird + - supports-color + + ssri@8.0.1: + dependencies: + minipass: 3.3.6 + optional: true + stack-trace@0.0.10: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + text-hex@1.0.0: {} tinybench@2.9.0: {} @@ -1622,18 +4544,48 @@ snapshots: tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + triple-beam@1.4.1: {} ts-mixer@6.0.4: {} tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + typescript@5.9.3: {} + + undici-types@5.26.5: {} + undici-types@7.16.0: {} undici@6.23.0: {} + unique-filename@1.1.1: + dependencies: + unique-slug: 2.0.2 + optional: true + + unique-slug@2.0.2: + dependencies: + imurmurhash: 0.1.4 + optional: true + util-deprecate@1.0.2: {} + uuid@10.0.0: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + vite@7.3.1(@types/node@25.2.0): dependencies: esbuild: 0.27.3 @@ -1683,11 +4635,33 @@ snapshots: - tsx - yaml + web-streams-polyfill@3.3.3: {} + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-fetch@3.6.20: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + optional: true + winston-daily-rotate-file@5.0.0(winston@3.19.0): dependencies: file-stream-rotator: 0.6.1 @@ -1716,6 +4690,38 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + ws@8.19.0: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + xtend@4.0.2: {} + + yallist@4.0.0: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} diff --git a/src/commands/memory.js b/src/commands/memory.js index c86f929c..fcef2007 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -17,6 +17,7 @@ import { isMemoryAvailable, searchMemories, } from '../modules/memory.js'; +import { splitMessage } from '../utils/splitMessage.js'; export const data = new SlashCommandBuilder() .setName('memory') @@ -87,17 +88,18 @@ async function handleView(interaction, userId, username) { const memoryList = memories.map((m, i) => `${i + 1}. ${m.memory}`).join('\n'); - // Truncate for Discord's 2000 char limit const header = `🧠 **What I remember about ${username}:**\n\n`; - const maxContent = 2000 - header.length - 50; // padding - const truncated = - memoryList.length > maxContent - ? `${memoryList.substring(0, maxContent)}...\n\n*(...and more)*` - : memoryList; + const truncationNotice = '\n\n*(...and more)*'; + const maxBodyLength = 2000 - header.length - truncationNotice.length; - await interaction.editReply({ - content: `${header}${truncated}`, - }); + // Use splitMessage to safely split on word boundaries (handles multi-byte chars) + const chunks = splitMessage(memoryList, maxBodyLength); + const isTruncated = chunks.length > 1; + const content = isTruncated + ? `${header}${chunks[0]}${truncationNotice}` + : `${header}${memoryList}`; + + await interaction.editReply({ content }); info('Memory view command', { userId, username, count: memories.length }); } @@ -136,7 +138,7 @@ async function handleForgetAll(interaction, userId, username) { async function handleForgetTopic(interaction, userId, username, topic) { await interaction.deferReply({ ephemeral: true }); - // Search for memories matching the topic + // Search for memories matching the topic (results include IDs) const { memories: matches } = await searchMemories(userId, topic, 10); if (matches.length === 0) { @@ -146,18 +148,10 @@ async function handleForgetTopic(interaction, userId, username, topic) { return; } - // Get full memories with IDs so we can delete them - const allMemories = await getMemories(userId); - - // Find memories whose text matches the search results - let deletedCount = 0; - for (const match of matches) { - const found = allMemories.find((m) => m.memory === match.memory && m.id); - if (found) { - const deleted = await deleteMemory(found.id); - if (deleted) deletedCount++; - } - } + // Use memory IDs directly from search results and delete in parallel + const matchesWithIds = matches.filter((m) => m.id); + const results = await Promise.allSettled(matchesWithIds.map((m) => deleteMemory(m.id))); + const deletedCount = results.filter((r) => r.status === 'fulfilled' && r.value === true).length; if (deletedCount > 0) { await interaction.editReply({ diff --git a/src/modules/memory.js b/src/modules/memory.js index 64e50010..e3d9e0db 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -195,6 +195,7 @@ export async function searchMemories(userId, query, limit) { const relations = result?.relations || []; const memories = rawMemories.map((m) => ({ + id: m.id || '', memory: m.memory || m.text || m.content || '', score: m.score ?? null, })); diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js index d0c5ce2a..c4051682 100644 --- a/tests/commands/memory.test.js +++ b/tests/commands/memory.test.js @@ -72,6 +72,14 @@ vi.mock('discord.js', () => { return { SlashCommandBuilder: MockSlashCommandBuilder }; }); +// Mock splitMessage utility +vi.mock('../../src/utils/splitMessage.js', () => ({ + splitMessage: vi.fn((text, maxLength) => { + if (!text || text.length <= (maxLength || 1990)) return text ? [text] : []; + return [text.slice(0, maxLength || 1990), text.slice(maxLength || 1990)]; + }), +})); + // Mock memory module vi.mock('../../src/modules/memory.js', () => ({ isMemoryAvailable: vi.fn(() => true), @@ -213,6 +221,7 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(deleteAllMemories).toHaveBeenCalledWith('123456'); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ @@ -227,6 +236,7 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('Failed'), @@ -236,15 +246,11 @@ describe('memory command', () => { }); describe('/memory forget ', () => { - it('should search and delete matching memories', async () => { + it('should search and delete matching memories using IDs from search results', async () => { searchMemories.mockResolvedValue({ - memories: [{ memory: 'User is learning Rust', score: 0.95 }], + memories: [{ id: 'mem-1', memory: 'User is learning Rust', score: 0.95 }], relations: [], }); - getMemories.mockResolvedValue([ - { id: 'mem-1', memory: 'User is learning Rust' }, - { id: 'mem-2', memory: 'User works at Google' }, - ]); deleteMemory.mockResolvedValue(true); const interaction = createMockInteraction({ @@ -254,6 +260,7 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(searchMemories).toHaveBeenCalledWith('123456', 'Rust', 10); expect(deleteMemory).toHaveBeenCalledWith('mem-1'); expect(interaction.editReply).toHaveBeenCalledWith( @@ -272,6 +279,7 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('No memories found'), @@ -281,10 +289,9 @@ describe('memory command', () => { it('should handle deletion failure for matched memories', async () => { searchMemories.mockResolvedValue({ - memories: [{ memory: 'Test memory', score: 0.9 }], + memories: [{ id: 'mem-1', memory: 'Test memory', score: 0.9 }], relations: [], }); - getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Test memory' }]); deleteMemory.mockResolvedValue(false); const interaction = createMockInteraction({ @@ -294,6 +301,7 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining("couldn't delete"), @@ -301,18 +309,14 @@ describe('memory command', () => { ); }); - it('should report correct count for multiple deletions', async () => { + it('should report correct count for multiple parallel deletions', async () => { searchMemories.mockResolvedValue({ memories: [ - { memory: 'Rust project A', score: 0.95 }, - { memory: 'Rust project B', score: 0.9 }, + { id: 'mem-1', memory: 'Rust project A', score: 0.95 }, + { id: 'mem-2', memory: 'Rust project B', score: 0.9 }, ], relations: [], }); - getMemories.mockResolvedValue([ - { id: 'mem-1', memory: 'Rust project A' }, - { id: 'mem-2', memory: 'Rust project B' }, - ]); deleteMemory.mockResolvedValue(true); const interaction = createMockInteraction({ @@ -322,7 +326,10 @@ describe('memory command', () => { await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(deleteMemory).toHaveBeenCalledTimes(2); + expect(deleteMemory).toHaveBeenCalledWith('mem-1'); + expect(deleteMemory).toHaveBeenCalledWith('mem-2'); expect(interaction.editReply).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('2 memories'), diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 777bb06d..0e37a5e9 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -271,8 +271,8 @@ describe('memory module', () => { const result = await searchMemories('user123', 'What language?'); expect(result.memories).toEqual([ - { memory: 'User is learning Rust', score: 0.95 }, - { memory: 'User works at Google', score: 0.8 }, + { id: '', memory: 'User is learning Rust', score: 0.95 }, + { id: '', memory: 'User works at Google', score: 0.8 }, ]); expect(result.relations).toHaveLength(1); expect(result.relations[0].relationship).toBe('works at'); @@ -293,7 +293,7 @@ describe('memory module', () => { _setClient(mockClient); const result = await searchMemories('user123', 'languages'); - expect(result.memories).toEqual([{ memory: 'User loves TypeScript', score: 0.9 }]); + expect(result.memories).toEqual([{ id: '', memory: 'User loves TypeScript', score: 0.9 }]); expect(result.relations).toEqual([]); }); @@ -335,9 +335,9 @@ describe('memory module', () => { _setClient(mockClient); const result = await searchMemories('user123', 'test'); - expect(result.memories[0].memory).toBe('via text field'); - expect(result.memories[1].memory).toBe('via content field'); - expect(result.memories[2].memory).toBe('via memory field'); + expect(result.memories[0]).toEqual({ id: '', memory: 'via text field', score: null }); + expect(result.memories[1]).toEqual({ id: '', memory: 'via content field', score: null }); + expect(result.memories[2]).toEqual({ id: '', memory: 'via memory field', score: null }); }); it('should return empty results when client is null', async () => { From e1ec32a70972cf898d33adb9c4c1a7de71a30c73 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:33:13 -0500 Subject: [PATCH 04/20] fix: add auto-recovery for transient mem0 failures Replace permanent mem0Available = false on API errors with a cooldown- based recovery mechanism. After RECOVERY_COOLDOWN_MS (60s), the next request is allowed through to check if the service recovered. If it fails again, the cooldown resets. This prevents a single transient network error from permanently disabling the memory system for all users until restart. Resolves review threads #4, #8, #12. --- src/modules/memory.js | 78 +++++++++++++++++++++++++++----- tests/modules/memory.test.js | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index e3d9e0db..cda11b2e 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -22,12 +22,36 @@ const APP_ID = 'bills-bot'; /** Default maximum memories to inject into context */ const DEFAULT_MAX_CONTEXT_MEMORIES = 5; +/** Cooldown period before retrying after a transient failure (ms) */ +const RECOVERY_COOLDOWN_MS = 60_000; + /** Tracks whether mem0 is reachable (set by health check, cleared on errors) */ let mem0Available = false; +/** Timestamp (ms) when mem0 was last marked unavailable (0 = never) */ +let mem0UnavailableSince = 0; + /** Singleton MemoryClient instance */ let client = null; +/** + * Mark mem0 as unavailable with a cooldown for auto-recovery. + * After RECOVERY_COOLDOWN_MS, the next request will be allowed through + * to check if the service has recovered. + */ +function markUnavailable() { + mem0Available = false; + mem0UnavailableSince = Date.now(); +} + +/** + * Mark mem0 as available and clear the recovery cooldown. + */ +function markAvailable() { + mem0Available = true; + mem0UnavailableSince = 0; +} + /** * Get or create the mem0 client instance. * Returns null if the API key is not configured. @@ -72,12 +96,29 @@ export function getMemoryConfig() { } /** - * Check if memory feature is enabled and mem0 is available + * Check if memory feature is enabled and mem0 is available. + * Supports auto-recovery: if mem0 was marked unavailable due to a transient + * error and the cooldown period has elapsed, it will be tentatively re-enabled + * so the next request can check if the service has recovered. * @returns {boolean} */ export function isMemoryAvailable() { const memConfig = getMemoryConfig(); - return memConfig.enabled && mem0Available; + if (!memConfig.enabled) return false; + + if (mem0Available) return true; + + // Auto-recovery: if cooldown has elapsed, tentatively re-enable + if ( + mem0UnavailableSince > 0 && + Date.now() - mem0UnavailableSince >= RECOVERY_COOLDOWN_MS + ) { + info('mem0 cooldown expired, attempting auto-recovery'); + markAvailable(); + return true; + } + + return false; } /** @@ -85,7 +126,20 @@ export function isMemoryAvailable() { * @param {boolean} available */ export function _setMem0Available(available) { - mem0Available = available; + if (available) { + markAvailable(); + } else { + mem0Available = false; + mem0UnavailableSince = 0; + } +} + +/** + * Get the recovery cooldown duration in ms (exported for testing) + * @returns {number} + */ +export function _getRecoveryCooldownMs() { + return RECOVERY_COOLDOWN_MS; } /** @@ -105,30 +159,30 @@ export async function checkMem0Health() { const memConfig = getMemoryConfig(); if (!memConfig.enabled) { info('Memory module disabled via config'); - mem0Available = false; + markUnavailable(); return false; } const apiKey = process.env.MEM0_API_KEY; if (!apiKey) { logWarn('MEM0_API_KEY not set — memory features disabled'); - mem0Available = false; + markUnavailable(); return false; } try { const c = getClient(); if (!c) { - mem0Available = false; + markUnavailable(); return false; } - mem0Available = true; + markAvailable(); info('mem0 health check passed (API key configured, SDK client initialized)'); return true; } catch (err) { logWarn('mem0 health check failed', { error: err.message }); - mem0Available = false; + markUnavailable(); return false; } } @@ -160,7 +214,7 @@ export async function addMemory(userId, text, metadata = {}) { return true; } catch (err) { logWarn('Failed to add memory', { userId, error: err.message }); - mem0Available = false; + markUnavailable(); return false; } } @@ -203,7 +257,7 @@ export async function searchMemories(userId, query, limit) { return { memories, relations }; } catch (err) { logWarn('Failed to search memories', { userId, error: err.message }); - mem0Available = false; + markUnavailable(); return { memories: [], relations: [] }; } } @@ -234,7 +288,7 @@ export async function getMemories(userId) { })); } catch (err) { logWarn('Failed to get memories', { userId, error: err.message }); - mem0Available = false; + markUnavailable(); return []; } } @@ -363,7 +417,7 @@ export async function extractAndStoreMemories(userId, username, userMessage, ass return true; } catch (err) { logWarn('Memory extraction failed', { userId, error: err.message }); - mem0Available = false; + markUnavailable(); return false; } } diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 0e37a5e9..8a9f4a98 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -28,6 +28,7 @@ vi.mock('../../src/logger.js', () => ({ import { getConfig } from '../../src/modules/config.js'; import { + _getRecoveryCooldownMs, _setClient, _setMem0Available, addMemory, @@ -141,6 +142,93 @@ describe('memory module', () => { getConfig.mockReturnValue({ memory: { enabled: false } }); expect(isMemoryAvailable()).toBe(false); }); + + it('should auto-recover after cooldown period expires', () => { + _setMem0Available(true); + const mockClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('transient')), + }); + _setClient(mockClient); + + // Simulate a transient failure by calling addMemory (which will markUnavailable) + // Instead, manually trigger the unavailable state with a past timestamp + _setMem0Available(false); + + // Immediately after marking unavailable, should still be false + expect(isMemoryAvailable()).toBe(false); + + // Simulate the unavailable timestamp being in the past by using vi.useFakeTimers + vi.useFakeTimers(); + _setMem0Available(true); + + // Now mark unavailable again - this time we can control time + // We need to trigger markUnavailable through an API call + const failingClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('API error')), + }); + _setClient(failingClient); + _setMem0Available(true); + + // This will fail and call markUnavailable() + addMemory('user123', 'test').then(() => { + expect(isMemoryAvailable()).toBe(false); + + // Advance time past the cooldown + vi.advanceTimersByTime(_getRecoveryCooldownMs()); + + // Should now auto-recover + expect(isMemoryAvailable()).toBe(true); + + vi.useRealTimers(); + }); + }); + + it('should not auto-recover before cooldown expires', async () => { + vi.useFakeTimers(); + _setMem0Available(true); + const failingClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('API error')), + }); + _setClient(failingClient); + + // Trigger a failure to markUnavailable + await addMemory('user123', 'test'); + expect(isMemoryAvailable()).toBe(false); + + // Advance time but not enough + vi.advanceTimersByTime(_getRecoveryCooldownMs() - 1000); + expect(isMemoryAvailable()).toBe(false); + + // Now advance past the cooldown + vi.advanceTimersByTime(1000); + expect(isMemoryAvailable()).toBe(true); + + vi.useRealTimers(); + }); + + it('should re-disable if recovery attempt also fails', async () => { + vi.useFakeTimers(); + _setMem0Available(true); + const failingClient = createMockClient({ + add: vi.fn().mockRejectedValue(new Error('API error')), + search: vi.fn().mockRejectedValue(new Error('Still down')), + }); + _setClient(failingClient); + + // Trigger initial failure + await addMemory('user123', 'test'); + expect(isMemoryAvailable()).toBe(false); + + // Advance past cooldown - auto-recovery kicks in + vi.advanceTimersByTime(_getRecoveryCooldownMs()); + expect(isMemoryAvailable()).toBe(true); + + // But the next operation also fails + await searchMemories('user123', 'test'); + expect(isMemoryAvailable()).toBe(false); + + vi.useRealTimers(); + }); }); describe('checkMem0Health', () => { From 34b2eaf2a8c5242104787b8b438a33801d625647 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:34:01 -0500 Subject: [PATCH 05/20] fix: verify SDK connectivity in health check instead of just client creation The health check now performs a lightweight search against the mem0 platform to verify the SDK client actually works, rather than just checking that the client object was created. This properly validates connectivity with the hosted mem0 platform. Also fixes misleading test name that asserted success but was named as a failure case. Resolves review threads #5, #11. --- src/modules/memory.js | 12 +++++++++-- tests/modules/memory.test.js | 39 ++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index cda11b2e..292aae56 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -152,7 +152,8 @@ export function _setClient(newClient) { /** * Run a health check against the mem0 platform on startup. - * Verifies the API key is configured and the SDK client can be created. + * Verifies the API key is configured and the SDK client can actually + * communicate with the hosted platform by performing a lightweight search. * @returns {Promise} true if mem0 is ready */ export async function checkMem0Health() { @@ -177,8 +178,15 @@ export async function checkMem0Health() { return false; } + // Verify SDK connectivity with a lightweight search against the platform + await c.search('health-check', { + user_id: '__health_check__', + app_id: APP_ID, + limit: 1, + }); + markAvailable(); - info('mem0 health check passed (API key configured, SDK client initialized)'); + info('mem0 health check passed (SDK connectivity verified)'); return true; } catch (err) { logWarn('mem0 health check failed', { error: err.message }); diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 8a9f4a98..9d703b55 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -232,14 +232,23 @@ describe('memory module', () => { }); describe('checkMem0Health', () => { - it('should mark as available when API key is set and client can be created', async () => { + it('should mark as available when API key is set and SDK connectivity verified', async () => { process.env.MEM0_API_KEY = 'test-api-key'; - const mockClient = createMockClient(); + const mockClient = createMockClient({ + search: vi.fn().mockResolvedValue({ results: [], relations: [] }), + }); _setClient(mockClient); const result = await checkMem0Health(); expect(result).toBe(true); expect(isMemoryAvailable()).toBe(true); + + // Verify it performed a lightweight search to check connectivity + expect(mockClient.search).toHaveBeenCalledWith('health-check', { + user_id: '__health_check__', + app_id: 'bills-bot', + limit: 1, + }); }); it('should mark as unavailable when API key is not set', async () => { @@ -256,19 +265,27 @@ describe('memory module', () => { expect(isMemoryAvailable()).toBe(false); }); - it('should mark as unavailable when client creation fails', async () => { + it('should fail health check when auto-created client cannot connect', async () => { process.env.MEM0_API_KEY = 'test-api-key'; - // Don't set a client — getClient will try to create one - // Since the MemoryClient constructor is mocked (noop), it won't throw, - // but we can test the error path by ensuring getClient returns null + // Don't set a client — getClient will auto-create from mocked constructor + // The auto-created client has no search method, so health check will fail _setClient(null); - // The mocked MemoryClient constructor returns undefined (vi.fn()), - // so getClient() will return the mocked instance. Let's test that path. const result = await checkMem0Health(); - // The mocked constructor returns an object (vi.fn() return value), - // so this should succeed - expect(result).toBe(true); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); + }); + + it('should mark as unavailable when SDK connectivity check fails', async () => { + process.env.MEM0_API_KEY = 'test-api-key'; + const mockClient = createMockClient({ + search: vi.fn().mockRejectedValue(new Error('Connection refused')), + }); + _setClient(mockClient); + + const result = await checkMem0Health(); + expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); }); }); From 75caa19b0fe92aca0890af944407ff63f288cce9 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:34:32 -0500 Subject: [PATCH 06/20] docs: add privacy notice for external memory storage Add privacy documentation to the memory module explaining that user messages are sent to the mem0 hosted platform for processing. Update the /memory command description to indicate external storage. Users can view and delete their data via /memory view and /memory forget. Resolves review thread #6. --- src/commands/memory.js | 6 +++++- src/modules/memory.js | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/memory.js b/src/commands/memory.js index fcef2007..d5a1b186 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -2,6 +2,10 @@ * Memory Command * Allows users to view and manage what the bot remembers about them. * + * Memories are stored externally on the mem0 platform (api.mem0.ai). + * Users can view their data with /memory view and delete it with + * /memory forget at any time. + * * Subcommands: * /memory view — Show all memories the bot has about you * /memory forget — Clear all your memories @@ -21,7 +25,7 @@ import { splitMessage } from '../utils/splitMessage.js'; export const data = new SlashCommandBuilder() .setName('memory') - .setDescription('Manage what the bot remembers about you') + .setDescription('Manage what the bot remembers about you (stored externally)') .addSubcommand((sub) => sub.setName('view').setDescription('View what the bot remembers about you'), ) diff --git a/src/modules/memory.js b/src/modules/memory.js index 292aae56..674a3870 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -10,6 +10,13 @@ * * Graceful fallback: if mem0 is unavailable, all operations return * safe defaults (empty arrays / false) so the AI pipeline continues. + * + * **Privacy Notice:** + * This module sends user messages to the mem0 hosted platform + * (api.mem0.ai) for memory extraction and storage. By interacting + * with the bot, users' messages may be processed and stored externally. + * Users can view and delete their stored memories via the /memory command. + * The /memory forget command allows users to clear all their data. */ import MemoryClient from 'mem0ai'; From 7961d2ea5ac01c4506eba71574ab60995ba764a4 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:35:17 -0500 Subject: [PATCH 07/20] chore: remove unused extractModel config and document addMemory public API - Remove extractModel from getMemoryConfig() and config.json since it was never consumed by any function (#10) - Document addMemory as part of the public API, exported for direct use by other modules/plugins that need to store memories (#9) --- config.json | 3 +-- src/modules/memory.js | 7 +++++-- tests/modules/memory.test.js | 5 ----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index 02c0ff45..fd22570d 100644 --- a/config.json +++ b/config.json @@ -66,8 +66,7 @@ "memory": { "enabled": true, "maxContextMemories": 5, - "autoExtract": true, - "extractModel": null + "autoExtract": true }, "logging": { "level": "info", diff --git a/src/modules/memory.js b/src/modules/memory.js index 674a3870..46c12a9f 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -90,14 +90,12 @@ export function getMemoryConfig() { enabled: config?.memory?.enabled ?? true, maxContextMemories: config?.memory?.maxContextMemories ?? DEFAULT_MAX_CONTEXT_MEMORIES, autoExtract: config?.memory?.autoExtract ?? true, - extractModel: config?.memory?.extractModel ?? null, }; } catch { return { enabled: true, maxContextMemories: DEFAULT_MAX_CONTEXT_MEMORIES, autoExtract: true, - extractModel: null, }; } } @@ -205,6 +203,11 @@ export async function checkMem0Health() { /** * Add a memory for a user. * Graph memory is enabled to automatically build entity relationships. + * + * Part of the public API — used by extractAndStoreMemories internally and + * exported for direct use by other modules/plugins that need to store + * specific memories programmatically. + * * @param {string} userId - Discord user ID * @param {string} text - The memory text to store * @param {Object} [metadata] - Optional metadata diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 9d703b55..e76eefcc 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -13,7 +13,6 @@ vi.mock('../../src/modules/config.js', () => ({ enabled: true, maxContextMemories: 5, autoExtract: true, - extractModel: null, }, })), })); @@ -71,7 +70,6 @@ describe('memory module', () => { enabled: true, maxContextMemories: 5, autoExtract: true, - extractModel: null, }, }); // Set up env for tests @@ -89,7 +87,6 @@ describe('memory module', () => { expect(config.enabled).toBe(true); expect(config.maxContextMemories).toBe(5); expect(config.autoExtract).toBe(true); - expect(config.extractModel).toBeNull(); }); it('should return defaults when config is missing', () => { @@ -115,14 +112,12 @@ describe('memory module', () => { enabled: false, maxContextMemories: 10, autoExtract: false, - extractModel: 'custom-model', }, }); const config = getMemoryConfig(); expect(config.enabled).toBe(false); expect(config.maxContextMemories).toBe(10); expect(config.autoExtract).toBe(false); - expect(config.extractModel).toBe('custom-model'); }); }); From 232d33a7bfdbc6bed844ceddced6ffb807f6df27 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 19:37:45 -0500 Subject: [PATCH 08/20] style: fix Biome formatting in memory module --- src/modules/memory.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index 46c12a9f..1579ca06 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -114,10 +114,7 @@ export function isMemoryAvailable() { if (mem0Available) return true; // Auto-recovery: if cooldown has elapsed, tentatively re-enable - if ( - mem0UnavailableSince > 0 && - Date.now() - mem0UnavailableSince >= RECOVERY_COOLDOWN_MS - ) { + if (mem0UnavailableSince > 0 && Date.now() - mem0UnavailableSince >= RECOVERY_COOLDOWN_MS) { info('mem0 cooldown expired, attempting auto-recovery'); markAvailable(); return true; From 79c341a33b784cee151b751f65cb56c8888ebeed Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:15:33 -0500 Subject: [PATCH 09/20] feat: add memory command security features - Add /memory optout subcommand to toggle memory collection per user - In-memory Set with JSON file persistence (data/optout.json) - extractAndStoreMemories and buildMemoryContext skip opted-out users - Toggle behavior: running again opts back in - Add confirmation prompt on /memory forget (all) - Discord ButtonBuilder with Confirm (Danger) and Cancel buttons - 30-second timeout with auto-cancel - Only the command user can interact with buttons - Add /memory admin view and /memory admin clear subcommands - Requires ManageGuild or Administrator permission - Admin view shows target user's memories with opt-out status - Admin clear includes confirmation prompt before deletion - Comprehensive test coverage for all new features - 17 new optout module tests - 32 total memory command tests (was 12) - 54 total memory module tests (was 52) - All metrics above 80% threshold --- src/commands/memory.js | 286 ++++++++++++++++- src/modules/memory.js | 3 + src/modules/optout.js | 115 +++++++ tests/commands/memory.test.js | 565 +++++++++++++++++++++++++++++++++- tests/modules/memory.test.js | 30 ++ tests/modules/optout.test.js | 185 +++++++++++ 6 files changed, 1162 insertions(+), 22 deletions(-) create mode 100644 src/modules/optout.js create mode 100644 tests/modules/optout.test.js diff --git a/src/commands/memory.js b/src/commands/memory.js index d5a1b186..45c5e18b 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -7,12 +7,22 @@ * /memory forget at any time. * * Subcommands: - * /memory view — Show all memories the bot has about you - * /memory forget — Clear all your memories + * /memory view — Show all memories the bot has about you + * /memory forget — Clear all your memories (with confirmation) * /memory forget — Clear memories matching a topic + * /memory optout — Toggle memory collection on/off + * /memory admin view @user — (Mod) View any user's memories + * /memory admin clear @user — (Mod) Clear any user's memories */ -import { SlashCommandBuilder } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + PermissionFlagsBits, + SlashCommandBuilder, +} from 'discord.js'; import { info, warn } from '../logger.js'; import { deleteAllMemories, @@ -21,6 +31,7 @@ import { isMemoryAvailable, searchMemories, } from '../modules/memory.js'; +import { isOptedOut, toggleOptOut } from '../modules/optout.js'; import { splitMessage } from '../utils/splitMessage.js'; export const data = new SlashCommandBuilder() @@ -39,6 +50,30 @@ export const data = new SlashCommandBuilder() .setDescription('Specific topic to forget (omit to forget everything)') .setRequired(false), ), + ) + .addSubcommand((sub) => + sub.setName('optout').setDescription('Toggle memory collection on/off for your account'), + ) + .addSubcommandGroup((group) => + group + .setName('admin') + .setDescription('Admin memory management commands') + .addSubcommand((sub) => + sub + .setName('view') + .setDescription("View a user's memories") + .addUserOption((opt) => + opt.setName('user').setDescription('The user to view memories for').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('clear') + .setDescription("Clear a user's memories") + .addUserOption((opt) => + opt.setName('user').setDescription('The user to clear memories for').setRequired(true), + ), + ), ); /** @@ -46,10 +81,23 @@ export const data = new SlashCommandBuilder() * @param {import('discord.js').ChatInputCommandInteraction} interaction */ export async function execute(interaction) { + const subcommandGroup = interaction.options.getSubcommandGroup(false); const subcommand = interaction.options.getSubcommand(); const userId = interaction.user.id; const username = interaction.user.username; + // Handle admin subcommand group + if (subcommandGroup === 'admin') { + await handleAdmin(interaction, subcommand); + return; + } + + // Handle opt-out (doesn't require memory to be available) + if (subcommand === 'optout') { + await handleOptOut(interaction, userId); + return; + } + if (!isMemoryAvailable()) { await interaction.reply({ content: @@ -71,6 +119,31 @@ export async function execute(interaction) { } } +/** + * Handle /memory optout — toggle memory collection for the user + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} userId + */ +async function handleOptOut(interaction, userId) { + const { optedOut } = toggleOptOut(userId); + + if (optedOut) { + await interaction.reply({ + content: + '🚫 You have **opted out** of memory collection. The bot will no longer remember things about you. Your existing memories are unchanged — use `/memory forget` to delete them.', + ephemeral: true, + }); + } else { + await interaction.reply({ + content: + '✅ You have **opted back in** to memory collection. The bot will start remembering things about you again.', + ephemeral: true, + }); + } + + info('Memory opt-out toggled', { userId, optedOut }); +} + /** * Handle /memory view — show all memories for the user * @param {import('discord.js').ChatInputCommandInteraction} interaction @@ -109,26 +182,66 @@ async function handleView(interaction, userId, username) { } /** - * Handle /memory forget (all) — delete all memories for the user + * Handle /memory forget (all) — delete all memories with confirmation * @param {import('discord.js').ChatInputCommandInteraction} interaction * @param {string} userId * @param {string} username */ async function handleForgetAll(interaction, userId, username) { - await interaction.deferReply({ ephemeral: true }); + const confirmButton = new ButtonBuilder() + .setCustomId('memory_forget_confirm') + .setLabel('Confirm') + .setStyle(ButtonStyle.Danger); - const success = await deleteAllMemories(userId); + const cancelButton = new ButtonBuilder() + .setCustomId('memory_forget_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); - if (success) { - await interaction.editReply({ - content: '🧹 Done! All your memories have been cleared. Fresh start!', + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + const response = await interaction.reply({ + content: + '⚠️ **Are you sure?** This will delete **ALL** your memories permanently. This cannot be undone.', + components: [row], + ephemeral: true, + }); + + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === userId, + time: 30_000, }); - info('All memories cleared', { userId, username }); - } else { + + if (buttonInteraction.customId === 'memory_forget_confirm') { + const success = await deleteAllMemories(userId); + + if (success) { + await buttonInteraction.update({ + content: '🧹 Done! All your memories have been cleared. Fresh start!', + components: [], + }); + info('All memories cleared', { userId, username }); + } else { + await buttonInteraction.update({ + content: '❌ Failed to clear memories. Please try again later.', + components: [], + }); + warn('Failed to clear memories', { userId, username }); + } + } else { + await buttonInteraction.update({ + content: '↩️ Memory deletion cancelled.', + components: [], + }); + } + } catch { + // Timeout — no interaction received within 30 seconds await interaction.editReply({ - content: '❌ Failed to clear memories. Please try again later.', + content: '⏰ Confirmation timed out. No memories were deleted.', + components: [], }); - warn('Failed to clear memories', { userId, username }); } } @@ -168,3 +281,150 @@ async function handleForgetTopic(interaction, userId, username, topic) { }); } } + +/** + * Handle /memory admin commands + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} subcommand - 'view' or 'clear' + */ +async function handleAdmin(interaction, subcommand) { + // Permission check + const hasPermission = + interaction.memberPermissions && + (interaction.memberPermissions.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions.has(PermissionFlagsBits.Administrator)); + + if (!hasPermission) { + await interaction.reply({ + content: + '❌ You need **Manage Server** or **Administrator** permission to use admin commands.', + ephemeral: true, + }); + return; + } + + if (!isMemoryAvailable()) { + await interaction.reply({ + content: + '🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.', + ephemeral: true, + }); + return; + } + + const targetUser = interaction.options.getUser('user'); + const targetId = targetUser.id; + const targetUsername = targetUser.username; + + if (subcommand === 'view') { + await handleAdminView(interaction, targetId, targetUsername); + } else if (subcommand === 'clear') { + await handleAdminClear(interaction, targetId, targetUsername); + } +} + +/** + * Handle /memory admin view @user — view a user's memories (mod only) + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} targetId + * @param {string} targetUsername + */ +async function handleAdminView(interaction, targetId, targetUsername) { + await interaction.deferReply({ ephemeral: true }); + + const memories = await getMemories(targetId); + const optedOutStatus = isOptedOut(targetId) ? ' *(opted out)*' : ''; + + if (memories.length === 0) { + await interaction.editReply({ + content: `🧠 No memories found for **${targetUsername}**${optedOutStatus}.`, + }); + return; + } + + const memoryList = memories.map((m, i) => `${i + 1}. ${m.memory}`).join('\n'); + + const header = `🧠 **Memories for ${targetUsername}${optedOutStatus}:**\n\n`; + const truncationNotice = '\n\n*(...and more)*'; + const maxBodyLength = 2000 - header.length - truncationNotice.length; + + const chunks = splitMessage(memoryList, maxBodyLength); + const isTruncated = chunks.length > 1; + const content = isTruncated + ? `${header}${chunks[0]}${truncationNotice}` + : `${header}${memoryList}`; + + await interaction.editReply({ content }); + + info('Admin memory view', { + adminId: interaction.user.id, + targetId, + targetUsername, + count: memories.length, + }); +} + +/** + * Handle /memory admin clear @user — clear a user's memories with confirmation (mod only) + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {string} targetId + * @param {string} targetUsername + */ +async function handleAdminClear(interaction, targetId, targetUsername) { + const adminId = interaction.user.id; + + const confirmButton = new ButtonBuilder() + .setCustomId('memory_admin_clear_confirm') + .setLabel('Confirm') + .setStyle(ButtonStyle.Danger); + + const cancelButton = new ButtonBuilder() + .setCustomId('memory_admin_clear_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + const response = await interaction.reply({ + content: `⚠️ **Are you sure?** This will delete **ALL** memories for **${targetUsername}** permanently. This cannot be undone.`, + components: [row], + ephemeral: true, + }); + + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === adminId, + time: 30_000, + }); + + if (buttonInteraction.customId === 'memory_admin_clear_confirm') { + const success = await deleteAllMemories(targetId); + + if (success) { + await buttonInteraction.update({ + content: `🧹 Done! All memories for **${targetUsername}** have been cleared.`, + components: [], + }); + info('Admin cleared all memories', { adminId, targetId, targetUsername }); + } else { + await buttonInteraction.update({ + content: `❌ Failed to clear memories for **${targetUsername}**. Please try again later.`, + components: [], + }); + warn('Admin failed to clear memories', { adminId, targetId, targetUsername }); + } + } else { + await buttonInteraction.update({ + content: '↩️ Memory deletion cancelled.', + components: [], + }); + } + } catch { + // Timeout — no interaction received within 30 seconds + await interaction.editReply({ + content: '⏰ Confirmation timed out. No memories were deleted.', + components: [], + }); + } +} diff --git a/src/modules/memory.js b/src/modules/memory.js index 1579ca06..27fd4304 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -22,6 +22,7 @@ import MemoryClient from 'mem0ai'; import { debug, info, warn as logWarn } from '../logger.js'; import { getConfig } from './config.js'; +import { isOptedOut } from './optout.js'; /** App namespace — isolates memories from other mem0 consumers */ const APP_ID = 'bills-bot'; @@ -373,6 +374,7 @@ export function formatRelations(relations) { */ export async function buildMemoryContext(userId, username, query) { if (!isMemoryAvailable()) return ''; + if (isOptedOut(userId)) return ''; const { memories, relations } = await searchMemories(userId, query); @@ -405,6 +407,7 @@ export async function buildMemoryContext(userId, username, query) { */ export async function extractAndStoreMemories(userId, username, userMessage, assistantReply) { if (!isMemoryAvailable()) return false; + if (isOptedOut(userId)) return false; const memConfig = getMemoryConfig(); if (!memConfig.autoExtract) return false; diff --git a/src/modules/optout.js b/src/modules/optout.js new file mode 100644 index 00000000..ce770cd5 --- /dev/null +++ b/src/modules/optout.js @@ -0,0 +1,115 @@ +/** + * Opt-Out Module + * Manages user opt-out state for memory collection. + * + * Users who opt out will not have their messages analyzed for memory + * extraction and will not have memories injected into AI context. + * The bot still works normally for opted-out users, just without + * long-term memory features. + * + * State is stored in an in-memory Set for fast lookups and persisted + * to data/optout.json for durability across restarts. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { info, warn as logWarn } from '../logger.js'; + +/** Default path for the opt-out persistence file */ +const DEFAULT_OPTOUT_PATH = resolve('data/optout.json'); + +/** In-memory set of opted-out user IDs */ +let optedOutUsers = new Set(); + +/** Current file path (can be overridden for testing) */ +let optoutFilePath = DEFAULT_OPTOUT_PATH; + +/** + * Set the file path for opt-out persistence (for testing). + * @param {string} filePath + */ +export function _setOptoutPath(filePath) { + optoutFilePath = filePath; +} + +/** + * Reset the opt-out state (for testing). + */ +export function _resetOptouts() { + optedOutUsers = new Set(); + optoutFilePath = DEFAULT_OPTOUT_PATH; +} + +/** + * Check if a user has opted out of memory collection. + * @param {string} userId - Discord user ID + * @returns {boolean} true if the user has opted out + */ +export function isOptedOut(userId) { + return optedOutUsers.has(userId); +} + +/** + * Toggle the opt-out state for a user. + * If opted out, opts them back in. If opted in, opts them out. + * Persists the change to disk. + * @param {string} userId - Discord user ID + * @returns {{ optedOut: boolean }} The new opt-out state + */ +export function toggleOptOut(userId) { + if (optedOutUsers.has(userId)) { + optedOutUsers.delete(userId); + info('User opted back in to memory', { userId }); + saveOptOuts(); + return { optedOut: false }; + } + + optedOutUsers.add(userId); + info('User opted out of memory', { userId }); + saveOptOuts(); + return { optedOut: true }; +} + +/** + * Load opt-out state from the persistence file. + * Handles missing or corrupt files gracefully. + */ +export function loadOptOuts() { + try { + if (!existsSync(optoutFilePath)) { + info('No opt-out file found, starting with empty set', { path: optoutFilePath }); + return; + } + + const raw = readFileSync(optoutFilePath, 'utf-8'); + const data = JSON.parse(raw); + + if (Array.isArray(data)) { + optedOutUsers = new Set(data); + info('Loaded opt-out list', { count: optedOutUsers.size, path: optoutFilePath }); + } else { + logWarn('Invalid opt-out file format, expected array', { path: optoutFilePath }); + optedOutUsers = new Set(); + } + } catch (err) { + logWarn('Failed to load opt-out file', { path: optoutFilePath, error: err.message }); + optedOutUsers = new Set(); + } +} + +/** + * Save the current opt-out state to the persistence file. + */ +export function saveOptOuts() { + try { + const dir = dirname(optoutFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const data = JSON.stringify([...optedOutUsers], null, 2); + writeFileSync(optoutFilePath, data, 'utf-8'); + } catch (err) { + logWarn('Failed to save opt-out file', { path: optoutFilePath, error: err.message }); + } +} diff --git a/tests/commands/memory.test.js b/tests/commands/memory.test.js index c4051682..b4cf5e15 100644 --- a/tests/commands/memory.test.js +++ b/tests/commands/memory.test.js @@ -7,6 +7,7 @@ vi.mock('discord.js', () => { this.name = ''; this.description = ''; this._subcommands = []; + this._subcommandGroups = []; } setName(name) { this.name = name; @@ -22,11 +23,39 @@ vi.mock('discord.js', () => { this._subcommands.push(sub); return this; } + addSubcommandGroup(fn) { + const group = new MockSubcommandGroupBuilder(); + fn(group); + this._subcommandGroups.push(group); + return this; + } toJSON() { return { name: this.name, description: this.description }; } } + class MockSubcommandGroupBuilder { + constructor() { + this.name = ''; + this.description = ''; + this._subcommands = []; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommand(fn) { + const sub = new MockSubcommandBuilder(); + fn(sub); + this._subcommands.push(sub); + return this; + } + } + class MockSubcommandBuilder { constructor() { this.name = ''; @@ -47,6 +76,12 @@ vi.mock('discord.js', () => { this._options.push(opt); return this; } + addUserOption(fn) { + const opt = new MockUserOption(); + fn(opt); + this._options.push(opt); + return this; + } } class MockStringOption { @@ -69,7 +104,67 @@ vi.mock('discord.js', () => { } } - return { SlashCommandBuilder: MockSlashCommandBuilder }; + class MockUserOption { + constructor() { + this.name = ''; + this.description = ''; + this.required = false; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + setRequired(req) { + this.required = req; + return this; + } + } + + class MockButtonBuilder { + constructor() { + this._customId = ''; + this._label = ''; + this._style = null; + } + setCustomId(id) { + this._customId = id; + return this; + } + setLabel(label) { + this._label = label; + return this; + } + setStyle(style) { + this._style = style; + return this; + } + } + + class MockActionRowBuilder { + constructor() { + this._components = []; + } + addComponents(...components) { + this._components.push(...components); + return this; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + ButtonBuilder: MockButtonBuilder, + ActionRowBuilder: MockActionRowBuilder, + ButtonStyle: { Danger: 4, Secondary: 2 }, + ComponentType: { Button: 2 }, + PermissionFlagsBits: { + ManageGuild: 1n << 5n, + Administrator: 1n << 3n, + }, + }; }); // Mock splitMessage utility @@ -89,6 +184,12 @@ vi.mock('../../src/modules/memory.js', () => ({ deleteMemory: vi.fn(() => Promise.resolve(true)), })); +// Mock optout module +vi.mock('../../src/modules/optout.js', () => ({ + isOptedOut: vi.fn(() => false), + toggleOptOut: vi.fn(() => ({ optedOut: true })), +})); + // Mock logger vi.mock('../../src/logger.js', () => ({ info: vi.fn(), @@ -97,6 +198,7 @@ vi.mock('../../src/logger.js', () => ({ debug: vi.fn(), })); +import { PermissionFlagsBits } from 'discord.js'; import { data, execute } from '../../src/commands/memory.js'; import { deleteAllMemories, @@ -105,6 +207,7 @@ import { isMemoryAvailable, searchMemories, } from '../../src/modules/memory.js'; +import { isOptedOut, toggleOptOut } from '../../src/modules/optout.js'; /** * Create a mock interaction for memory command tests. @@ -113,19 +216,37 @@ import { */ function createMockInteraction({ subcommand = 'view', + subcommandGroup = null, topic = null, userId = '123456', username = 'testuser', + targetUser = null, + hasManageGuild = false, + hasAdmin = false, } = {}) { + const mockResponse = { + awaitMessageComponent: vi.fn(), + }; + return { options: { getSubcommand: () => subcommand, + getSubcommandGroup: () => subcommandGroup, getString: (name) => (name === 'topic' ? topic : null), + getUser: () => targetUser, }, user: { id: userId, username }, - reply: vi.fn(), + memberPermissions: { + has: (perm) => { + if (perm === PermissionFlagsBits.ManageGuild) return hasManageGuild; + if (perm === PermissionFlagsBits.Administrator) return hasAdmin; + return false; + }, + }, + reply: vi.fn().mockResolvedValue(mockResponse), deferReply: vi.fn(), editReply: vi.fn(), + _mockResponse: mockResponse, }; } @@ -137,6 +258,8 @@ describe('memory command', () => { deleteAllMemories.mockResolvedValue(true); searchMemories.mockResolvedValue({ memories: [], relations: [] }); deleteMemory.mockResolvedValue(true); + toggleOptOut.mockReturnValue({ optedOut: true }); + isOptedOut.mockReturnValue(false); }); describe('data export', () => { @@ -214,35 +337,164 @@ describe('memory command', () => { }); }); - describe('/memory forget (all)', () => { - it('should delete all memories and confirm', async () => { + describe('/memory optout', () => { + it('should toggle opt-out and reply with opted-out message', async () => { + toggleOptOut.mockReturnValue({ optedOut: true }); + const interaction = createMockInteraction({ subcommand: 'optout' }); + + await execute(interaction); + + expect(toggleOptOut).toHaveBeenCalledWith('123456'); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('opted out'), + ephemeral: true, + }), + ); + }); + + it('should toggle opt-in and reply with opted-in message', async () => { + toggleOptOut.mockReturnValue({ optedOut: false }); + const interaction = createMockInteraction({ subcommand: 'optout' }); + + await execute(interaction); + + expect(toggleOptOut).toHaveBeenCalledWith('123456'); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('opted back in'), + ephemeral: true, + }), + ); + }); + + it('should work even when memory system is unavailable', async () => { + isMemoryAvailable.mockReturnValue(false); + toggleOptOut.mockReturnValue({ optedOut: true }); + const interaction = createMockInteraction({ subcommand: 'optout' }); + + await execute(interaction); + + expect(toggleOptOut).toHaveBeenCalledWith('123456'); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('opted out'), + ephemeral: true, + }), + ); + }); + }); + + describe('/memory forget (all) — confirmation flow', () => { + it('should show confirmation buttons when forgetting all', async () => { + const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_confirm', + update: vi.fn(), + }); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Are you sure'), + components: expect.any(Array), + ephemeral: true, + }), + ); + }); + + it('should delete memories on confirm', async () => { deleteAllMemories.mockResolvedValue(true); + const mockUpdate = vi.fn(); const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_confirm', + update: mockUpdate, + }); await execute(interaction); - expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); expect(deleteAllMemories).toHaveBeenCalledWith('123456'); - expect(interaction.editReply).toHaveBeenCalledWith( + expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('cleared'), + components: [], }), ); }); - it('should show error when deletion fails', async () => { + it('should show error when deletion fails on confirm', async () => { deleteAllMemories.mockResolvedValue(false); + const mockUpdate = vi.fn(); const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_confirm', + update: mockUpdate, + }); await execute(interaction); - expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); - expect(interaction.editReply).toHaveBeenCalledWith( + expect(mockUpdate).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('Failed'), + components: [], }), ); }); + + it('should cancel on cancel button', async () => { + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_cancel', + update: mockUpdate, + }); + + await execute(interaction); + + expect(deleteAllMemories).not.toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('cancelled'), + components: [], + }), + ); + }); + + it('should timeout after 30 seconds', async () => { + const interaction = createMockInteraction({ subcommand: 'forget' }); + interaction._mockResponse.awaitMessageComponent.mockRejectedValue( + new Error('Collector received no interactions before ending with reason: time'), + ); + + await execute(interaction); + + expect(deleteAllMemories).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('timed out'), + components: [], + }), + ); + }); + + it('should pass correct filter to awaitMessageComponent', async () => { + const interaction = createMockInteraction({ subcommand: 'forget', userId: 'user789' }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_forget_cancel', + update: vi.fn(), + }); + + await execute(interaction); + + const awaitCall = interaction._mockResponse.awaitMessageComponent.mock.calls[0][0]; + expect(awaitCall.time).toBe(30_000); + + // Test the filter function + expect(awaitCall.filter({ user: { id: 'user789' } })).toBe(true); + expect(awaitCall.filter({ user: { id: 'other_user' } })).toBe(false); + }); }); describe('/memory forget ', () => { @@ -337,4 +589,299 @@ describe('memory command', () => { ); }); }); + + describe('/memory admin view', () => { + it('should reject without proper permissions', async () => { + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: false, + hasAdmin: false, + }); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Manage Server'), + ephemeral: true, + }), + ); + }); + + it('should allow with ManageGuild permission', async () => { + getMemories.mockResolvedValue([{ id: 'mem-1', memory: 'Target loves coding' }]); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(getMemories).toHaveBeenCalledWith('999'); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('targetuser'), + }), + ); + }); + + it('should allow with Administrator permission', async () => { + getMemories.mockResolvedValue([]); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasAdmin: true, + }); + + await execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('No memories found'), + }), + ); + }); + + it('should show opted-out status for target user', async () => { + isOptedOut.mockReturnValue(true); + getMemories.mockResolvedValue([]); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('opted out'), + }), + ); + }); + + it('should show memories for target user', async () => { + getMemories.mockResolvedValue([ + { id: 'mem-1', memory: 'Loves TypeScript' }, + { id: 'mem-2', memory: 'Works remotely' }, + ]); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + + await execute(interaction); + + const content = interaction.editReply.mock.calls[0][0].content; + expect(content).toContain('Loves TypeScript'); + expect(content).toContain('Works remotely'); + expect(content).toContain('targetuser'); + }); + + it('should reply unavailable when memory system is down', async () => { + isMemoryAvailable.mockReturnValue(false); + const interaction = createMockInteraction({ + subcommand: 'view', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('unavailable'), + ephemeral: true, + }), + ); + }); + }); + + describe('/memory admin clear', () => { + it('should reject without proper permissions', async () => { + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: false, + hasAdmin: false, + }); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Manage Server'), + ephemeral: true, + }), + ); + }); + + it('should show confirmation with target username', async () => { + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_admin_clear_cancel', + update: vi.fn(), + }); + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('targetuser'), + components: expect.any(Array), + ephemeral: true, + }), + ); + }); + + it('should delete target memories on confirm', async () => { + deleteAllMemories.mockResolvedValue(true); + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_admin_clear_confirm', + update: mockUpdate, + }); + + await execute(interaction); + + expect(deleteAllMemories).toHaveBeenCalledWith('999'); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('targetuser'), + components: [], + }), + ); + }); + + it('should show error when admin clear fails', async () => { + deleteAllMemories.mockResolvedValue(false); + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_admin_clear_confirm', + update: mockUpdate, + }); + + await execute(interaction); + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Failed'), + components: [], + }), + ); + }); + + it('should cancel on cancel button', async () => { + const mockUpdate = vi.fn(); + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_admin_clear_cancel', + update: mockUpdate, + }); + + await execute(interaction); + + expect(deleteAllMemories).not.toHaveBeenCalled(); + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('cancelled'), + components: [], + }), + ); + }); + + it('should timeout after 30 seconds', async () => { + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + }); + interaction._mockResponse.awaitMessageComponent.mockRejectedValue( + new Error('Collector received no interactions before ending with reason: time'), + ); + + await execute(interaction); + + expect(deleteAllMemories).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('timed out'), + components: [], + }), + ); + }); + + it('should only allow admin user to click buttons', async () => { + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + hasManageGuild: true, + userId: 'admin123', + }); + interaction._mockResponse.awaitMessageComponent.mockResolvedValue({ + customId: 'memory_admin_clear_cancel', + update: vi.fn(), + }); + + await execute(interaction); + + const awaitCall = interaction._mockResponse.awaitMessageComponent.mock.calls[0][0]; + expect(awaitCall.filter({ user: { id: 'admin123' } })).toBe(true); + expect(awaitCall.filter({ user: { id: 'other_user' } })).toBe(false); + }); + + it('should handle null memberPermissions', async () => { + const interaction = createMockInteraction({ + subcommand: 'clear', + subcommandGroup: 'admin', + targetUser: { id: '999', username: 'targetuser' }, + }); + interaction.memberPermissions = null; + + await execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Manage Server'), + ephemeral: true, + }), + ); + }); + }); }); diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index e76eefcc..cbaa1302 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -17,6 +17,11 @@ vi.mock('../../src/modules/config.js', () => ({ })), })); +// Mock optout module +vi.mock('../../src/modules/optout.js', () => ({ + isOptedOut: vi.fn(() => false), +})); + // Mock logger vi.mock('../../src/logger.js', () => ({ info: vi.fn(), @@ -42,6 +47,7 @@ import { isMemoryAvailable, searchMemories, } from '../../src/modules/memory.js'; +import { isOptedOut } from '../../src/modules/optout.js'; /** * Create a mock mem0 client with all SDK methods stubbed. @@ -72,6 +78,8 @@ describe('memory module', () => { autoExtract: true, }, }); + // Reset optout mock + isOptedOut.mockReturnValue(false); // Set up env for tests delete process.env.MEM0_API_KEY; }); @@ -627,6 +635,17 @@ describe('memory module', () => { expect(result).toBe(''); }); + it('should return empty string when user has opted out', async () => { + _setMem0Available(true); + const mockClient = createMockClient(); + _setClient(mockClient); + isOptedOut.mockReturnValue(true); + + const result = await buildMemoryContext('user123', 'testuser', 'hello'); + expect(result).toBe(''); + expect(mockClient.search).not.toHaveBeenCalled(); + }); + it('should return formatted context string with memories and relations', async () => { _setMem0Available(true); const mockClient = createMockClient({ @@ -715,6 +734,17 @@ describe('memory module', () => { expect(result).toBe(false); }); + it('should return false when user has opted out', async () => { + _setMem0Available(true); + const mockClient = createMockClient(); + _setClient(mockClient); + isOptedOut.mockReturnValue(true); + + const result = await extractAndStoreMemories('user123', 'testuser', 'hello', 'hi'); + expect(result).toBe(false); + expect(mockClient.add).not.toHaveBeenCalled(); + }); + it('should return false when autoExtract is disabled', async () => { _setMem0Available(true); const mockClient = createMockClient(); diff --git a/tests/modules/optout.test.js b/tests/modules/optout.test.js new file mode 100644 index 00000000..7e484e2d --- /dev/null +++ b/tests/modules/optout.test.js @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock node:fs +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => '[]'), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + _resetOptouts, + _setOptoutPath, + isOptedOut, + loadOptOuts, + saveOptOuts, + toggleOptOut, +} from '../../src/modules/optout.js'; + +describe('optout module', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetOptouts(); + _setOptoutPath('/tmp/test-optout.json'); + }); + + afterEach(() => { + _resetOptouts(); + }); + + describe('isOptedOut', () => { + it('should return false for users who have not opted out', () => { + expect(isOptedOut('user123')).toBe(false); + }); + + it('should return true for users who have opted out', () => { + toggleOptOut('user123'); + expect(isOptedOut('user123')).toBe(true); + }); + + it('should return false for different users', () => { + toggleOptOut('user123'); + expect(isOptedOut('user456')).toBe(false); + }); + }); + + describe('toggleOptOut', () => { + it('should opt out a user who is opted in', () => { + const result = toggleOptOut('user123'); + expect(result).toEqual({ optedOut: true }); + expect(isOptedOut('user123')).toBe(true); + }); + + it('should opt in a user who is opted out', () => { + toggleOptOut('user123'); // opt out + const result = toggleOptOut('user123'); // opt back in + expect(result).toEqual({ optedOut: false }); + expect(isOptedOut('user123')).toBe(false); + }); + + it('should persist after each toggle', () => { + toggleOptOut('user123'); + expect(writeFileSync).toHaveBeenCalledTimes(1); + + toggleOptOut('user123'); + expect(writeFileSync).toHaveBeenCalledTimes(2); + }); + + it('should handle multiple users independently', () => { + toggleOptOut('user1'); + toggleOptOut('user2'); + + expect(isOptedOut('user1')).toBe(true); + expect(isOptedOut('user2')).toBe(true); + expect(isOptedOut('user3')).toBe(false); + + toggleOptOut('user1'); // opt back in + expect(isOptedOut('user1')).toBe(false); + expect(isOptedOut('user2')).toBe(true); + }); + }); + + describe('loadOptOuts', () => { + it('should handle missing file gracefully', () => { + existsSync.mockReturnValue(false); + loadOptOuts(); + expect(isOptedOut('anyone')).toBe(false); + }); + + it('should load opted-out users from file', () => { + existsSync.mockReturnValue(true); + readFileSync.mockReturnValue('["user1", "user2"]'); + + loadOptOuts(); + + expect(isOptedOut('user1')).toBe(true); + expect(isOptedOut('user2')).toBe(true); + expect(isOptedOut('user3')).toBe(false); + }); + + it('should handle corrupt JSON gracefully', () => { + existsSync.mockReturnValue(true); + readFileSync.mockReturnValue('not valid json'); + + loadOptOuts(); + + expect(isOptedOut('anyone')).toBe(false); + }); + + it('should handle non-array JSON gracefully', () => { + existsSync.mockReturnValue(true); + readFileSync.mockReturnValue('{"key": "value"}'); + + loadOptOuts(); + + expect(isOptedOut('anyone')).toBe(false); + }); + + it('should handle file read errors gracefully', () => { + existsSync.mockReturnValue(true); + readFileSync.mockImplementation(() => { + throw new Error('EACCES'); + }); + + loadOptOuts(); + + expect(isOptedOut('anyone')).toBe(false); + }); + + it('should handle empty array', () => { + existsSync.mockReturnValue(true); + readFileSync.mockReturnValue('[]'); + + loadOptOuts(); + + expect(isOptedOut('anyone')).toBe(false); + }); + }); + + describe('saveOptOuts', () => { + it('should write opted-out users to file', () => { + toggleOptOut('user1'); + toggleOptOut('user2'); + + // writeFileSync was already called by toggleOptOut, check last call + const lastCall = writeFileSync.mock.calls[writeFileSync.mock.calls.length - 1]; + const saved = JSON.parse(lastCall[1]); + expect(saved).toContain('user1'); + expect(saved).toContain('user2'); + }); + + it('should create directory if it does not exist', () => { + existsSync.mockReturnValue(false); + saveOptOuts(); + expect(mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + }); + + it('should handle write errors gracefully', () => { + existsSync.mockReturnValue(true); + writeFileSync.mockImplementation(() => { + throw new Error('ENOSPC'); + }); + + // Should not throw + expect(() => saveOptOuts()).not.toThrow(); + }); + + it('should write empty array when no users opted out', () => { + existsSync.mockReturnValue(true); + saveOptOuts(); + + const lastCall = writeFileSync.mock.calls[writeFileSync.mock.calls.length - 1]; + expect(JSON.parse(lastCall[1])).toEqual([]); + }); + }); +}); From fcf132f46c82fb0d907bfe9f5cb0ad11faa4b014 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:28:20 -0500 Subject: [PATCH 10/20] refactor: persist opt-out state to PostgreSQL instead of JSON file - Add memory_optouts table (user_id TEXT PK, created_at TIMESTAMPTZ) to db.js schema - Rewrite optout.js: remove all filesystem code, use DB pool for persistence - Keep in-memory Set for fast lookups (isOptedOut called on every message) - Best-effort DB persistence: log warning on failure, keep in-memory state - loadOptOuts now async, loads from DB on startup with graceful fallback - toggleOptOut now async, writes INSERT/DELETE to DB - Export _setPool for test injection, keep _resetOptouts for testing - Update memory.js to await async toggleOptOut - Rewrite tests to mock DB pool instead of filesystem - All 714 tests passing, lint clean --- src/commands/memory.js | 2 +- src/db.js | 8 ++ src/modules/optout.js | 113 ++++++++++--------- tests/modules/optout.test.js | 206 +++++++++++++++++++---------------- 4 files changed, 182 insertions(+), 147 deletions(-) diff --git a/src/commands/memory.js b/src/commands/memory.js index 45c5e18b..de952a47 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -125,7 +125,7 @@ export async function execute(interaction) { * @param {string} userId */ async function handleOptOut(interaction, userId) { - const { optedOut } = toggleOptOut(userId); + const { optedOut } = await toggleOptOut(userId); if (optedOut) { await interaction.reply({ diff --git a/src/db.js b/src/db.js index 77cc7512..111ed732 100644 --- a/src/db.js +++ b/src/db.js @@ -159,6 +159,14 @@ export async function initDb() { ON mod_scheduled_actions (executed, execute_at) `); + // Memory opt-out table + await pool.query(` + CREATE TABLE IF NOT EXISTS memory_optouts ( + user_id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + info('Database schema initialized'); } catch (err) { // Clean up the pool so getPool() doesn't return an unusable instance diff --git a/src/modules/optout.js b/src/modules/optout.js index ce770cd5..cdc9ea5b 100644 --- a/src/modules/optout.js +++ b/src/modules/optout.js @@ -8,28 +8,38 @@ * long-term memory features. * * State is stored in an in-memory Set for fast lookups and persisted - * to data/optout.json for durability across restarts. + * to PostgreSQL (memory_optouts table) for durability across restarts. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { getPool } from '../db.js'; import { info, warn as logWarn } from '../logger.js'; -/** Default path for the opt-out persistence file */ -const DEFAULT_OPTOUT_PATH = resolve('data/optout.json'); - /** In-memory set of opted-out user IDs */ let optedOutUsers = new Set(); -/** Current file path (can be overridden for testing) */ -let optoutFilePath = DEFAULT_OPTOUT_PATH; +/** Database pool — defaults to getPool(), can be overridden for testing */ +let pool = null; + +/** + * Get the active database pool. + * Uses injected pool if set, otherwise falls back to getPool(). + * @returns {import('pg').Pool | null} + */ +function resolvePool() { + if (pool) return pool; + try { + return getPool(); + } catch { + return null; + } +} /** - * Set the file path for opt-out persistence (for testing). - * @param {string} filePath + * Set the database pool (for testing). + * @param {import('pg').Pool | null} mockPool */ -export function _setOptoutPath(filePath) { - optoutFilePath = filePath; +export function _setPool(mockPool) { + pool = mockPool; } /** @@ -37,7 +47,7 @@ export function _setOptoutPath(filePath) { */ export function _resetOptouts() { optedOutUsers = new Set(); - optoutFilePath = DEFAULT_OPTOUT_PATH; + pool = null; } /** @@ -52,64 +62,63 @@ export function isOptedOut(userId) { /** * Toggle the opt-out state for a user. * If opted out, opts them back in. If opted in, opts them out. - * Persists the change to disk. + * Persists the change to the database (best-effort). * @param {string} userId - Discord user ID - * @returns {{ optedOut: boolean }} The new opt-out state + * @returns {Promise<{ optedOut: boolean }>} The new opt-out state */ -export function toggleOptOut(userId) { +export async function toggleOptOut(userId) { + const db = resolvePool(); + if (optedOutUsers.has(userId)) { optedOutUsers.delete(userId); info('User opted back in to memory', { userId }); - saveOptOuts(); + + if (db) { + try { + await db.query('DELETE FROM memory_optouts WHERE user_id = $1', [userId]); + } catch (err) { + logWarn('Failed to delete opt-out from database', { userId, error: err.message }); + } + } + return { optedOut: false }; } optedOutUsers.add(userId); info('User opted out of memory', { userId }); - saveOptOuts(); + + if (db) { + try { + await db.query( + 'INSERT INTO memory_optouts (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING', + [userId], + ); + } catch (err) { + logWarn('Failed to persist opt-out to database', { userId, error: err.message }); + } + } + return { optedOut: true }; } /** - * Load opt-out state from the persistence file. - * Handles missing or corrupt files gracefully. + * Load opt-out state from the database. + * Handles unavailable database gracefully. */ -export function loadOptOuts() { - try { - if (!existsSync(optoutFilePath)) { - info('No opt-out file found, starting with empty set', { path: optoutFilePath }); - return; - } - - const raw = readFileSync(optoutFilePath, 'utf-8'); - const data = JSON.parse(raw); +export async function loadOptOuts() { + const db = resolvePool(); - if (Array.isArray(data)) { - optedOutUsers = new Set(data); - info('Loaded opt-out list', { count: optedOutUsers.size, path: optoutFilePath }); - } else { - logWarn('Invalid opt-out file format, expected array', { path: optoutFilePath }); - optedOutUsers = new Set(); - } - } catch (err) { - logWarn('Failed to load opt-out file', { path: optoutFilePath, error: err.message }); - optedOutUsers = new Set(); + if (!db) { + logWarn('Database not available, starting with empty opt-out set'); + return; } -} -/** - * Save the current opt-out state to the persistence file. - */ -export function saveOptOuts() { try { - const dir = dirname(optoutFilePath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const data = JSON.stringify([...optedOutUsers], null, 2); - writeFileSync(optoutFilePath, data, 'utf-8'); + const result = await db.query('SELECT user_id FROM memory_optouts'); + optedOutUsers = new Set(result.rows.map((row) => row.user_id)); + info('Loaded opt-out list from database', { count: optedOutUsers.size }); } catch (err) { - logWarn('Failed to save opt-out file', { path: optoutFilePath, error: err.message }); + logWarn('Failed to load opt-outs from database', { error: err.message }); + optedOutUsers = new Set(); } } diff --git a/tests/modules/optout.test.js b/tests/modules/optout.test.js index 7e484e2d..2c94d526 100644 --- a/tests/modules/optout.test.js +++ b/tests/modules/optout.test.js @@ -1,13 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock node:fs -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => '[]'), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), -})); - // Mock logger vi.mock('../../src/logger.js', () => ({ info: vi.fn(), @@ -16,21 +8,36 @@ vi.mock('../../src/logger.js', () => ({ debug: vi.fn(), })); -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +// Mock db module +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(() => { + throw new Error('Database not initialized'); + }), +})); + +import { getPool } from '../../src/db.js'; +import { warn } from '../../src/logger.js'; import { _resetOptouts, - _setOptoutPath, + _setPool, isOptedOut, loadOptOuts, - saveOptOuts, toggleOptOut, } from '../../src/modules/optout.js'; +/** Helper to create a mock pool */ +function createMockPool() { + return { query: vi.fn() }; +} + describe('optout module', () => { + let mockPool; + beforeEach(() => { vi.clearAllMocks(); _resetOptouts(); - _setOptoutPath('/tmp/test-optout.json'); + mockPool = createMockPool(); + _setPool(mockPool); }); afterEach(() => { @@ -42,144 +49,155 @@ describe('optout module', () => { expect(isOptedOut('user123')).toBe(false); }); - it('should return true for users who have opted out', () => { - toggleOptOut('user123'); + it('should return true for users who have opted out', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + await toggleOptOut('user123'); expect(isOptedOut('user123')).toBe(true); }); - it('should return false for different users', () => { - toggleOptOut('user123'); + it('should return false for different users', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + await toggleOptOut('user123'); expect(isOptedOut('user456')).toBe(false); }); }); describe('toggleOptOut', () => { - it('should opt out a user who is opted in', () => { - const result = toggleOptOut('user123'); + it('should opt out a user who is opted in', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + const result = await toggleOptOut('user123'); expect(result).toEqual({ optedOut: true }); expect(isOptedOut('user123')).toBe(true); + expect(mockPool.query).toHaveBeenCalledWith( + 'INSERT INTO memory_optouts (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING', + ['user123'], + ); }); - it('should opt in a user who is opted out', () => { - toggleOptOut('user123'); // opt out - const result = toggleOptOut('user123'); // opt back in + it('should opt in a user who is opted out', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + await toggleOptOut('user123'); // opt out + const result = await toggleOptOut('user123'); // opt back in expect(result).toEqual({ optedOut: false }); expect(isOptedOut('user123')).toBe(false); + expect(mockPool.query).toHaveBeenCalledWith('DELETE FROM memory_optouts WHERE user_id = $1', [ + 'user123', + ]); }); - it('should persist after each toggle', () => { - toggleOptOut('user123'); - expect(writeFileSync).toHaveBeenCalledTimes(1); + it('should persist to database on each toggle', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + await toggleOptOut('user123'); + expect(mockPool.query).toHaveBeenCalledTimes(1); - toggleOptOut('user123'); - expect(writeFileSync).toHaveBeenCalledTimes(2); + await toggleOptOut('user123'); + expect(mockPool.query).toHaveBeenCalledTimes(2); }); - it('should handle multiple users independently', () => { - toggleOptOut('user1'); - toggleOptOut('user2'); + it('should handle multiple users independently', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + await toggleOptOut('user1'); + await toggleOptOut('user2'); expect(isOptedOut('user1')).toBe(true); expect(isOptedOut('user2')).toBe(true); expect(isOptedOut('user3')).toBe(false); - toggleOptOut('user1'); // opt back in + await toggleOptOut('user1'); // opt back in expect(isOptedOut('user1')).toBe(false); expect(isOptedOut('user2')).toBe(true); }); - }); - describe('loadOptOuts', () => { - it('should handle missing file gracefully', () => { - existsSync.mockReturnValue(false); - loadOptOuts(); - expect(isOptedOut('anyone')).toBe(false); + it('should keep in-memory state when DB insert fails', async () => { + mockPool.query.mockRejectedValue(new Error('connection refused')); + const result = await toggleOptOut('user123'); + expect(result).toEqual({ optedOut: true }); + expect(isOptedOut('user123')).toBe(true); + expect(warn).toHaveBeenCalledWith( + 'Failed to persist opt-out to database', + expect.objectContaining({ userId: 'user123' }), + ); }); - it('should load opted-out users from file', () => { - existsSync.mockReturnValue(true); - readFileSync.mockReturnValue('["user1", "user2"]'); - - loadOptOuts(); + it('should keep in-memory state when DB delete fails', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); // insert succeeds + await toggleOptOut('user123'); // opt out - expect(isOptedOut('user1')).toBe(true); - expect(isOptedOut('user2')).toBe(true); - expect(isOptedOut('user3')).toBe(false); + mockPool.query.mockRejectedValueOnce(new Error('connection refused')); + const result = await toggleOptOut('user123'); // opt back in + expect(result).toEqual({ optedOut: false }); + expect(isOptedOut('user123')).toBe(false); + expect(warn).toHaveBeenCalledWith( + 'Failed to delete opt-out from database', + expect.objectContaining({ userId: 'user123' }), + ); }); - it('should handle corrupt JSON gracefully', () => { - existsSync.mockReturnValue(true); - readFileSync.mockReturnValue('not valid json'); - - loadOptOuts(); - - expect(isOptedOut('anyone')).toBe(false); + it('should work without a pool (no DB available)', async () => { + _setPool(null); + // getPool already mocked to throw + const result = await toggleOptOut('user123'); + expect(result).toEqual({ optedOut: true }); + expect(isOptedOut('user123')).toBe(true); }); + }); - it('should handle non-array JSON gracefully', () => { - existsSync.mockReturnValue(true); - readFileSync.mockReturnValue('{"key": "value"}'); + describe('loadOptOuts', () => { + it('should load opted-out users from database', async () => { + mockPool.query.mockResolvedValue({ + rows: [{ user_id: 'user1' }, { user_id: 'user2' }], + }); - loadOptOuts(); + await loadOptOuts(); - expect(isOptedOut('anyone')).toBe(false); + expect(isOptedOut('user1')).toBe(true); + expect(isOptedOut('user2')).toBe(true); + expect(isOptedOut('user3')).toBe(false); + expect(mockPool.query).toHaveBeenCalledWith('SELECT user_id FROM memory_optouts'); }); - it('should handle file read errors gracefully', () => { - existsSync.mockReturnValue(true); - readFileSync.mockImplementation(() => { - throw new Error('EACCES'); - }); + it('should handle empty result set', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); - loadOptOuts(); + await loadOptOuts(); expect(isOptedOut('anyone')).toBe(false); }); - it('should handle empty array', () => { - existsSync.mockReturnValue(true); - readFileSync.mockReturnValue('[]'); + it('should handle database query failure gracefully', async () => { + mockPool.query.mockRejectedValue(new Error('relation does not exist')); - loadOptOuts(); + await loadOptOuts(); expect(isOptedOut('anyone')).toBe(false); + expect(warn).toHaveBeenCalledWith( + 'Failed to load opt-outs from database', + expect.objectContaining({ error: 'relation does not exist' }), + ); }); - }); - describe('saveOptOuts', () => { - it('should write opted-out users to file', () => { - toggleOptOut('user1'); - toggleOptOut('user2'); + it('should handle no pool available gracefully', async () => { + _setPool(null); + // getPool already mocked to throw - // writeFileSync was already called by toggleOptOut, check last call - const lastCall = writeFileSync.mock.calls[writeFileSync.mock.calls.length - 1]; - const saved = JSON.parse(lastCall[1]); - expect(saved).toContain('user1'); - expect(saved).toContain('user2'); - }); + await loadOptOuts(); - it('should create directory if it does not exist', () => { - existsSync.mockReturnValue(false); - saveOptOuts(); - expect(mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + expect(isOptedOut('anyone')).toBe(false); + expect(warn).toHaveBeenCalledWith('Database not available, starting with empty opt-out set'); }); - it('should handle write errors gracefully', () => { - existsSync.mockReturnValue(true); - writeFileSync.mockImplementation(() => { - throw new Error('ENOSPC'); + it('should fall back to getPool when no injected pool', async () => { + _setPool(null); + const fallbackPool = createMockPool(); + fallbackPool.query.mockResolvedValue({ + rows: [{ user_id: 'user1' }], }); + getPool.mockReturnValue(fallbackPool); - // Should not throw - expect(() => saveOptOuts()).not.toThrow(); - }); - - it('should write empty array when no users opted out', () => { - existsSync.mockReturnValue(true); - saveOptOuts(); + await loadOptOuts(); - const lastCall = writeFileSync.mock.calls[writeFileSync.mock.calls.length - 1]; - expect(JSON.parse(lastCall[1])).toEqual([]); + expect(isOptedOut('user1')).toBe(true); + expect(getPool).toHaveBeenCalled(); }); }); }); From 9638b44402fbf6e0b4f0b6bfc7949e393b422356 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:46:04 -0500 Subject: [PATCH 11/20] fix: call loadOptOuts() on startup to restore opt-out state from DB Opt-out preferences were never loaded from the database on startup, causing all users to appear opted-in after a restart. Now loadOptOuts() is awaited during the startup sequence before the mem0 health check. --- src/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.js b/src/index.js index 981220ce..551bbea5 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,7 @@ import { import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { checkMem0Health } from './modules/memory.js'; +import { loadOptOuts } from './modules/optout.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; @@ -289,6 +290,9 @@ async function startup() { // Start periodic conversation cleanup startConversationCleanup(); + // Load opt-out preferences from DB before enabling memory features + await loadOptOuts(); + // Check mem0 availability for user memory features await checkMem0Health(); From 8a9deb9cdbd29d4fdcda69125dd1c8ef846dfb53 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:46:13 -0500 Subject: [PATCH 12/20] fix: default memory to disabled when config fails to load Previously the catch fallback in getMemoryConfig() defaulted to enabled: true, meaning a config failure would silently enable memory collection. Now defaults to enabled: false and autoExtract: false so memory is safely disabled if config is unavailable. --- src/modules/memory.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index 27fd4304..e5cf6290 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -94,9 +94,9 @@ export function getMemoryConfig() { }; } catch { return { - enabled: true, + enabled: false, maxContextMemories: DEFAULT_MAX_CONTEXT_MEMORIES, - autoExtract: true, + autoExtract: false, }; } } From 3ca854d9147b432e95b3146c92b128e4e7c53001 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:46:23 -0500 Subject: [PATCH 13/20] fix: add markUnavailable() in delete function error handlers deleteAllMemories and deleteMemory were missing markUnavailable() calls in their catch blocks, inconsistent with all other API methods (addMemory, searchMemories, getMemories, extractAndStoreMemories). Now transient failures in delete operations trigger the same cooldown/recovery mechanism. --- src/modules/memory.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/memory.js b/src/modules/memory.js index e5cf6290..f0f3d481 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -326,6 +326,7 @@ export async function deleteAllMemories(userId) { return true; } catch (err) { logWarn('Failed to delete all memories', { userId, error: err.message }); + markUnavailable(); return false; } } @@ -347,6 +348,7 @@ export async function deleteMemory(memoryId) { return true; } catch (err) { logWarn('Failed to delete memory', { memoryId, error: err.message }); + markUnavailable(); return false; } } From e612cc2649404a657192e264b983be25b86b11e6 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:46:47 -0500 Subject: [PATCH 14/20] refactor: extract shared truncation logic into formatMemoryList helper handleView and handleAdminView had identical truncation logic for fitting memory lists within Discord's 2000-char limit. Extracted into a shared formatMemoryList() helper to reduce duplication. --- src/commands/memory.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/commands/memory.js b/src/commands/memory.js index de952a47..e3d1b5a2 100644 --- a/src/commands/memory.js +++ b/src/commands/memory.js @@ -34,6 +34,21 @@ import { import { isOptedOut, toggleOptOut } from '../modules/optout.js'; import { splitMessage } from '../utils/splitMessage.js'; +/** + * Truncate a memory list to fit within Discord's 2000-char limit. + * @param {string} memoryList - Numbered memory lines joined by newlines + * @param {string} header - Header text prepended to the output + * @returns {string} Final message content, truncated if necessary + */ +function formatMemoryList(memoryList, header) { + const truncationNotice = '\n\n*(...and more)*'; + const maxBodyLength = 2000 - header.length - truncationNotice.length; + + const chunks = splitMessage(memoryList, maxBodyLength); + const isTruncated = chunks.length > 1; + return isTruncated ? `${header}${chunks[0]}${truncationNotice}` : `${header}${memoryList}`; +} + export const data = new SlashCommandBuilder() .setName('memory') .setDescription('Manage what the bot remembers about you (stored externally)') @@ -164,17 +179,8 @@ async function handleView(interaction, userId, username) { } const memoryList = memories.map((m, i) => `${i + 1}. ${m.memory}`).join('\n'); - const header = `🧠 **What I remember about ${username}:**\n\n`; - const truncationNotice = '\n\n*(...and more)*'; - const maxBodyLength = 2000 - header.length - truncationNotice.length; - - // Use splitMessage to safely split on word boundaries (handles multi-byte chars) - const chunks = splitMessage(memoryList, maxBodyLength); - const isTruncated = chunks.length > 1; - const content = isTruncated - ? `${header}${chunks[0]}${truncationNotice}` - : `${header}${memoryList}`; + const content = formatMemoryList(memoryList, header); await interaction.editReply({ content }); @@ -343,16 +349,8 @@ async function handleAdminView(interaction, targetId, targetUsername) { } const memoryList = memories.map((m, i) => `${i + 1}. ${m.memory}`).join('\n'); - const header = `🧠 **Memories for ${targetUsername}${optedOutStatus}:**\n\n`; - const truncationNotice = '\n\n*(...and more)*'; - const maxBodyLength = 2000 - header.length - truncationNotice.length; - - const chunks = splitMessage(memoryList, maxBodyLength); - const isTruncated = chunks.length > 1; - const content = isTruncated - ? `${header}${chunks[0]}${truncationNotice}` - : `${header}${memoryList}`; + const content = formatMemoryList(memoryList, header); await interaction.editReply({ content }); From d5c8a4c5b3f6b03d92d9c53f6dd98d10e6d9a2ea Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:46:57 -0500 Subject: [PATCH 15/20] fix: rename test and update expected fallback config values Test 'should return defaults when getConfig throws' renamed to 'should return safe disabled fallback when getConfig throws' to match the new behavior where config failure disables memory. Updated assertions to expect enabled: false and autoExtract: false. --- tests/modules/memory.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index cbaa1302..251078be 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -105,13 +105,14 @@ describe('memory module', () => { expect(config.autoExtract).toBe(true); }); - it('should return defaults when getConfig throws', () => { + it('should return safe disabled fallback when getConfig throws', () => { getConfig.mockImplementation(() => { throw new Error('not loaded'); }); const config = getMemoryConfig(); - expect(config.enabled).toBe(true); + expect(config.enabled).toBe(false); expect(config.maxContextMemories).toBe(5); + expect(config.autoExtract).toBe(false); }); it('should respect custom config values', () => { From 4c2f2979bb088fa7264ac5279be2a8e4fb9cfd49 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 20:47:12 -0500 Subject: [PATCH 16/20] fix: properly await addMemory promise in auto-recovery test The auto-recovery test had an unawaited .then() chain which meant assertions inside the callback could silently pass or fail without affecting the test result. Refactored to use async/await with a simpler, more reliable test structure. --- src/index.js | 2 +- tests/modules/memory.test.js | 40 +++++++++++------------------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/src/index.js b/src/index.js index 551bbea5..d5a4cc77 100644 --- a/src/index.js +++ b/src/index.js @@ -29,8 +29,8 @@ import { import { loadConfig } from './modules/config.js'; import { registerEventHandlers } from './modules/events.js'; import { checkMem0Health } from './modules/memory.js'; -import { loadOptOuts } from './modules/optout.js'; import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js'; +import { loadOptOuts } from './modules/optout.js'; import { HealthMonitor } from './utils/health.js'; import { loadCommandsFromDirectory } from './utils/loadCommands.js'; import { getPermissionError, hasPermission } from './utils/permissions.js'; diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 251078be..702d0cac 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -147,44 +147,26 @@ describe('memory module', () => { expect(isMemoryAvailable()).toBe(false); }); - it('should auto-recover after cooldown period expires', () => { - _setMem0Available(true); - const mockClient = createMockClient({ - add: vi.fn().mockRejectedValue(new Error('transient')), - }); - _setClient(mockClient); - - // Simulate a transient failure by calling addMemory (which will markUnavailable) - // Instead, manually trigger the unavailable state with a past timestamp - _setMem0Available(false); - - // Immediately after marking unavailable, should still be false - expect(isMemoryAvailable()).toBe(false); - - // Simulate the unavailable timestamp being in the past by using vi.useFakeTimers + it('should auto-recover after cooldown period expires', async () => { vi.useFakeTimers(); _setMem0Available(true); - // Now mark unavailable again - this time we can control time - // We need to trigger markUnavailable through an API call const failingClient = createMockClient({ add: vi.fn().mockRejectedValue(new Error('API error')), }); _setClient(failingClient); - _setMem0Available(true); // This will fail and call markUnavailable() - addMemory('user123', 'test').then(() => { - expect(isMemoryAvailable()).toBe(false); + await addMemory('user123', 'test'); + expect(isMemoryAvailable()).toBe(false); - // Advance time past the cooldown - vi.advanceTimersByTime(_getRecoveryCooldownMs()); + // Advance time past the cooldown + vi.advanceTimersByTime(_getRecoveryCooldownMs()); - // Should now auto-recover - expect(isMemoryAvailable()).toBe(true); + // Should now auto-recover + expect(isMemoryAvailable()).toBe(true); - vi.useRealTimers(); - }); + vi.useRealTimers(); }); it('should not auto-recover before cooldown expires', async () => { @@ -541,7 +523,7 @@ describe('memory module', () => { }); }); - it('should return false on SDK error', async () => { + it('should return false on SDK error and mark unavailable', async () => { _setMem0Available(true); const mockClient = createMockClient({ deleteAll: vi.fn().mockRejectedValue(new Error('API error')), @@ -550,6 +532,7 @@ describe('memory module', () => { const result = await deleteAllMemories('user123'); expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); }); it('should return false when client is null', async () => { @@ -578,7 +561,7 @@ describe('memory module', () => { expect(mockClient.delete).toHaveBeenCalledWith('mem-42'); }); - it('should return false on SDK error', async () => { + it('should return false on SDK error and mark unavailable', async () => { _setMem0Available(true); const mockClient = createMockClient({ delete: vi.fn().mockRejectedValue(new Error('Not found')), @@ -587,6 +570,7 @@ describe('memory module', () => { const result = await deleteMemory('nonexistent'); expect(result).toBe(false); + expect(isMemoryAvailable()).toBe(false); }); it('should return false when client is null', async () => { From 3ee0f9634ae112823e727123488449867cb15096 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 21:08:28 -0500 Subject: [PATCH 17/20] fix: classify transient vs permanent errors in memory module Not every API error should disable the entire memory system. Added isTransientError() helper that distinguishes: - Transient (retryable): network errors (ECONNREFUSED, ETIMEDOUT, etc.), 5xx server errors, 429 rate limits, timeout/fetch-failed messages - Permanent (disable system): 4xx client errors (401/403 auth failures), unknown errors Only permanent errors now call markUnavailable(). Transient errors log a warning and return safe defaults without disabling the system. Updated all 6 API operation catch blocks: addMemory, searchMemories, getMemories, deleteAllMemories, deleteMemory, extractAndStoreMemories. Added 8 tests covering error classification and behavior verification. --- src/modules/memory.js | 73 ++++++++++++++++++++++++--- tests/modules/memory.test.js | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index f0f3d481..6afaced2 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -33,6 +33,58 @@ const DEFAULT_MAX_CONTEXT_MEMORIES = 5; /** Cooldown period before retrying after a transient failure (ms) */ const RECOVERY_COOLDOWN_MS = 60_000; +/** HTTP status codes and error patterns for transient (retryable) errors */ +const TRANSIENT_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EAI_AGAIN', +]); + +/** + * Determine whether an error is transient (temporary network/server issue) + * or permanent (auth failure, bad request, etc.). + * + * Transient errors should NOT disable the memory system — they are expected + * to resolve on their own (network blips, server restarts, 5xx errors). + * + * Permanent errors (401, 403, 422, other 4xx) indicate configuration issues + * that won't self-resolve, so the system should be marked unavailable. + * + * @param {Error} err - The caught error + * @returns {boolean} true if the error is transient and retryable + */ +function isTransientError(err) { + // Network-level errors (no HTTP response) + if (err.code && TRANSIENT_ERROR_CODES.has(err.code)) return true; + + // HTTP status-based classification + const status = err.status || err.statusCode || err.response?.status; + if (status) { + // 5xx = server error (transient), 429 = rate limited (transient) + if (status >= 500 || status === 429) return true; + // 4xx = client error (permanent) — auth failures, bad requests + if (status >= 400 && status < 500) return false; + } + + // Common transient error message patterns + const msg = (err.message || '').toLowerCase(); + if ( + msg.includes('timeout') || + msg.includes('econnrefused') || + msg.includes('econnreset') || + msg.includes('network') || + msg.includes('socket hang up') || + msg.includes('fetch failed') + ) { + return true; + } + + // Default: treat unknown errors as permanent (safer — triggers markUnavailable) + return false; +} + /** Tracks whether mem0 is reachable (set by health check, cleared on errors) */ let mem0Available = false; @@ -145,6 +197,15 @@ export function _getRecoveryCooldownMs() { return RECOVERY_COOLDOWN_MS; } +/** + * Expose isTransientError for testing (prefixed with _ to indicate internal) + * @param {Error} err + * @returns {boolean} + */ +export function _isTransientError(err) { + return isTransientError(err); +} + /** * Set the mem0 client instance (for testing) * @param {object|null} newClient @@ -230,7 +291,7 @@ export async function addMemory(userId, text, metadata = {}) { return true; } catch (err) { logWarn('Failed to add memory', { userId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return false; } } @@ -273,7 +334,7 @@ export async function searchMemories(userId, query, limit) { return { memories, relations }; } catch (err) { logWarn('Failed to search memories', { userId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return { memories: [], relations: [] }; } } @@ -304,7 +365,7 @@ export async function getMemories(userId) { })); } catch (err) { logWarn('Failed to get memories', { userId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return []; } } @@ -326,7 +387,7 @@ export async function deleteAllMemories(userId) { return true; } catch (err) { logWarn('Failed to delete all memories', { userId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return false; } } @@ -348,7 +409,7 @@ export async function deleteMemory(memoryId) { return true; } catch (err) { logWarn('Failed to delete memory', { memoryId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return false; } } @@ -437,7 +498,7 @@ export async function extractAndStoreMemories(userId, username, userMessage, ass return true; } catch (err) { logWarn('Memory extraction failed', { userId, error: err.message }); - markUnavailable(); + if (!isTransientError(err)) markUnavailable(); return false; } } diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 702d0cac..4fbb3550 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -33,6 +33,7 @@ vi.mock('../../src/logger.js', () => ({ import { getConfig } from '../../src/modules/config.js'; import { _getRecoveryCooldownMs, + _isTransientError, _setClient, _setMem0Available, addMemory, @@ -217,6 +218,101 @@ describe('memory module', () => { }); }); + describe('error classification', () => { + it('should treat network errors as transient', () => { + const econnrefused = new Error('connect ECONNREFUSED'); + econnrefused.code = 'ECONNREFUSED'; + expect(_isTransientError(econnrefused)).toBe(true); + + const etimedout = new Error('connect ETIMEDOUT'); + etimedout.code = 'ETIMEDOUT'; + expect(_isTransientError(etimedout)).toBe(true); + + const econnreset = new Error('socket hang up'); + econnreset.code = 'ECONNRESET'; + expect(_isTransientError(econnreset)).toBe(true); + }); + + it('should treat 5xx status codes as transient', () => { + const err500 = new Error('Internal Server Error'); + err500.status = 500; + expect(_isTransientError(err500)).toBe(true); + + const err503 = new Error('Service Unavailable'); + err503.status = 503; + expect(_isTransientError(err503)).toBe(true); + }); + + it('should treat 429 rate-limit errors as transient', () => { + const err429 = new Error('Too Many Requests'); + err429.status = 429; + expect(_isTransientError(err429)).toBe(true); + }); + + it('should treat 4xx auth/client errors as permanent', () => { + const err401 = new Error('Unauthorized'); + err401.status = 401; + expect(_isTransientError(err401)).toBe(false); + + const err403 = new Error('Forbidden'); + err403.status = 403; + expect(_isTransientError(err403)).toBe(false); + + const err422 = new Error('Unprocessable Entity'); + err422.status = 422; + expect(_isTransientError(err422)).toBe(false); + }); + + it('should treat timeout message patterns as transient', () => { + expect(_isTransientError(new Error('request timeout'))).toBe(true); + expect(_isTransientError(new Error('fetch failed'))).toBe(true); + expect(_isTransientError(new Error('network error'))).toBe(true); + }); + + it('should treat unknown errors as permanent', () => { + expect(_isTransientError(new Error('API error'))).toBe(false); + expect(_isTransientError(new Error('something unexpected'))).toBe(false); + }); + + it('should not mark unavailable on transient errors', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + add: vi.fn().mockRejectedValue( + (() => { + const e = new Error('Service Unavailable'); + e.status = 503; + return e; + })(), + ), + }); + _setClient(mockClient); + + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); + // Should still be available — transient error + expect(isMemoryAvailable()).toBe(true); + }); + + it('should mark unavailable on permanent errors', async () => { + _setMem0Available(true); + const mockClient = createMockClient({ + add: vi.fn().mockRejectedValue( + (() => { + const e = new Error('Unauthorized'); + e.status = 401; + return e; + })(), + ), + }); + _setClient(mockClient); + + const result = await addMemory('user123', 'test'); + expect(result).toBe(false); + // Should be unavailable — auth error + expect(isMemoryAvailable()).toBe(false); + }); + }); + describe('checkMem0Health', () => { it('should mark as available when API key is set and SDK connectivity verified', async () => { process.env.MEM0_API_KEY = 'test-api-key'; From c60d7e487b2c632630597a053dcea4f27739a9ee Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 21:08:41 -0500 Subject: [PATCH 18/20] docs: add JSDoc explaining asymmetric _setMem0Available behavior Explains why setting false does a hard disable without cooldown (test helper for instant state toggling) while setting true calls markAvailable() to clear cooldown. Production code uses markUnavailable() which enables timed auto-recovery. --- src/modules/memory.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/modules/memory.js b/src/modules/memory.js index 6afaced2..3e5f49bf 100644 --- a/src/modules/memory.js +++ b/src/modules/memory.js @@ -177,7 +177,20 @@ export function isMemoryAvailable() { } /** - * Set the mem0 availability flag (for testing / health checks) + * Set the mem0 availability flag (for testing / health checks). + * + * **Asymmetric behavior by design:** + * - Setting `true` calls {@link markAvailable}, clearing any cooldown state. + * - Setting `false` performs a **hard disable** — sets mem0Available to false + * and resets the cooldown timestamp to 0 — but does NOT trigger the recovery + * cooldown (unlike {@link markUnavailable} which records a timestamp so + * auto-recovery can kick in after RECOVERY_COOLDOWN_MS). + * + * This is intentional: _setMem0Available is a test/health-check helper that + * needs to instantly toggle state without side effects from cooldown timers. + * Production error paths use markUnavailable() instead, which enables the + * timed auto-recovery flow. + * * @param {boolean} available */ export function _setMem0Available(available) { From 354823562b46946bf6f70f8d6e9635ca7839e30f Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 21:09:23 -0500 Subject: [PATCH 19/20] fix: move vi.useRealTimers() to afterEach to prevent timer leaks Previously, vi.useRealTimers() was called at the end of each test using fake timers. If a test failed mid-way, the call would be skipped and fake timers would leak into subsequent tests. Moved to an afterEach block in the isMemoryAvailable describe block so it always runs. --- tests/modules/memory.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index 4fbb3550..b77f146e 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -132,6 +132,11 @@ describe('memory module', () => { }); describe('isMemoryAvailable', () => { + afterEach(() => { + // Ensure fake timers never leak into other tests, even if a test fails mid-way + vi.useRealTimers(); + }); + it('should return false when mem0 is not available', () => { _setMem0Available(false); expect(isMemoryAvailable()).toBe(false); @@ -166,8 +171,6 @@ describe('memory module', () => { // Should now auto-recover expect(isMemoryAvailable()).toBe(true); - - vi.useRealTimers(); }); it('should not auto-recover before cooldown expires', async () => { @@ -189,8 +192,6 @@ describe('memory module', () => { // Now advance past the cooldown vi.advanceTimersByTime(1000); expect(isMemoryAvailable()).toBe(true); - - vi.useRealTimers(); }); it('should re-disable if recovery attempt also fails', async () => { @@ -213,8 +214,6 @@ describe('memory module', () => { // But the next operation also fails await searchMemories('user123', 'test'); expect(isMemoryAvailable()).toBe(false); - - vi.useRealTimers(); }); }); From b2751b2e0a83fa535bd9d11e16a3b3fc2e88a02c Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 15 Feb 2026 21:09:46 -0500 Subject: [PATCH 20/20] fix: use explicit mock for health check connectivity test Replaced fragile test that relied on the auto-created mock client lacking a search method. Now explicitly creates a mock client with a search method that throws ECONNREFUSED, clearly testing the scenario where a client is created but cannot reach the mem0 platform. Also asserts that search was actually called. --- tests/modules/memory.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/modules/memory.test.js b/tests/modules/memory.test.js index b77f146e..28b4b9c9 100644 --- a/tests/modules/memory.test.js +++ b/tests/modules/memory.test.js @@ -346,15 +346,19 @@ describe('memory module', () => { expect(isMemoryAvailable()).toBe(false); }); - it('should fail health check when auto-created client cannot connect', async () => { + it('should fail health check when SDK connectivity check throws', async () => { process.env.MEM0_API_KEY = 'test-api-key'; - // Don't set a client — getClient will auto-create from mocked constructor - // The auto-created client has no search method, so health check will fail - _setClient(null); + // Explicitly mock a client whose search method throws — simulates a client + // that was created successfully but cannot reach the mem0 platform + const brokenClient = createMockClient({ + search: vi.fn().mockRejectedValue(new Error('ECONNREFUSED: connect failed')), + }); + _setClient(brokenClient); const result = await checkMem0Health(); expect(result).toBe(false); expect(isMemoryAvailable()).toBe(false); + expect(brokenClient.search).toHaveBeenCalled(); }); it('should mark as unavailable when SDK connectivity check fails', async () => {