Skip to content

Commit 439104a

Browse files
authored
Merge pull request #63 from BillChirico/fix/mem0-review-fixes
2 parents 5dd743b + ab04cc6 commit 439104a

8 files changed

Lines changed: 503 additions & 65 deletions

File tree

src/commands/memory.js

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import {
2525
} from 'discord.js';
2626
import { info, warn } from '../logger.js';
2727
import {
28+
checkAndRecoverMemory,
2829
deleteAllMemories,
2930
deleteMemory,
3031
getMemories,
31-
isMemoryAvailable,
3232
searchMemories,
3333
} from '../modules/memory.js';
3434
import { isOptedOut, toggleOptOut } from '../modules/optout.js';
@@ -113,7 +113,7 @@ export async function execute(interaction) {
113113
return;
114114
}
115115

116-
if (!isMemoryAvailable()) {
116+
if (!checkAndRecoverMemory()) {
117117
await interaction.reply({
118118
content:
119119
'🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.',
@@ -261,26 +261,45 @@ async function handleForgetAll(interaction, userId, username) {
261261
async function handleForgetTopic(interaction, userId, username, topic) {
262262
await interaction.deferReply({ ephemeral: true });
263263

264-
// Search for memories matching the topic (results include IDs)
265-
const { memories: matches } = await searchMemories(userId, topic, 10);
264+
const BATCH_SIZE = 100;
265+
const MAX_ITERATIONS = 10;
266+
let totalDeleted = 0;
267+
let totalFound = 0;
268+
let iterations = 0;
266269

267-
if (matches.length === 0) {
268-
await interaction.editReply({
269-
content: `🔍 No memories found matching "${topic}".`,
270-
});
271-
return;
272-
}
270+
// Loop to delete all matching memories (not just the first batch)
271+
while (iterations < MAX_ITERATIONS) {
272+
iterations++;
273+
const { memories: matches } = await searchMemories(userId, topic, BATCH_SIZE);
274+
275+
if (matches.length === 0) break;
276+
totalFound += matches.length;
277+
278+
const matchesWithIds = matches.filter(
279+
(m) => m.id !== undefined && m.id !== null && m.id !== '',
280+
);
273281

274-
// Use memory IDs directly from search results and delete in parallel
275-
const matchesWithIds = matches.filter((m) => m.id);
276-
const results = await Promise.allSettled(matchesWithIds.map((m) => deleteMemory(m.id)));
277-
const deletedCount = results.filter((r) => r.status === 'fulfilled' && r.value === true).length;
282+
if (matchesWithIds.length === 0) break;
278283

279-
if (deletedCount > 0) {
284+
const results = await Promise.allSettled(matchesWithIds.map((m) => deleteMemory(m.id)));
285+
const batchDeleted = results.filter((r) => r.status === 'fulfilled' && r.value === true).length;
286+
totalDeleted += batchDeleted;
287+
288+
// If we got fewer results than the batch size, we've reached the end
289+
if (matches.length < BATCH_SIZE) break;
290+
// If nothing was deleted this round, stop to avoid infinite loop
291+
if (batchDeleted === 0) break;
292+
}
293+
294+
if (totalDeleted > 0) {
280295
await interaction.editReply({
281-
content: `🧹 Forgot ${deletedCount} memor${deletedCount === 1 ? 'y' : 'ies'} related to "${topic}".`,
296+
content: `🧹 Forgot ${totalDeleted} memor${totalDeleted === 1 ? 'y' : 'ies'} related to "${topic}".`,
297+
});
298+
info('Topic memories cleared', { userId, username, topic, count: totalDeleted });
299+
} else if (totalFound === 0) {
300+
await interaction.editReply({
301+
content: `🔍 No memories found matching "${topic}".`,
282302
});
283-
info('Topic memories cleared', { userId, username, topic, count: deletedCount });
284303
} else {
285304
await interaction.editReply({
286305
content: `❌ Found memories about "${topic}" but couldn't delete them. Please try again.`,
@@ -309,7 +328,7 @@ async function handleAdmin(interaction, subcommand) {
309328
return;
310329
}
311330

312-
if (!isMemoryAvailable()) {
331+
if (!checkAndRecoverMemory()) {
313332
await interaction.reply({
314333
content:
315334
'🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.',

src/index.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from './modules/ai.js';
2929
import { loadConfig } from './modules/config.js';
3030
import { registerEventHandlers } from './modules/events.js';
31-
import { checkMem0Health } from './modules/memory.js';
31+
import { checkMem0Health, markUnavailable } from './modules/memory.js';
3232
import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js';
3333
import { loadOptOuts } from './modules/optout.js';
3434
import { HealthMonitor } from './utils/health.js';
@@ -293,8 +293,26 @@ async function startup() {
293293
// Load opt-out preferences from DB before enabling memory features
294294
await loadOptOuts();
295295

296-
// Check mem0 availability for user memory features
297-
await checkMem0Health();
296+
// Check mem0 availability for user memory features (with timeout to avoid blocking startup).
297+
// AbortController prevents a late-resolving health check from calling markAvailable()
298+
// after the timeout has already called markUnavailable().
299+
const healthAbort = new AbortController();
300+
try {
301+
await Promise.race([
302+
checkMem0Health({ signal: healthAbort.signal }),
303+
new Promise((_, reject) =>
304+
setTimeout(() => {
305+
healthAbort.abort();
306+
reject(new Error('mem0 health check timed out'));
307+
}, 10_000),
308+
),
309+
]);
310+
} catch (err) {
311+
markUnavailable();
312+
warn('mem0 health check timed out or failed — continuing without memory features', {
313+
error: err.message,
314+
});
315+
}
298316

299317
// Register event handlers with live config reference
300318
registerEventHandlers(client, config, healthMonitor);

src/modules/ai.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,15 +400,20 @@ You're witty, knowledgeable about programming and tech, and always eager to help
400400
Keep responses concise and Discord-friendly (under 2000 chars).
401401
You can use Discord markdown formatting.`;
402402

403-
// Pre-response: inject user memory context into system prompt
403+
// Pre-response: inject user memory context into system prompt (with timeout)
404404
if (userId) {
405405
try {
406-
const memoryContext = await buildMemoryContext(userId, username, userMessage);
406+
const memoryContext = await Promise.race([
407+
buildMemoryContext(userId, username, userMessage),
408+
new Promise((_, reject) =>
409+
setTimeout(() => reject(new Error('Memory context timeout')), 5000),
410+
),
411+
]);
407412
if (memoryContext) {
408413
systemPrompt += memoryContext;
409414
}
410415
} catch (err) {
411-
// Memory lookup failed — continue without it
416+
// Memory lookup failed or timed out — continue without it
412417
logWarn('Memory context lookup failed', { userId, error: err.message });
413418
}
414419
}

src/modules/memory.js

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ let client = null;
9999
* After RECOVERY_COOLDOWN_MS, the next request will be allowed through
100100
* to check if the service has recovered.
101101
*/
102-
function markUnavailable() {
102+
export function markUnavailable() {
103103
mem0Available = false;
104104
mem0UnavailableSince = Date.now();
105105
}
@@ -154,15 +154,30 @@ export function getMemoryConfig() {
154154
}
155155

156156
/**
157-
* Check if memory feature is enabled and mem0 is available.
158-
* Supports auto-recovery: if mem0 was marked unavailable due to a transient
159-
* error and the cooldown period has elapsed, it will be tentatively re-enabled
160-
* so the next request can check if the service has recovered.
157+
* Pure availability check — no side effects.
158+
* Returns true only if memory is both enabled in config and currently marked available.
159+
* Does NOT trigger auto-recovery. Use {@link checkAndRecoverMemory} when you want
160+
* the cooldown-based recovery logic.
161161
* @returns {boolean}
162162
*/
163163
export function isMemoryAvailable() {
164164
const memConfig = getMemoryConfig();
165165
if (!memConfig.enabled) return false;
166+
return mem0Available;
167+
}
168+
169+
/**
170+
* Check if memory feature is enabled and mem0 is available, with auto-recovery.
171+
* If mem0 was marked unavailable due to a transient error and the cooldown period
172+
* has elapsed, this will tentatively re-enable it so the next request can check
173+
* if the service has recovered.
174+
*
175+
* Use this instead of {@link isMemoryAvailable} when you want the recovery side effect.
176+
* @returns {boolean}
177+
*/
178+
export function checkAndRecoverMemory() {
179+
const memConfig = getMemoryConfig();
180+
if (!memConfig.enabled) return false;
166181

167182
if (mem0Available) return true;
168183

@@ -231,9 +246,12 @@ export function _setClient(newClient) {
231246
* Run a health check against the mem0 platform on startup.
232247
* Verifies the API key is configured and the SDK client can actually
233248
* communicate with the hosted platform by performing a lightweight search.
249+
* @param {object} [options]
250+
* @param {AbortSignal} [options.signal] - When aborted, prevents a late-resolving
251+
* check from calling {@link markAvailable} (guards against race with startup timeout).
234252
* @returns {Promise<boolean>} true if mem0 is ready
235253
*/
236-
export async function checkMem0Health() {
254+
export async function checkMem0Health({ signal } = {}) {
237255
const memConfig = getMemoryConfig();
238256
if (!memConfig.enabled) {
239257
info('Memory module disabled via config');
@@ -262,6 +280,11 @@ export async function checkMem0Health() {
262280
limit: 1,
263281
});
264282

283+
// Guard against late resolution after a startup timeout has already
284+
// called markUnavailable(). If the caller's AbortSignal has fired,
285+
// the timeout won the race and we must not flip availability back on.
286+
if (signal?.aborted) return false;
287+
265288
markAvailable();
266289
info('mem0 health check passed (SDK connectivity verified)');
267290
return true;
@@ -286,7 +309,7 @@ export async function checkMem0Health() {
286309
* @returns {Promise<boolean>} true if stored successfully
287310
*/
288311
export async function addMemory(userId, text, metadata = {}) {
289-
if (!isMemoryAvailable()) return false;
312+
if (!checkAndRecoverMemory()) return false;
290313

291314
try {
292315
const c = getClient();
@@ -318,7 +341,7 @@ export async function addMemory(userId, text, metadata = {}) {
318341
* @returns {Promise<{memories: Array<{memory: string, score?: number}>, relations: Array}>}
319342
*/
320343
export async function searchMemories(userId, query, limit) {
321-
if (!isMemoryAvailable()) return { memories: [], relations: [] };
344+
if (!checkAndRecoverMemory()) return { memories: [], relations: [] };
322345

323346
const memConfig = getMemoryConfig();
324347
const maxResults = limit ?? memConfig.maxContextMemories;
@@ -339,7 +362,7 @@ export async function searchMemories(userId, query, limit) {
339362
const relations = result?.relations || [];
340363

341364
const memories = rawMemories.map((m) => ({
342-
id: m.id || '',
365+
id: m.id ?? '',
343366
memory: m.memory || m.text || m.content || '',
344367
score: m.score ?? null,
345368
}));
@@ -358,7 +381,7 @@ export async function searchMemories(userId, query, limit) {
358381
* @returns {Promise<Array<{id: string, memory: string}>>} All user memories
359382
*/
360383
export async function getMemories(userId) {
361-
if (!isMemoryAvailable()) return [];
384+
if (!checkAndRecoverMemory()) return [];
362385

363386
try {
364387
const c = getClient();
@@ -373,7 +396,7 @@ export async function getMemories(userId) {
373396
const memories = Array.isArray(result) ? result : result?.results || [];
374397

375398
return memories.map((m) => ({
376-
id: m.id || '',
399+
id: m.id ?? '',
377400
memory: m.memory || m.text || m.content || '',
378401
}));
379402
} catch (err) {
@@ -389,7 +412,7 @@ export async function getMemories(userId) {
389412
* @returns {Promise<boolean>} true if deleted successfully
390413
*/
391414
export async function deleteAllMemories(userId) {
392-
if (!isMemoryAvailable()) return false;
415+
if (!checkAndRecoverMemory()) return false;
393416

394417
try {
395418
const c = getClient();
@@ -411,7 +434,7 @@ export async function deleteAllMemories(userId) {
411434
* @returns {Promise<boolean>} true if deleted successfully
412435
*/
413436
export async function deleteMemory(memoryId) {
414-
if (!isMemoryAvailable()) return false;
437+
if (!checkAndRecoverMemory()) return false;
415438

416439
try {
417440
const c = getClient();
@@ -435,21 +458,29 @@ export async function deleteMemory(memoryId) {
435458
export function formatRelations(relations) {
436459
if (!relations || relations.length === 0) return '';
437460

438-
const lines = relations.map((r) => `- ${r.source}${r.relationship}${r.target}`);
461+
const lines = relations
462+
.filter((r) => r.source && r.relationship && r.target)
463+
.map((r) => `- ${r.source}${r.relationship}${r.target}`);
464+
465+
if (lines.length === 0) return '';
439466

440467
return `\nRelationships:\n${lines.join('\n')}`;
441468
}
442469

470+
/** Maximum characters for memory context injected into system prompt */
471+
const MAX_MEMORY_CONTEXT_CHARS = 2000;
472+
443473
/**
444474
* Build a context string from user memories to inject into the system prompt.
445475
* Includes both regular memories and graph relations for richer context.
476+
* Enforces a character budget to prevent oversized system prompts.
446477
* @param {string} userId - Discord user ID
447478
* @param {string} username - Display name
448479
* @param {string} query - The user's current message (for relevance search)
449480
* @returns {Promise<string>} Context string or empty string
450481
*/
451482
export async function buildMemoryContext(userId, username, query) {
452-
if (!isMemoryAvailable()) return '';
483+
if (!checkAndRecoverMemory()) return '';
453484
if (isOptedOut(userId)) return '';
454485

455486
const { memories, relations } = await searchMemories(userId, query);
@@ -468,6 +499,11 @@ export async function buildMemoryContext(userId, username, query) {
468499
context += relationsContext;
469500
}
470501

502+
// Enforce character budget to prevent oversized system prompts
503+
if (context.length > MAX_MEMORY_CONTEXT_CHARS) {
504+
context = `${context.substring(0, MAX_MEMORY_CONTEXT_CHARS)}...`;
505+
}
506+
471507
return context;
472508
}
473509

@@ -482,7 +518,7 @@ export async function buildMemoryContext(userId, username, query) {
482518
* @returns {Promise<boolean>} true if any memories were stored
483519
*/
484520
export async function extractAndStoreMemories(userId, username, userMessage, assistantReply) {
485-
if (!isMemoryAvailable()) return false;
521+
if (!checkAndRecoverMemory()) return false;
486522
if (isOptedOut(userId)) return false;
487523

488524
const memConfig = getMemoryConfig();
@@ -493,13 +529,14 @@ export async function extractAndStoreMemories(userId, username, userMessage, ass
493529
if (!c) return false;
494530

495531
const messages = [
496-
{ role: 'user', content: `${username}: ${userMessage}` },
532+
{ role: 'user', content: userMessage },
497533
{ role: 'assistant', content: assistantReply },
498534
];
499535

500536
await c.add(messages, {
501537
user_id: userId,
502538
app_id: APP_ID,
539+
metadata: { username },
503540
enable_graph: true,
504541
});
505542

@@ -510,8 +547,10 @@ export async function extractAndStoreMemories(userId, username, userMessage, ass
510547
});
511548
return true;
512549
} catch (err) {
550+
// Only log — do NOT call markUnavailable() here.
551+
// This runs fire-and-forget in the background; a failure for one user's
552+
// extraction should not disable the memory system for all other users.
513553
logWarn('Memory extraction failed', { userId, error: err.message });
514-
if (!isTransientError(err)) markUnavailable();
515554
return false;
516555
}
517556
}

0 commit comments

Comments
 (0)