fix(memories): get memory tool, mem0 integration update (#4415)

* fix(memories): get memory tool, mem0 integration update

* timeout test

* address comments

* address more comments

* remove flaky test
This commit is contained in:
Vikhyath Mondreti
2026-05-02 18:46:31 -07:00
committed by GitHub
parent 71ebe8168d
commit 79214497c5
16 changed files with 691 additions and 334 deletions
+43 -7
View File
@@ -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]
+7 -1
View File
@@ -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
+52
View File
@@ -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,
})
})
})
+38 -76
View File
@@ -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<Mem0Response> = {
type: 'mem0',
@@ -32,7 +34,6 @@ export const Mem0Block: BlockConfig<Mem0Response> = {
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<Mem0Response> = {
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<string, any>) => {
// 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 = Number(params.limit)
if (params.limit) result.limit = params.limit
const operation = params.operation || 'add'
// 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' },
},
}
@@ -28,50 +28,9 @@ async function makeNoisePng(width: number, height: number): Promise<Buffer> {
.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 () => {
+73
View File
@@ -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',
},
})
})
})
+19 -85
View File
@@ -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<Mem0AddMemoriesParams, Mem0AddMemoriesResponse> = {
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')
}
const messages = parseMem0Messages(params.messages)
return {
messages,
user_id: params.userId.trim(),
}
// 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<string, any> = {
messages: messagesArray,
version: 'v2',
user_id: params.userId,
}
return body
},
},
transformResponse: async (response) => {
transformResponse: async (response): Promise<Mem0AddMemoriesResponse> => {
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,
},
}
+122
View File
@@ -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'])
})
})
+64 -39
View File
@@ -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<Mem0GetMemoriesParams> = {
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<string, any>) => {
// 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<string, any>) => {
// 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<string, unknown> = {
user_id: params.userId?.trim(),
}
if (params.startDate || params.endDate) {
const dateFilter: Record<string, any> = {}
const dateFilter: Record<string, unknown> = {}
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<string, any> = {
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,
},
},
}
+2
View File
@@ -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'
@@ -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'],
})
})
})
+40 -48
View File
@@ -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<any, Mem0Response> = {
export const mem0SearchMemoriesTool: ToolConfig<Mem0SearchMemoriesParams, Mem0Response> = {
id: 'mem0_search_memories',
name: 'Search Memories',
description: 'Search for memories in Mem0 using semantic search',
@@ -41,71 +58,46 @@ export const mem0SearchMemoriesTool: ToolConfig<any, Mem0Response> = {
},
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<string, any> = {
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<Mem0Response> => {
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,
},
}
},
+47 -28
View File
@@ -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<string, OutputProperty>
@@ -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<string, OutputProperty>
/**
@@ -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
+42
View File
@@ -0,0 +1,42 @@
import { toError } from '@sim/utils/errors'
import type { Mem0Message } from '@/tools/mem0/types'
export type JsonRecord = Record<string, unknown>
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
}
+65
View File
@@ -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',
},
})
})
})
+5 -9
View File
@@ -35,12 +35,8 @@ export const memoryGetTool: ToolConfig<any, MemoryResponse> = {
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<any, MemoryResponse> = {
transformResponse: async (response): Promise<MemoryResponse> => {
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<any, MemoryResponse> = {
return {
success: true,
output: {
memories,
message: `Found ${memories.length} memories`,
memories: [memory],
message: 'Found 1 memory',
},
}
},