From a8e2a4920990e71954a135c908e6830cff03132b Mon Sep 17 00:00:00 2001 From: kartik-mem0 Date: Wed, 29 Jul 2026 22:02:13 +0530 Subject: [PATCH] fix(n8n): escape ids in URLs, add agent/run filters, run tests in CI Three review findings on the Mem0 n8n node: - Memory IDs and event IDs were interpolated raw into request paths, so a value containing / or .. could reach a different endpoint than intended. Wrap all four sites in encodeURIComponent. - Search and Get Many could only filter by user_id, leaving agent- and run-scoped memories unreachable from the node. Both now accept User ID, Agent ID, and Run ID. Multiple ids combine with OR because Mem0 indexes entities separately and an AND across them matches nothing. User ID is no longer marked required; the node still fails with a clear message when all three are empty, rather than letting the API return a raw 400. - n8n-nodes-mem0-checks.yml ran lint and build but never the jest suite, so the tests could rot unnoticed. Add a test job matching the layout of pi-agent-plugin-checks.yml. Tests cover flat single-entity filters, the OR combination, agent-only listing, the empty-entity guard on both operations, and id escaping in the get/delete/poll URLs. Docs and README document the new fields and the OR semantics. --- .github/workflows/n8n-nodes-mem0-checks.yml | 23 +++++ docs/integrations/n8n.mdx | 16 +++- integrations/n8n-nodes-mem0/README.md | 10 +- .../n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts | 92 ++++++++++++++++--- .../n8n-nodes-mem0/test/Mem0.node.test.ts | 60 ++++++++++++ 5 files changed, 184 insertions(+), 17 deletions(-) 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 () => {