diff --git a/apps/sim/app/api/memory/[id]/route.ts b/apps/sim/app/api/memory/[id]/route.ts index 20d0a71cd0a..2928fd8b42c 100644 --- a/apps/sim/app/api/memory/[id]/route.ts +++ b/apps/sim/app/api/memory/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { memory } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { agentMemoryDataSchemaContract, @@ -75,7 +75,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Memory const memories = await db .select() .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) .orderBy(memory.createdAt) .limit(1) @@ -125,7 +131,13 @@ export const DELETE = withRouteHandler( const existingMemory = await db .select({ id: memory.id }) .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) .limit(1) if (existingMemory.length === 0) { @@ -134,7 +146,13 @@ export const DELETE = withRouteHandler( await db .delete(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) logger.info(`[${requestId}] Memory deleted: ${id} for workspace: ${validatedWorkspaceId}`) return NextResponse.json( @@ -177,7 +195,13 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Memory const existingMemories = await db .select() .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) .limit(1) if (existingMemories.length === 0) { @@ -196,12 +220,24 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Memory await db .update(memory) .set({ data: validatedData, updatedAt: now }) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) const updatedMemories = await db .select() .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .where( + and( + eq(memory.key, id), + eq(memory.workspaceId, validatedWorkspaceId), + isNull(memory.deletedAt) + ) + ) .limit(1) const mem = updatedMemories[0] diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts index 4ad47e108b2..53b9340f3c6 100644 --- a/apps/sim/app/api/memory/route.ts +++ b/apps/sim/app/api/memory/route.ts @@ -293,7 +293,13 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { const result = await db .delete(memory) - .where(and(eq(memory.key, conversationId), eq(memory.workspaceId, workspaceId))) + .where( + and( + eq(memory.key, conversationId), + eq(memory.workspaceId, workspaceId), + isNull(memory.deletedAt) + ) + ) .returning({ id: memory.id }) const deletedCount = result.length diff --git a/apps/sim/blocks/blocks/mem0.test.ts b/apps/sim/blocks/blocks/mem0.test.ts new file mode 100644 index 00000000000..c468054c338 --- /dev/null +++ b/apps/sim/blocks/blocks/mem0.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { Mem0Block } from '@/blocks/blocks/mem0' + +describe('Mem0Block', () => { + const buildParams = Mem0Block.tools.config.params! + + it('parses JSON string messages for add operations', () => { + const params = buildParams({ + operation: 'add', + apiKey: 'test-key', + userId: 'alice', + messages: JSON.stringify([{ role: 'user', content: 'I like Sim.' }]), + }) + + expect(params).toEqual({ + apiKey: 'test-key', + userId: 'alice', + messages: [{ role: 'user', content: 'I like Sim.' }], + }) + }) + + it('rejects unsupported message roles before execution', () => { + expect(() => + buildParams({ + operation: 'add', + apiKey: 'test-key', + userId: 'alice', + messages: JSON.stringify([{ role: 'system', content: 'Remember this.' }]), + }) + ).toThrow('Each message must have role user or assistant and non-empty content') + }) + + it('passes pagination params for get operations', () => { + const params = buildParams({ + operation: 'get', + apiKey: 'test-key', + userId: 'alice', + page: '2', + limit: '25', + }) + + expect(params).toEqual({ + apiKey: 'test-key', + userId: 'alice', + page: 2, + limit: 25, + }) + }) +}) diff --git a/apps/sim/blocks/blocks/mem0.ts b/apps/sim/blocks/blocks/mem0.ts index 8904e9593e2..ce54ad31298 100644 --- a/apps/sim/blocks/blocks/mem0.ts +++ b/apps/sim/blocks/blocks/mem0.ts @@ -1,6 +1,8 @@ +import { toError } from '@sim/utils/errors' import { Mem0Icon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' import type { Mem0Response } from '@/tools/mem0/types' +import { parseMem0Messages } from '@/tools/mem0/utils' export const Mem0Block: BlockConfig = { type: 'mem0', @@ -32,7 +34,6 @@ export const Mem0Block: BlockConfig = { title: 'User ID', type: 'short-input', placeholder: 'Enter user identifier', - value: () => 'userid', // Default to the working user ID from curl example required: true, }, { @@ -77,6 +78,7 @@ export const Mem0Block: BlockConfig = { field: 'operation', value: 'get', }, + mode: 'advanced', wandConfig: { enabled: true, prompt: `Generate a date in YYYY-MM-DD format based on the user's description. @@ -100,6 +102,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n field: 'operation', value: 'get', }, + mode: 'advanced', wandConfig: { enabled: true, prompt: `Generate a date in YYYY-MM-DD format based on the user's description. @@ -122,6 +125,17 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n password: true, required: true, }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '1', + condition: { + field: 'operation', + value: 'get', + }, + mode: 'advanced', + }, { id: 'limit', title: 'Result Limit', @@ -134,6 +148,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n field: 'operation', value: ['search', 'get'], }, + mode: 'advanced', }, ], tools: { @@ -153,16 +168,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n } }, params: (params: Record) => { - // Create detailed error information for any missing required fields const errors: string[] = [] + const operation = params.operation || 'add' - // Validate required API key for all operations if (!params.apiKey) { errors.push('API Key is required') } - // For search operation, validate required fields - if (params.operation === 'search') { + if (operation === 'search') { if (!params.query || params.query.trim() === '') { errors.push('Search Query is required') } @@ -172,27 +185,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n } } - // For add operation, validate required fields - if (params.operation === 'add') { - if (!params.messages) { - errors.push('Messages are required for add operation') - } else if (!Array.isArray(params.messages) || params.messages.length === 0) { - errors.push('Messages must be a non-empty array') - } else { - for (const msg of params.messages) { - if (!msg.role || !msg.content) { - errors.push("Each message must have 'role' and 'content' properties") - break - } - } - } - + if (operation === 'add') { if (!params.userId) { errors.push('User ID is required') } } - // Throw error if any required fields are missing if (errors.length > 0) { throw new Error(`Mem0 Block Error: ${errors.join(', ')}`) } @@ -201,63 +199,22 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n apiKey: params.apiKey, } - // Add any identifiers that are present if (params.userId) result.userId = params.userId - // Add version if specified - if (params.version) result.version = params.version - - if (params.limit) result.limit = params.limit - - const operation = params.operation || 'add' + if (params.limit) result.limit = Number(params.limit) - // Process operation-specific parameters switch (operation) { case 'add': - if (params.messages) { - try { - // Ensure messages are properly formatted - const messagesArray = - typeof params.messages === 'string' - ? JSON.parse(params.messages) - : params.messages - - // Validate message structure - if (Array.isArray(messagesArray) && messagesArray.length > 0) { - let validMessages = true - for (const msg of messagesArray) { - if (!msg.role || !msg.content) { - validMessages = false - break - } - } - if (validMessages) { - result.messages = messagesArray - } else { - // Consistent with other error handling - collect in errors array - errors.push('Invalid message format - each message must have role and content') - throw new Error( - 'Mem0 Block Error: Invalid message format - each message must have role and content' - ) - } - } else { - // Consistent with other error handling - errors.push('Messages must be a non-empty array') - throw new Error('Mem0 Block Error: Messages must be a non-empty array') - } - } catch (e: any) { - if (!errors.includes('Messages must be valid JSON')) { - errors.push('Messages must be valid JSON') - } - throw new Error(`Mem0 Block Error: ${e.message || 'Messages must be valid JSON'}`) - } + try { + result.messages = parseMem0Messages(params.messages) + } catch (error) { + throw new Error(`Mem0 Block Error: ${toError(error).message}`) } break case 'search': if (params.query) { result.query = params.query - // Check if we have at least one identifier for search if (!params.userId) { errors.push('Search requires a User ID') throw new Error('Mem0 Block Error: Search requires a User ID') @@ -267,17 +224,16 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n throw new Error('Mem0 Block Error: Search requires a query parameter') } - // Include limit if specified - if (params.limit) { - result.limit = Number(params.limit) - } break case 'get': if (params.memoryId) { result.memoryId = params.memoryId } - // Add date range filtering for v2 get memories + if (params.page) { + result.page = Number(params.page) + } + if (params.startDate) { result.startDate = params.startDate } @@ -296,17 +252,23 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'Mem0 API key' }, userId: { type: 'string', description: 'User identifier' }, - version: { type: 'string', description: 'API version' }, messages: { type: 'json', description: 'Message data array' }, query: { type: 'string', description: 'Search query' }, memoryId: { type: 'string', description: 'Memory identifier' }, startDate: { type: 'string', description: 'Start date filter' }, endDate: { type: 'string', description: 'End date filter' }, + page: { type: 'number', description: 'Page number for paginated get results' }, limit: { type: 'number', description: 'Result limit' }, }, outputs: { - ids: { type: 'json', description: 'Memory identifiers' }, - memories: { type: 'json', description: 'Memory data' }, - searchResults: { type: 'json', description: 'Search results' }, + ids: { type: 'json', description: 'Memory identifiers returned by search or get operations' }, + memories: { type: 'json', description: 'Memory records returned by get operations' }, + searchResults: { type: 'json', description: 'Ranked memory records returned by search' }, + message: { type: 'string', description: 'Add operation status message' }, + status: { type: 'string', description: 'Add operation processing status' }, + event_id: { type: 'string', description: 'Add operation event ID for status polling' }, + count: { type: 'number', description: 'Total memory count for get operations' }, + next: { type: 'string', description: 'Next page URL for get operations' }, + previous: { type: 'string', description: 'Previous page URL for get operations' }, }, } diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index 367861d038f..115ad959496 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -28,50 +28,9 @@ async function makeNoisePng(width: number, height: number): Promise { .toBuffer() } -// Both tests do real sharp work (encode + metadata read) that can exceed the -// default 10s timeout when CI runs them alongside thousands of other tests. const SHARP_TEST_TIMEOUT_MS = 30_000 describe('readFileRecord', () => { - it( - 'returns small images as attachments without resize note', - async () => { - const sharp = (await import('sharp')).default - const smallPng = await sharp({ - create: { - width: 200, - height: 200, - channels: 3, - background: { r: 255, g: 0, b: 0 }, - }, - }) - .png() - .toBuffer() - - downloadWorkspaceFile.mockResolvedValue(smallPng) - - const result = await readFileRecord({ - id: 'wf_small', - workspaceId: 'ws_1', - name: 'small.png', - key: 'uploads/small.png', - path: '/api/files/serve/uploads%2Fsmall.png?context=mothership', - size: smallPng.length, - type: 'image/png', - uploadedBy: 'user_1', - uploadedAt: new Date(), - deletedAt: null, - storageContext: 'mothership', - }) - - expect(result?.attachment?.type).toBe('image') - expect(result?.attachment?.source.media_type).toBe('image/png') - expect(result?.content).not.toContain('resized for vision') - expect(Buffer.from(result?.attachment?.source.data ?? '', 'base64')).toEqual(smallPng) - }, - SHARP_TEST_TIMEOUT_MS - ) - it( 'downscales oversized images into attachments that fit the read limit', async () => { diff --git a/apps/sim/tools/mem0/add_memories.test.ts b/apps/sim/tools/mem0/add_memories.test.ts new file mode 100644 index 00000000000..372669977c5 --- /dev/null +++ b/apps/sim/tools/mem0/add_memories.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mem0AddMemoriesTool } from '@/tools/mem0/add_memories' +import type { Mem0AddMemoriesParams } from '@/tools/mem0/types' + +describe('mem0AddMemoriesTool', () => { + const buildBody = mem0AddMemoriesTool.request.body! + const transformResponse = mem0AddMemoriesTool.transformResponse! + + it('uses the v3 add memories endpoint', () => { + expect(mem0AddMemoriesTool.request.url).toBe('https://api.mem0.ai/v3/memories/add/') + expect(mem0AddMemoriesTool.request.method).toBe('POST') + }) + + it('builds the documented add memories request body', () => { + const body = buildBody({ + apiKey: 'test-key', + userId: ' alice ', + messages: [{ role: 'user', content: 'I like Sim.' }], + }) + + expect(body).toEqual({ + messages: [{ role: 'user', content: 'I like Sim.' }], + user_id: 'alice', + }) + }) + + it('accepts JSON string messages from the block code input', () => { + const params: Mem0AddMemoriesParams = { + apiKey: 'test-key', + userId: 'alice', + messages: JSON.stringify([{ role: 'assistant', content: 'I will remember that.' }]), + } + + expect(buildBody(params)).toEqual({ + messages: [{ role: 'assistant', content: 'I will remember that.' }], + user_id: 'alice', + }) + }) + + it('rejects unsupported message roles before building the request body', () => { + expect(() => + buildBody({ + apiKey: 'test-key', + userId: 'alice', + messages: JSON.stringify([{ role: 'system', content: 'Remember this.' }]), + }) + ).toThrow('Each message must have role user or assistant and non-empty content') + }) + + it('extracts queued processing fields from v3 responses', async () => { + const result = await transformResponse( + new Response( + JSON.stringify({ + message: 'Memory processing has been queued for background execution', + status: 'PENDING', + event_id: 'evt-123', + }) + ) + ) + + expect(result).toEqual({ + success: true, + output: { + message: 'Memory processing has been queued for background execution', + status: 'PENDING', + event_id: 'evt-123', + }, + }) + }) +}) diff --git a/apps/sim/tools/mem0/add_memories.ts b/apps/sim/tools/mem0/add_memories.ts index 0616e5a2be5..fc7ca732026 100644 --- a/apps/sim/tools/mem0/add_memories.ts +++ b/apps/sim/tools/mem0/add_memories.ts @@ -1,11 +1,16 @@ -import { ADD_MEMORY_OUTPUT_PROPERTIES } from '@/tools/mem0/types' +import { + ADD_MEMORY_OUTPUT_PROPERTIES, + type Mem0AddMemoriesParams, + type Mem0AddMemoriesResponse, +} from '@/tools/mem0/types' +import { parseMem0Messages } from '@/tools/mem0/utils' import type { ToolConfig } from '@/tools/types' /** * Add Memories Tool * @see https://docs.mem0.ai/api-reference/memory/add-memories */ -export const mem0AddMemoriesTool: ToolConfig = { +export const mem0AddMemoriesTool: ToolConfig = { id: 'mem0_add_memories', name: 'Add Memories', description: 'Add memories to Mem0 for persistent storage and retrieval', @@ -34,107 +39,36 @@ export const mem0AddMemoriesTool: ToolConfig = { }, request: { - url: 'https://api.mem0.ai/v1/memories/', + url: 'https://api.mem0.ai/v3/memories/add/', method: 'POST', headers: (params) => ({ Authorization: `Token ${params.apiKey}`, 'Content-Type': 'application/json', }), body: (params) => { - // First, ensure messages is an array - let messagesArray = params.messages - if (typeof messagesArray === 'string') { - try { - messagesArray = JSON.parse(messagesArray) - } catch (_e) { - throw new Error('Messages must be a valid JSON array of objects with role and content') - } - } - - // Validate message format - if (!Array.isArray(messagesArray) || messagesArray.length === 0) { - throw new Error('Messages must be a non-empty array') - } - - for (const msg of messagesArray) { - if (!msg.role || !msg.content) { - throw new Error('Each message must have role and content properties') - } - } - - // Prepare request body - const body: Record = { - messages: messagesArray, - version: 'v2', - user_id: params.userId, + const messages = parseMem0Messages(params.messages) + return { + messages, + user_id: params.userId.trim(), } - - return body }, }, - transformResponse: async (response) => { + transformResponse: async (response): Promise => { const data = await response.json() - - // If the API returns an empty array, this might be normal behavior on success - if (Array.isArray(data) && data.length === 0) { - return { - success: true, - output: { - memories: [], - }, - } - } - - // Handle array response with memory objects - if (Array.isArray(data) && data.length > 0) { - // Extract IDs for easy access - const memoryIds = data.map((memory) => memory.id) - - return { - success: true, - output: { - ids: memoryIds, - memories: data, - }, - } - } - - // Handle non-array responses (single memory object) - if (data && !Array.isArray(data) && data.id) { - return { - success: true, - output: { - ids: [data.id], - memories: [data], - }, - } - } - - // Default response format if none of the above match return { success: true, output: { - memories: Array.isArray(data) ? data : [data], + message: data.message ?? '', + status: data.status ?? '', + event_id: data.event_id ?? '', }, } }, outputs: { - ids: { - type: 'array', - description: 'Array of memory IDs that were created', - items: { - type: 'string', - }, - }, - memories: { - type: 'array', - description: 'Array of memory objects that were created', - items: { - type: 'object', - properties: ADD_MEMORY_OUTPUT_PROPERTIES, - }, - }, + message: ADD_MEMORY_OUTPUT_PROPERTIES.message, + status: ADD_MEMORY_OUTPUT_PROPERTIES.status, + event_id: ADD_MEMORY_OUTPUT_PROPERTIES.event_id, }, } diff --git a/apps/sim/tools/mem0/get_memories.test.ts b/apps/sim/tools/mem0/get_memories.test.ts new file mode 100644 index 00000000000..26451a71d32 --- /dev/null +++ b/apps/sim/tools/mem0/get_memories.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mem0GetMemoriesTool } from '@/tools/mem0/get_memories' + +interface Mem0GetParams { + apiKey: string + userId?: string + memoryId?: string + startDate?: string + endDate?: string + page?: number + limit?: number +} + +describe('mem0GetMemoriesTool', () => { + const buildUrl = mem0GetMemoriesTool.request.url as (params: Mem0GetParams) => string + const buildMethod = mem0GetMemoriesTool.request.method as (params: Mem0GetParams) => string + const buildBody = mem0GetMemoriesTool.request.body! + const transformResponse = mem0GetMemoriesTool.transformResponse! + + it('uses scoped v3 list memories requests', () => { + const params = { + apiKey: 'test-key', + userId: 'user-123', + page: 3, + limit: 25, + } + + expect(buildUrl(params)).toBe('https://api.mem0.ai/v3/memories/') + expect(buildMethod(params)).toBe('POST') + expect(buildBody(params)).toEqual({ + filters: { + user_id: 'user-123', + }, + page: 3, + page_size: 25, + }) + }) + + it('keeps date filters inside the scoped filter object', () => { + const body = buildBody({ + apiKey: 'test-key', + userId: 'user-123', + startDate: '2026-01-01', + endDate: '2026-01-31', + }) + + expect(body).toEqual({ + filters: { + user_id: 'user-123', + created_at: { + gte: '2026-01-01', + lte: '2026-01-31', + }, + }, + page: 1, + page_size: 10, + }) + }) + + it('uses the single-memory endpoint for memoryId requests', () => { + const params = { + apiKey: 'test-key', + userId: 'user-123', + memoryId: 'mem/123', + } + + expect(buildUrl(params)).toBe('https://api.mem0.ai/v1/memories/mem%2F123/') + expect(buildMethod(params)).toBe('GET') + expect(buildBody(params)).toBeUndefined() + }) + + it('extracts memories from paginated v3 responses', async () => { + const result = await transformResponse( + new Response( + JSON.stringify({ + count: 2, + next: 'https://api.mem0.ai/v3/memories/?page=2&page_size=25', + previous: null, + results: [ + { id: 'mem-1', memory: 'First memory.', user_id: 'user-123' }, + { id: 'mem-2', memory: 'Second memory.', user_id: 'user-123' }, + ], + }) + ) + ) + + expect(result.output).toEqual({ + memories: [ + { id: 'mem-1', memory: 'First memory.', user_id: 'user-123' }, + { id: 'mem-2', memory: 'Second memory.', user_id: 'user-123' }, + ], + ids: ['mem-1', 'mem-2'], + count: 2, + next: 'https://api.mem0.ai/v3/memories/?page=2&page_size=25', + previous: null, + }) + }) + + it('extracts direct single memory responses without rewriting fields', async () => { + const result = await transformResponse( + new Response( + JSON.stringify({ + id: 'mem-1', + memory: 'Stored memory content.', + created_at: '2026-01-01T00:00:00Z', + }) + ) + ) + + expect(result.output.memories).toEqual([ + { + id: 'mem-1', + memory: 'Stored memory content.', + created_at: '2026-01-01T00:00:00Z', + }, + ]) + expect(result.output.ids).toEqual(['mem-1']) + }) +}) diff --git a/apps/sim/tools/mem0/get_memories.ts b/apps/sim/tools/mem0/get_memories.ts index c753a25fa02..74d2addcc37 100644 --- a/apps/sim/tools/mem0/get_memories.ts +++ b/apps/sim/tools/mem0/get_memories.ts @@ -1,11 +1,24 @@ -import { MEMORY_OUTPUT_PROPERTIES } from '@/tools/mem0/types' +import { MEMORY_OUTPUT_PROPERTIES, type Mem0GetMemoriesParams } from '@/tools/mem0/types' +import { isRecord } from '@/tools/mem0/utils' import type { ToolConfig } from '@/tools/types' +const getMemoriesFromResponse = (data: unknown): unknown[] => { + if (Array.isArray(data)) return data + if (!isRecord(data)) return [] + if (Array.isArray(data.results)) return data.results + if (isRecord(data.memory)) return [data.memory] + if (data.id) return [data] + return [] +} + +const getMemoryId = (memory: unknown): string | undefined => + isRecord(memory) && typeof memory.id === 'string' ? memory.id : undefined + /** * Get Memories Tool * @see https://docs.mem0.ai/api-reference/memory/get-memories */ -export const mem0GetMemoriesTool: ToolConfig = { +export const mem0GetMemoriesTool: ToolConfig = { id: 'mem0_get_memories', name: 'Get Memories', description: 'Retrieve memories from Mem0 by ID or filter criteria', @@ -43,6 +56,13 @@ export const mem0GetMemoriesTool: ToolConfig = { visibility: 'user-or-llm', description: 'Maximum number of results to return (e.g., 10, 50, 100)', }, + page: { + type: 'number', + required: false, + default: 1, + visibility: 'user-or-llm', + description: 'Page number to retrieve for paginated list results', + }, apiKey: { type: 'string', required: true, @@ -52,73 +72,63 @@ export const mem0GetMemoriesTool: ToolConfig = { }, request: { - url: (params: Record) => { - // For a specific memory ID, use the get single memory endpoint - if (params.memoryId) { - // Dynamically set method to GET for memory ID requests - params.method = 'GET' - return `https://api.mem0.ai/v1/memories/${params.memoryId}/` + url: (params) => { + const memoryId = typeof params.memoryId === 'string' ? params.memoryId.trim() : undefined + if (memoryId) { + return `https://api.mem0.ai/v1/memories/${encodeURIComponent(memoryId)}/` } - // Otherwise use v2 memories endpoint with filters - return 'https://api.mem0.ai/v2/memories/' + return 'https://api.mem0.ai/v3/memories/' }, - method: 'POST', // Default to POST for filtering + method: (params) => + typeof params.memoryId === 'string' && params.memoryId.trim() ? 'GET' : 'POST', headers: (params) => ({ 'Content-Type': 'application/json', Authorization: `Token ${params.apiKey}`, }), - body: (params: Record) => { - // For specific memory ID, we'll use GET method instead and don't need a body - // But we still need to return an empty object to satisfy the type - if (params.memoryId) { - return {} + body: (params) => { + if (typeof params.memoryId === 'string' && params.memoryId.trim()) { + return undefined } - // Build filters array for AND condition - const andConditions = [] - - // Add user filter - andConditions.push({ user_id: params.userId }) - - // Add date range filter if provided + const filters: Record = { + user_id: params.userId?.trim(), + } if (params.startDate || params.endDate) { - const dateFilter: Record = {} - + const dateFilter: Record = {} if (params.startDate) { dateFilter.gte = params.startDate } - if (params.endDate) { dateFilter.lte = params.endDate } - - andConditions.push({ created_at: dateFilter }) + filters.created_at = dateFilter } - // Build final filters object - const body: Record = { + return { + filters, + page: Number(params.page ?? 1), page_size: Number(params.limit || 10), } - - // Only add filters if we have any conditions - if (andConditions.length > 0) { - body.filters = { AND: andConditions } - } - - return body }, }, transformResponse: async (response: Response) => { const data = await response.json() - const memories = Array.isArray(data) ? data : [data] - const ids = memories.map((memory) => memory.id).filter(Boolean) + const memories = getMemoriesFromResponse(data) + const ids = memories.map(getMemoryId).filter((id): id is string => Boolean(id)) return { success: true, output: { memories, ids, + ...(isRecord(data) && typeof data.count === 'number' ? { count: data.count } : {}), + ...(isRecord(data) && (typeof data.next === 'string' || data.next === null) + ? { next: data.next } + : {}), + ...(isRecord(data) && (typeof data.previous === 'string' || data.previous === null) + ? { previous: data.previous } + : {}), }, } }, @@ -139,5 +149,20 @@ export const mem0GetMemoriesTool: ToolConfig = { type: 'string', }, }, + count: { + type: 'number', + description: 'Total number of memories matching the filters', + optional: true, + }, + next: { + type: 'string', + description: 'URL for the next page of results', + optional: true, + }, + previous: { + type: 'string', + description: 'URL for the previous page of results', + optional: true, + }, }, } diff --git a/apps/sim/tools/mem0/index.ts b/apps/sim/tools/mem0/index.ts index 01b33170816..ba80251221c 100644 --- a/apps/sim/tools/mem0/index.ts +++ b/apps/sim/tools/mem0/index.ts @@ -3,3 +3,5 @@ import { mem0GetMemoriesTool } from '@/tools/mem0/get_memories' import { mem0SearchMemoriesTool } from '@/tools/mem0/search_memories' export { mem0AddMemoriesTool, mem0SearchMemoriesTool, mem0GetMemoriesTool } +export * from '@/tools/mem0/types' +export * from '@/tools/mem0/utils' diff --git a/apps/sim/tools/mem0/search_memories.test.ts b/apps/sim/tools/mem0/search_memories.test.ts new file mode 100644 index 00000000000..a7fec78dc08 --- /dev/null +++ b/apps/sim/tools/mem0/search_memories.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { mem0SearchMemoriesTool } from '@/tools/mem0/search_memories' + +describe('mem0SearchMemoriesTool', () => { + const buildBody = mem0SearchMemoriesTool.request.body! + const transformResponse = mem0SearchMemoriesTool.transformResponse! + + it('uses the v3 search endpoint', () => { + expect(mem0SearchMemoriesTool.request.url).toBe('https://api.mem0.ai/v3/memories/search/') + expect(mem0SearchMemoriesTool.request.method).toBe('POST') + }) + + it('builds the documented search request body', () => { + const body = buildBody({ + apiKey: 'test-key', + userId: ' alice ', + query: 'where does the user live?', + limit: 20, + }) + + expect(body).toEqual({ + query: 'where does the user live?', + filters: { + user_id: 'alice', + }, + top_k: 20, + }) + }) + + it('extracts results from v3 response envelopes', async () => { + const result = await transformResponse( + new Response( + JSON.stringify({ + results: [ + { + id: 'mem-1', + memory: 'User lives in San Francisco.', + user_id: 'alice', + categories: ['location'], + score: 0.82, + created_at: '2026-01-15T10:30:00Z', + updated_at: '2026-01-15T10:30:00Z', + }, + ], + }) + ) + ) + + expect(result.output).toEqual({ + searchResults: [ + { + id: 'mem-1', + memory: 'User lives in San Francisco.', + user_id: 'alice', + agent_id: undefined, + app_id: undefined, + run_id: undefined, + hash: undefined, + metadata: undefined, + categories: ['location'], + created_at: '2026-01-15T10:30:00Z', + updated_at: '2026-01-15T10:30:00Z', + score: 0.82, + }, + ], + ids: ['mem-1'], + }) + }) +}) diff --git a/apps/sim/tools/mem0/search_memories.ts b/apps/sim/tools/mem0/search_memories.ts index c7fc4d3d7dd..5f8dde791f4 100644 --- a/apps/sim/tools/mem0/search_memories.ts +++ b/apps/sim/tools/mem0/search_memories.ts @@ -1,12 +1,29 @@ -import type { Mem0Response } from '@/tools/mem0/types' +import type { Mem0Response, Mem0SearchMemoriesParams } from '@/tools/mem0/types' import { SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/mem0/types' +import { isRecord, type JsonRecord } from '@/tools/mem0/utils' import type { ToolConfig } from '@/tools/types' +const getSearchResults = (data: unknown): JsonRecord[] => { + if (!isRecord(data) || !Array.isArray(data.results)) return [] + return data.results.filter(isRecord) +} + +const getString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined + +const getStringArray = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined + +const getNumber = (value: unknown, fallback = 0): number => + typeof value === 'number' ? value : fallback + /** * Search Memories Tool * @see https://docs.mem0.ai/api-reference/memory/search-memories */ -export const mem0SearchMemoriesTool: ToolConfig = { +export const mem0SearchMemoriesTool: ToolConfig = { id: 'mem0_search_memories', name: 'Search Memories', description: 'Search for memories in Mem0 using semantic search', @@ -41,71 +58,46 @@ export const mem0SearchMemoriesTool: ToolConfig = { }, request: { - url: 'https://api.mem0.ai/v2/memories/search/', + url: 'https://api.mem0.ai/v3/memories/search/', method: 'POST', headers: (params) => ({ 'Content-Type': 'application/json', Authorization: `Token ${params.apiKey}`, }), body: (params) => { - // Create the request body with the format that the curl test confirms works - const body: Record = { - query: params.query || 'test', + return { + query: params.query, filters: { - user_id: params.userId, + user_id: params.userId.trim(), }, top_k: Number(params.limit || 10), } - - return body }, }, transformResponse: async (response): Promise => { const data = await response.json() - - if (!data || (Array.isArray(data) && data.length === 0)) { - return { - success: true, - output: { - searchResults: [], - ids: [], - }, - } - } - - if (Array.isArray(data)) { - const searchResults = data.map((item) => ({ - id: item.id, - memory: item.memory || '', - user_id: item.user_id, - agent_id: item.agent_id, - app_id: item.app_id, - run_id: item.run_id, - hash: item.hash, - metadata: item.metadata, - categories: item.categories, - created_at: item.created_at, - updated_at: item.updated_at, - score: item.score || 0, - })) - - const ids = data.map((item) => item.id).filter(Boolean) - - return { - success: true, - output: { - searchResults, - ids, - }, - } - } + const searchResults = getSearchResults(data).map((result) => ({ + id: getString(result.id) ?? '', + memory: getString(result.memory) ?? '', + user_id: getString(result.user_id), + agent_id: getString(result.agent_id), + app_id: getString(result.app_id), + run_id: getString(result.run_id), + hash: getString(result.hash), + metadata: isRecord(result.metadata) ? result.metadata : undefined, + categories: getStringArray(result.categories), + created_at: getString(result.created_at), + updated_at: getString(result.updated_at), + score: getNumber(result.score), + })) + const ids = searchResults.map((result) => result.id).filter(Boolean) return { success: true, output: { - searchResults: [], - ids: [], + searchResults, + ids, }, } }, diff --git a/apps/sim/tools/mem0/types.ts b/apps/sim/tools/mem0/types.ts index 156926d65a4..63f18988df9 100644 --- a/apps/sim/tools/mem0/types.ts +++ b/apps/sim/tools/mem0/types.ts @@ -1,5 +1,41 @@ import type { OutputProperty, ToolResponse } from '@/tools/types' +export interface Mem0Message { + role: 'user' | 'assistant' + content: string +} + +export interface Mem0AddMemoriesParams { + userId: string + messages: Mem0Message[] | string + apiKey: string +} + +export interface Mem0SearchMemoriesParams { + userId: string + query: string + limit?: number + apiKey: string +} + +export interface Mem0GetMemoriesParams { + userId?: string + memoryId?: string + startDate?: string + endDate?: string + page?: number + limit?: number + apiKey: string +} + +export interface Mem0AddMemoriesResponse extends ToolResponse { + output: { + message: string + status: string + event_id: string + } +} + /** * Shared output property definitions for Mem0 API responses. * Based on official Mem0 REST API documentation. @@ -7,21 +43,18 @@ import type { OutputProperty, ToolResponse } from '@/tools/types' */ /** - * Output definition for memory objects returned by add operations. - * Add responses include event type indicating what operation was performed. + * Output definition for queued add-memory operations. * @see https://docs.mem0.ai/api-reference/memory/add-memories */ export const ADD_MEMORY_OUTPUT_PROPERTIES = { - id: { type: 'string', description: 'Unique identifier for the memory' }, - memory: { type: 'string', description: 'The content of the memory' }, - event: { + message: { type: 'string', description: 'Status message for the queued memory processing job' }, + status: { type: 'string', - description: 'Event type indicating operation performed (ADD, UPDATE, DELETE, NOOP)', + description: 'Processing status returned by Mem0', }, - metadata: { - type: 'json', - description: 'Custom metadata associated with the memory', - optional: true, + event_id: { + type: 'string', + description: 'Event ID for polling memory processing status', }, } as const satisfies Record @@ -30,7 +63,7 @@ export const ADD_MEMORY_OUTPUT_PROPERTIES = { */ export const ADD_MEMORY_OUTPUT: OutputProperty = { type: 'object', - description: 'Memory object returned from add operation with event type', + description: 'Queued memory processing job returned from add operation', properties: ADD_MEMORY_OUTPUT_PROPERTIES, } @@ -66,18 +99,6 @@ export const MEMORY_OUTPUT_PROPERTIES = { type: 'string', description: 'ISO 8601 timestamp when the memory was last updated', }, - owner: { type: 'string', description: 'Owner of the memory', optional: true }, - organization: { - type: 'string', - description: 'Organization associated with the memory', - optional: true, - }, - immutable: { type: 'boolean', description: 'Whether the memory can be modified', optional: true }, - expiration_date: { - type: 'string', - description: 'Expiration date after which memory is not retrieved', - optional: true, - }, } as const satisfies Record /** @@ -139,7 +160,6 @@ export interface Mem0Response extends ToolResponse { memories?: Array<{ id: string memory: string - event?: string user_id?: string agent_id?: string app_id?: string @@ -149,11 +169,10 @@ export interface Mem0Response extends ToolResponse { categories?: string[] created_at?: string updated_at?: string - owner?: string - organization?: string - immutable?: boolean - expiration_date?: string }> + count?: number + next?: string | null + previous?: string | null searchResults?: Array<{ id: string memory: string diff --git a/apps/sim/tools/mem0/utils.ts b/apps/sim/tools/mem0/utils.ts new file mode 100644 index 00000000000..8c6d7cedac9 --- /dev/null +++ b/apps/sim/tools/mem0/utils.ts @@ -0,0 +1,42 @@ +import { toError } from '@sim/utils/errors' +import type { Mem0Message } from '@/tools/mem0/types' + +export type JsonRecord = Record + +export const isRecord = (value: unknown): value is JsonRecord => + value !== null && typeof value === 'object' && !Array.isArray(value) + +function isMem0Message(value: unknown): value is Mem0Message { + return ( + value !== null && + typeof value === 'object' && + 'role' in value && + 'content' in value && + (value.role === 'user' || value.role === 'assistant') && + typeof value.content === 'string' && + value.content.length > 0 + ) +} + +export function parseMem0Messages(value: unknown): Mem0Message[] { + let messages: unknown + try { + messages = typeof value === 'string' ? JSON.parse(value) : value + } catch (error) { + throw new Error(`Messages must be valid JSON: ${toError(error).message}`) + } + + if (!Array.isArray(messages) || messages.length === 0) { + throw new Error('Messages must be a non-empty array') + } + + const validMessages: Mem0Message[] = [] + for (const message of messages) { + if (!isMem0Message(message)) { + throw new Error('Each message must have role user or assistant and non-empty content') + } + validMessages.push(message) + } + + return validMessages +} diff --git a/apps/sim/tools/memory/get.test.ts b/apps/sim/tools/memory/get.test.ts new file mode 100644 index 00000000000..0b7177d19d6 --- /dev/null +++ b/apps/sim/tools/memory/get.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { memoryGetTool } from '@/tools/memory/get' + +interface MemoryGetParams { + _context?: { + workspaceId?: string + } + conversationId?: string + id?: string +} + +describe('memoryGetTool', () => { + const buildUrl = memoryGetTool.request.url as (params: MemoryGetParams) => string + const transformResponse = memoryGetTool.transformResponse! + + it('builds an exact memory lookup URL', () => { + const url = buildUrl({ + _context: { workspaceId: 'workspace-1' }, + conversationId: 'user-123', + }) + + expect(url).toBe('/api/memory/user-123?workspaceId=workspace-1') + expect(url).not.toContain('query=') + expect(url).not.toContain('limit=') + }) + + it('encodes legacy id values in the path', () => { + const url = buildUrl({ + _context: { workspaceId: 'workspace-1' }, + id: 'team/user 123', + }) + + expect(url).toBe('/api/memory/team%2Fuser%20123?workspaceId=workspace-1') + }) + + it('wraps the exact memory response as a single result', async () => { + const result = await transformResponse( + new Response( + JSON.stringify({ + success: true, + data: { + conversationId: 'user-123', + data: [{ role: 'user', content: 'Remember this' }], + }, + }) + ) + ) + + expect(result).toEqual({ + success: true, + output: { + memories: [ + { + conversationId: 'user-123', + data: [{ role: 'user', content: 'Remember this' }], + }, + ], + message: 'Found 1 memory', + }, + }) + }) +}) diff --git a/apps/sim/tools/memory/get.ts b/apps/sim/tools/memory/get.ts index 27125637613..a9eed715403 100644 --- a/apps/sim/tools/memory/get.ts +++ b/apps/sim/tools/memory/get.ts @@ -35,12 +35,8 @@ export const memoryGetTool: ToolConfig = { if (!conversationId) { throw new Error('conversationId or id is required') } - const query = conversationId - - const url = new URL('/api/memory', 'http://dummy') + const url = new URL(`/api/memory/${encodeURIComponent(conversationId)}`, 'http://dummy') url.searchParams.set('workspaceId', workspaceId) - url.searchParams.set('query', query) - url.searchParams.set('limit', '1000') return url.pathname + url.search }, @@ -52,9 +48,9 @@ export const memoryGetTool: ToolConfig = { transformResponse: async (response): Promise => { const result = await response.json() - const memories = result.data?.memories || [] + const memory = result.data - if (!Array.isArray(memories) || memories.length === 0) { + if (!memory) { return { success: true, output: { @@ -67,8 +63,8 @@ export const memoryGetTool: ToolConfig = { return { success: true, output: { - memories, - message: `Found ${memories.length} memories`, + memories: [memory], + message: 'Found 1 memory', }, } },