diff --git a/.github/workflows/n8n-nodes-mem0-checks.yml b/.github/workflows/n8n-nodes-mem0-checks.yml index 89a5aac28..f211af515 100644 --- a/.github/workflows/n8n-nodes-mem0-checks.yml +++ b/.github/workflows/n8n-nodes-mem0-checks.yml @@ -35,6 +35,29 @@ jobs: - name: Lint run: cd integrations/n8n-nodes-mem0 && pnpm run lint + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + cache-dependency-path: integrations/n8n-nodes-mem0/pnpm-lock.yaml + + - name: Install dependencies + run: cd integrations/n8n-nodes-mem0 && pnpm install --frozen-lockfile --ignore-scripts + + - name: Run tests + run: cd integrations/n8n-nodes-mem0 && pnpm test + build: runs-on: ubuntu-latest steps: diff --git a/docs/integrations/n8n.mdx b/docs/integrations/n8n.mdx index b2bd9d423..36ce31c6c 100644 --- a/docs/integrations/n8n.mdx +++ b/docs/integrations/n8n.mdx @@ -55,11 +55,23 @@ Extraction is asynchronous. **Wait for Completion** (on by default) polls the ev ### Search -Semantic search over stored memories. Provide a **Query**, a **User ID** (required, the API needs an entity filter), and an optional **Limit**. +Semantic search over stored memories. Provide a **Query**, at least one entity id, and an optional **Limit**. ### Get Many -Lists memories for a **User ID**. Turn on **Return All** to page through every memory automatically; leave it off to fetch a single **Page**. **Page Size** applies either way. +Lists stored memories for the entity ids you supply. Turn on **Return All** to page through every memory automatically; leave it off to fetch a single **Page**. **Page Size** applies either way. + +### Entity filters on Search and Get Many + +Both operations take **User ID**, **Agent ID**, and **Run ID**. At least one is required (the API rejects a query with no entity scope), and the node fails with a clear message before making the call if you leave all three empty. + +Supply several and they are combined with **OR**, so the result set is the union of the three scopes: + +```json +{ "OR": [{ "user_id": "alice" }, { "agent_id": "support-bot" }] } +``` + +This is deliberate. Mem0 indexes each entity separately, so an `AND` across `user_id` and `agent_id` matches nothing even when a memory was written with both. To narrow rather than widen, run one operation per entity id. ### Get / Update / Delete diff --git a/integrations/n8n-nodes-mem0/README.md b/integrations/n8n-nodes-mem0/README.md index 7db2f53b5..98d965aec 100644 --- a/integrations/n8n-nodes-mem0/README.md +++ b/integrations/n8n-nodes-mem0/README.md @@ -20,7 +20,7 @@ The **Memory** resource supports: | --- | --- | --- | | **Add** | Extract and store memories from messages | `POST /v3/memories/add/` | | **Search** | Semantic search over stored memories | `POST /v3/memories/search/` | -| **Get Many** | List memories for a user (single page, or **Return All**) | `POST /v3/memories/` | +| **Get Many** | List stored memories (single page, or **Return All**) | `POST /v3/memories/` | | **Get** | Retrieve a single memory by ID | `GET /v1/memories/{id}/` | | **Update** | Update a memory's text or metadata | `PUT /v1/memories/{id}/` | | **Delete** | Delete a single memory by ID | `DELETE /v1/memories/{id}/` | @@ -36,6 +36,12 @@ Two independent controls: **Custom Instructions** and **Custom Categories** (also under Additional Fields) steer what extraction keeps for that call. +### Entity filters on Search & Get Many + +Both take **User ID**, **Agent ID**, and **Run ID**, and at least one is required — the API rejects a query with no entity scope, and the node fails with a clear message before calling it. + +Supplying several combines them with **OR**, giving the union of those scopes. Mem0 indexes each entity separately, so an `AND` across `user_id` and `agent_id` matches nothing even for a memory written with both. To narrow instead of widen, run one operation per entity id. + ## Credentials You need a Mem0 API key. Create one at [app.mem0.ai](https://app.mem0.ai) → Settings → API Keys. The key is sent as `Authorization: Token `. @@ -46,7 +52,7 @@ This node is also **usable as a tool** by n8n's AI Agent node — attach it so a A typical loop: -1. **Search** memory before answering, filtered by `User ID`. +1. **Search** memory before answering, filtered by `User ID` (or `Agent ID` / `Run ID`). 2. **Add** durable facts after a meaningful exchange. Memory writes are asynchronous by default; allow a moment after an Add before searching for the same content. diff --git a/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts index 1a8ec597b..76452b1cf 100644 --- a/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts +++ b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts @@ -219,9 +219,25 @@ export class Mem0 implements INodeType { name: 'userId', type: 'string', default: '', - required: true, displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, - description: 'Restrict the search to this user (required — the API needs an entity filter)', + description: + 'Restrict the search to this user. Supply at least one of User ID, Agent ID, or Run ID.', + }, + { + displayName: 'Agent ID', + name: 'agentId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this agent', + }, + { + displayName: 'Run ID', + name: 'runId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this session or run', }, { displayName: 'Limit', @@ -239,9 +255,25 @@ export class Mem0 implements INodeType { name: 'userId', type: 'string', default: '', - required: true, displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, - description: 'Restrict the listing to this user (required — the API needs an entity filter)', + description: + 'Restrict the listing to this user. Supply at least one of User ID, Agent ID, or Run ID.', + }, + { + displayName: 'Agent ID', + name: 'agentId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this agent', + }, + { + displayName: 'Run ID', + name: 'runId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this session or run', }, { displayName: 'Return All', @@ -414,16 +446,27 @@ export class Mem0 implements INodeType { output_format: 'v1.1', top_k: this.getNodeParameter('limit', i, 50) as number, }; - const userId = this.getNodeParameter('userId', i, '') as string; - if (userId) body.filters = { user_id: userId }; + body.filters = buildEntityFilters( + this.getNodeParameter('userId', i, '') as string, + this.getNodeParameter('agentId', i, '') as string, + this.getNodeParameter('runId', i, '') as string, + this, + i, + ); const resp = await request('POST', '/v3/memories/search/', body); responseData = Array.isArray(resp.results) ? (resp.results as IDataObject[]) : []; } else if (operation === 'getAll') { - const userId = this.getNodeParameter('userId', i, '') as string; const returnAll = this.getNodeParameter('returnAll', i, false) as boolean; const pageSize = this.getNodeParameter('pageSize', i, 50) as number; - const body: IDataObject = {}; - if (userId) body.filters = { user_id: userId }; + const body: IDataObject = { + filters: buildEntityFilters( + this.getNodeParameter('userId', i, '') as string, + this.getNodeParameter('agentId', i, '') as string, + this.getNodeParameter('runId', i, '') as string, + this, + i, + ), + }; if (returnAll) { // Page through until a short/empty page or no `next` (hard-capped for safety). const all: IDataObject[] = []; @@ -441,7 +484,7 @@ export class Mem0 implements INodeType { } } else if (operation === 'get') { const memoryId = this.getNodeParameter('memoryId', i) as string; - responseData = await request('GET', `/v1/memories/${memoryId}/`); + responseData = await request('GET', `/v1/memories/${encodeURIComponent(memoryId)}/`); } else if (operation === 'update') { const memoryId = this.getNodeParameter('memoryId', i) as string; const body: IDataObject = {}; @@ -462,10 +505,10 @@ export class Mem0 implements INodeType { itemIndex: i, }); } - responseData = await request('PUT', `/v1/memories/${memoryId}/`, body); + responseData = await request('PUT', `/v1/memories/${encodeURIComponent(memoryId)}/`, body); } else if (operation === 'delete') { const memoryId = this.getNodeParameter('memoryId', i) as string; - responseData = await request('DELETE', `/v1/memories/${memoryId}/`); + responseData = await request('DELETE', `/v1/memories/${encodeURIComponent(memoryId)}/`); } const arr = Array.isArray(responseData) ? responseData : [responseData]; @@ -485,6 +528,29 @@ export class Mem0 implements INodeType { } } +function buildEntityFilters( + userId: string, + agentId: string, + runId: string, + ctx: IExecuteFunctions, + itemIndex: number, +): IDataObject { + const clauses: IDataObject[] = []; + if (userId) clauses.push({ user_id: userId }); + if (agentId) clauses.push({ agent_id: agentId }); + if (runId) clauses.push({ run_id: runId }); + + if (clauses.length === 0) { + throw new NodeOperationError( + ctx.getNode(), + 'Provide at least one of User ID, Agent ID, or Run ID', + { itemIndex }, + ); + } + + return clauses.length === 1 ? clauses[0] : { OR: clauses }; +} + // Polls GET /v1/event/{id}/ until the memory-addition event resolves. async function pollEvent( request: (m: IHttpRequestMethods, u: string) => Promise, @@ -493,7 +559,7 @@ async function pollEvent( itemIndex: number, ): Promise { for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { - const event = await request('GET', `/v1/event/${eventId}/`); + const event = await request('GET', `/v1/event/${encodeURIComponent(eventId)}/`); const status = event.status as string; if (status === 'SUCCEEDED') { // Match the shape of search/getAll (a clean array); fall back to the envelope. diff --git a/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts index 2d1df8363..4e5c96719 100644 --- a/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts +++ b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts @@ -94,6 +94,66 @@ describe('Mem0 node (offline)', () => { expect(ctx.requests[0].qs.source).toBe('N8N'); }); + it('sends a single entity id as a flat filter', async () => { + const ctx = makeCtx('search', { query: 'x', userId: 'u1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ user_id: 'u1' }); + }); + + it('combines entity ids with OR, never AND (entities are stored separately, so AND matches nothing)', async () => { + const ctx = makeCtx( + 'search', + { query: 'x', userId: 'u1', agentId: 'a1', runId: 'r1' }, + async () => ({ results: [] }), + ); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ + OR: [{ user_id: 'u1' }, { agent_id: 'a1' }, { run_id: 'r1' }], + }); + }); + + it('filters Get Many by agent id alone', async () => { + const ctx = makeCtx('getAll', { agentId: 'a1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ agent_id: 'a1' }); + }); + + it.each(['search', 'getAll'])('reports a clear error when %s has no entity id', async (op) => { + const ctx = makeCtx(op, { query: 'x' }, async () => ({ results: [] }), { + continueOnFail: true, + }); + const out: any = await run(ctx); + expect(out[0][0].json.error).toMatch(/at least one of User ID/i); + }); + + it.each([ + ['get', 'GET'], + ['delete', 'DELETE'], + ])('escapes the memory id in the %s url', async (op, method) => { + const ctx = makeCtx(op, { memoryId: '../v1/entities' }, async () => ({})); + await run(ctx); + expect(ctx.requests[0].method).toBe(method); + expect(ctx.requests[0].url).toBe('https://api.mem0.ai/v1/memories/..%2Fv1%2Fentities/'); + }); + + it('escapes the event id when polling', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: {}, + userId: 'u1', + waitForCompletion: true, + }, + async (options) => { + if (options.url.includes('/v3/memories/add/')) return { event_id: 'a b/c' }; + return { status: 'SUCCEEDED', results: [] }; + }, + ); + await run(ctx); + expect(ctx.requests[1].url).toBe('https://api.mem0.ai/v1/event/a%20b%2Fc/'); + }); + it('Return All pages through until a short page', async () => { let call = 0; const ctx = makeCtx('getAll', { userId: 'u1', returnAll: true, pageSize: 2 }, async () => {