Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8414262
feat: integrate mem0 for persistent user memory across conversations
BillChirico Feb 16, 2026
ed3d7d4
feat: rewrite mem0 integration to use official SDK with graph memory
BillChirico Feb 16, 2026
a6c8d61
fix: use splitMessage utility, IDs from search, and parallel deletion…
BillChirico Feb 16, 2026
6cfa0d9
fix: add auto-recovery for transient mem0 failures
BillChirico Feb 16, 2026
c121d84
fix: verify SDK connectivity in health check instead of just client c…
BillChirico Feb 16, 2026
6428dbe
docs: add privacy notice for external memory storage
BillChirico Feb 16, 2026
b86f9e1
chore: remove unused extractModel config and document addMemory publi…
BillChirico Feb 16, 2026
54c41bd
style: fix Biome formatting in memory module
BillChirico Feb 16, 2026
9b91987
feat: add memory command security features
BillChirico Feb 16, 2026
a654ce8
refactor: persist opt-out state to PostgreSQL instead of JSON file
BillChirico Feb 16, 2026
c370c90
fix: call loadOptOuts() on startup to restore opt-out state from DB
BillChirico Feb 16, 2026
70d5476
fix: default memory to disabled when config fails to load
BillChirico Feb 16, 2026
5e34d1d
fix: add markUnavailable() in delete function error handlers
BillChirico Feb 16, 2026
6e98fdf
refactor: extract shared truncation logic into formatMemoryList helper
BillChirico Feb 16, 2026
f6df973
fix: rename test and update expected fallback config values
BillChirico Feb 16, 2026
76991ec
fix: properly await addMemory promise in auto-recovery test
BillChirico Feb 16, 2026
9823981
fix: classify transient vs permanent errors in memory module
BillChirico Feb 16, 2026
e6a6b3a
docs: add JSDoc explaining asymmetric _setMem0Available behavior
BillChirico Feb 16, 2026
10ed123
fix: move vi.useRealTimers() to afterEach to prevent timer leaks
BillChirico Feb 16, 2026
ff12805
fix: use explicit mock for health check connectivity test
BillChirico Feb 16, 2026
f30c904
fix: use explicit null/undefined/empty check for memory IDs in handle…
BillChirico Feb 16, 2026
3505b51
fix: guard formatRelations against missing source/relationship/target
BillChirico Feb 16, 2026
7d5cffa
fix: add 5s timeout to buildMemoryContext in generateResponse
BillChirico Feb 16, 2026
7344ec5
fix: don't call markUnavailable from fire-and-forget extractAndStoreM…
BillChirico Feb 16, 2026
95c21a8
fix: add 10s timeout to checkMem0Health during startup
BillChirico Feb 16, 2026
9cf095d
refactor: split isMemoryAvailable into pure getter + checkAndRecoverM…
BillChirico Feb 16, 2026
23e61ab
fix: loop to delete all matching memories in /memory forget topic
BillChirico Feb 16, 2026
6c46275
fix: add 2000-char budget for memory context in system prompt
BillChirico Feb 16, 2026
a3c9abc
fix: pass username as metadata instead of baking into memory content
BillChirico Feb 16, 2026
e99ff9c
chore: fix biome formatting
BillChirico Feb 16, 2026
51016c9
fix: use nullish coalescing for memory ID mapping
BillChirico Feb 16, 2026
9a69769
fix: call markUnavailable() on health check timeout to enable auto-re…
BillChirico Feb 16, 2026
ab04cc6
fix: prevent late health check from overriding markUnavailable via Ab…
BillChirico Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 37 additions & 18 deletions src/commands/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ import {
} from 'discord.js';
import { info, warn } from '../logger.js';
import {
checkAndRecoverMemory,
deleteAllMemories,
deleteMemory,
getMemories,
isMemoryAvailable,
searchMemories,
} from '../modules/memory.js';
import { isOptedOut, toggleOptOut } from '../modules/optout.js';
Expand Down Expand Up @@ -113,7 +113,7 @@ export async function execute(interaction) {
return;
}

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

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

if (matches.length === 0) {
await interaction.editReply({
content: `🔍 No memories found matching "${topic}".`,
});
return;
}
// Loop to delete all matching memories (not just the first batch)
while (iterations < MAX_ITERATIONS) {
iterations++;
const { memories: matches } = await searchMemories(userId, topic, BATCH_SIZE);

if (matches.length === 0) break;
totalFound += matches.length;

const matchesWithIds = matches.filter(
(m) => m.id !== undefined && m.id !== null && m.id !== '',
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filter null/undefined checks are dead code after upstream coercion

Low Severity

The handleForgetTopic filter checks m.id !== undefined && m.id !== null but these conditions can never trigger because searchMemories already coerces null/undefined IDs to '' via m.id ?? ''. The effective filter is just m.id !== ''. This makes the fix for falsy ID handling (fix #1) appear more robust than it actually is — the null/undefined guards are dead code. Either searchMemories should stop coercing IDs (use m.id directly) to let the downstream filter handle all cases, or the filter should be simplified to match what searchMemories actually produces.

Additional Locations (1)

Fix in Cursor Fix in Web


// 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 (matchesWithIds.length === 0) break;

if (deletedCount > 0) {
const results = await Promise.allSettled(matchesWithIds.map((m) => deleteMemory(m.id)));
const batchDeleted = results.filter((r) => r.status === 'fulfilled' && r.value === true).length;
totalDeleted += batchDeleted;

// If we got fewer results than the batch size, we've reached the end
if (matches.length < BATCH_SIZE) break;
// If nothing was deleted this round, stop to avoid infinite loop
if (batchDeleted === 0) break;
}

if (totalDeleted > 0) {
await interaction.editReply({
content: `🧹 Forgot ${deletedCount} memor${deletedCount === 1 ? 'y' : 'ies'} related to "${topic}".`,
content: `🧹 Forgot ${totalDeleted} memor${totalDeleted === 1 ? 'y' : 'ies'} related to "${topic}".`,
});
info('Topic memories cleared', { userId, username, topic, count: totalDeleted });
} else if (totalFound === 0) {
await interaction.editReply({
content: `🔍 No memories found matching "${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.`,
Expand Down Expand Up @@ -309,7 +328,7 @@ async function handleAdmin(interaction, subcommand) {
return;
}

if (!isMemoryAvailable()) {
if (!checkAndRecoverMemory()) {
await interaction.reply({
content:
'🧠 Memory system is currently unavailable. The bot still works, just without long-term memory.',
Expand Down
24 changes: 21 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +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 { checkMem0Health, markUnavailable } from './modules/memory.js';
import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderation.js';
import { loadOptOuts } from './modules/optout.js';
import { HealthMonitor } from './utils/health.js';
Expand Down Expand Up @@ -293,8 +293,26 @@ async function startup() {
// Load opt-out preferences from DB before enabling memory features
await loadOptOuts();

// Check mem0 availability for user memory features
await checkMem0Health();
// Check mem0 availability for user memory features (with timeout to avoid blocking startup).
// AbortController prevents a late-resolving health check from calling markAvailable()
// after the timeout has already called markUnavailable().
const healthAbort = new AbortController();
try {
await Promise.race([
checkMem0Health({ signal: healthAbort.signal }),
new Promise((_, reject) =>
setTimeout(() => {
healthAbort.abort();
reject(new Error('mem0 health check timed out'));
}, 10_000),
),
]);
} catch (err) {
markUnavailable();
warn('mem0 health check timed out or failed — continuing without memory features', {
error: err.message,
});
}
Comment thread
BillChirico marked this conversation as resolved.
Comment thread
BillChirico marked this conversation as resolved.

// Register event handlers with live config reference
registerEventHandlers(client, config, healthMonitor);
Expand Down
11 changes: 8 additions & 3 deletions src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,15 +400,20 @@ 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
// Pre-response: inject user memory context into system prompt (with timeout)
if (userId) {
try {
const memoryContext = await buildMemoryContext(userId, username, userMessage);
const memoryContext = await Promise.race([
buildMemoryContext(userId, username, userMessage),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Memory context timeout')), 5000),
),
]);
if (memoryContext) {
systemPrompt += memoryContext;
}
} catch (err) {
// Memory lookup failed — continue without it
// Memory lookup failed or timed out — continue without it
logWarn('Memory context lookup failed', { userId, error: err.message });
}
}
Expand Down
75 changes: 57 additions & 18 deletions src/modules/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ let client = null;
* After RECOVERY_COOLDOWN_MS, the next request will be allowed through
* to check if the service has recovered.
*/
function markUnavailable() {
export function markUnavailable() {
mem0Available = false;
mem0UnavailableSince = Date.now();
}
Expand Down Expand Up @@ -154,15 +154,30 @@ export function getMemoryConfig() {
}

/**
* 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.
* Pure availability check — no side effects.
* Returns true only if memory is both enabled in config and currently marked available.
* Does NOT trigger auto-recovery. Use {@link checkAndRecoverMemory} when you want
* the cooldown-based recovery logic.
* @returns {boolean}
*/
export function isMemoryAvailable() {
const memConfig = getMemoryConfig();
if (!memConfig.enabled) return false;
return mem0Available;
}

/**
* Check if memory feature is enabled and mem0 is available, with auto-recovery.
* If mem0 was marked unavailable due to a transient error and the cooldown period
* has elapsed, this will tentatively re-enable it so the next request can check
* if the service has recovered.
*
* Use this instead of {@link isMemoryAvailable} when you want the recovery side effect.
* @returns {boolean}
*/
export function checkAndRecoverMemory() {
const memConfig = getMemoryConfig();
if (!memConfig.enabled) return false;

if (mem0Available) return true;

Expand Down Expand Up @@ -231,9 +246,12 @@ 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 actually
* communicate with the hosted platform by performing a lightweight search.
* @param {object} [options]
* @param {AbortSignal} [options.signal] - When aborted, prevents a late-resolving
* check from calling {@link markAvailable} (guards against race with startup timeout).
* @returns {Promise<boolean>} true if mem0 is ready
*/
export async function checkMem0Health() {
export async function checkMem0Health({ signal } = {}) {
const memConfig = getMemoryConfig();
if (!memConfig.enabled) {
info('Memory module disabled via config');
Expand Down Expand Up @@ -262,6 +280,11 @@ export async function checkMem0Health() {
limit: 1,
});

// Guard against late resolution after a startup timeout has already
// called markUnavailable(). If the caller's AbortSignal has fired,
// the timeout won the race and we must not flip availability back on.
if (signal?.aborted) return false;

markAvailable();
info('mem0 health check passed (SDK connectivity verified)');
return true;
Expand All @@ -286,7 +309,7 @@ export async function checkMem0Health() {
* @returns {Promise<boolean>} true if stored successfully
*/
export async function addMemory(userId, text, metadata = {}) {
if (!isMemoryAvailable()) return false;
if (!checkAndRecoverMemory()) return false;

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

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

const memories = rawMemories.map((m) => ({
id: m.id || '',
id: m.id ?? '',
memory: m.memory || m.text || m.content || '',
score: m.score ?? null,
}));
Expand All @@ -358,7 +381,7 @@ export async function searchMemories(userId, query, limit) {
* @returns {Promise<Array<{id: string, memory: string}>>} All user memories
*/
export async function getMemories(userId) {
if (!isMemoryAvailable()) return [];
Comment thread
BillChirico marked this conversation as resolved.
if (!checkAndRecoverMemory()) return [];

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

return memories.map((m) => ({
id: m.id || '',
id: m.id ?? '',
memory: m.memory || m.text || m.content || '',
}));
} catch (err) {
Expand All @@ -389,7 +412,7 @@ export async function getMemories(userId) {
* @returns {Promise<boolean>} true if deleted successfully
*/
export async function deleteAllMemories(userId) {
if (!isMemoryAvailable()) return false;
if (!checkAndRecoverMemory()) return false;

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

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

const lines = relations.map((r) => `- ${r.source} → ${r.relationship} → ${r.target}`);
const lines = relations
.filter((r) => r.source && r.relationship && r.target)
.map((r) => `- ${r.source} → ${r.relationship} → ${r.target}`);

if (lines.length === 0) return '';

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

/** Maximum characters for memory context injected into system prompt */
const MAX_MEMORY_CONTEXT_CHARS = 2000;

/**
* Build a context string from user memories to inject into the system prompt.
* Includes both regular memories and graph relations for richer context.
* Enforces a character budget to prevent oversized system prompts.
* @param {string} userId - Discord user ID
* @param {string} username - Display name
* @param {string} query - The user's current message (for relevance search)
* @returns {Promise<string>} Context string or empty string
*/
export async function buildMemoryContext(userId, username, query) {
if (!isMemoryAvailable()) return '';
if (!checkAndRecoverMemory()) return '';
if (isOptedOut(userId)) return '';

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

// Enforce character budget to prevent oversized system prompts
if (context.length > MAX_MEMORY_CONTEXT_CHARS) {
context = `${context.substring(0, MAX_MEMORY_CONTEXT_CHARS)}...`;
}

return context;
}

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

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

const messages = [
{ role: 'user', content: `${username}: ${userMessage}` },
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantReply },
];

await c.add(messages, {
user_id: userId,
app_id: APP_ID,
metadata: { username },
enable_graph: true,
});

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