diff --git a/docs/integrations/n8n.mdx b/docs/integrations/n8n.mdx index 36ce31c6c..1fe8ed568 100644 --- a/docs/integrations/n8n.mdx +++ b/docs/integrations/n8n.mdx @@ -40,16 +40,23 @@ Create a **Mem0 API** credential in n8n: ### Add -Extracts and stores memories from one or more messages. Provide at least one entity id (**User ID**, or **Agent ID** / **Run ID** in Additional Fields); the node validates this before calling the API. +Extracts and stores memories from one or more messages. Provide at least one entity id (**User ID**, or **Agent ID** / **App ID** / **Run ID** in Additional Fields); the node validates this before calling the API. Additional Fields also expose: | Field | Purpose | | --- | --- | +| **Agent ID** | Scopes the memory to an agent | +| **App ID** | Scopes the memory to an app or project | +| **Run ID** | Scopes the memory to a single session or run | | **Metadata (JSON)** | Arbitrary JSON attached to each extracted memory | | **Infer** | On by default. Turn off to store messages verbatim instead of running LLM extraction | | **Custom Instructions** | Free-text guidance steering what the extractor keeps or ignores, for this call | | **Custom Categories** | JSON array of `{category: description}` objects, replacing the project-level catalog for this call | +| **Includes** | Only extract memories matching this description | +| **Excludes** | Skip memories matching this description | + +**Includes** and **Excludes** filter what extraction keeps. Sending *"I am vegetarian and I never eat mushrooms. I drive a blue Toyota Corolla and my parking spot is B12"* stores three memories by default; with `Includes: "only record food and diet preferences"` it stores only the dietary one. Extraction is asynchronous. **Wait for Completion** (on by default) polls the event until it finishes and returns the resulting memories; turn it off to return immediately with the event ID. @@ -63,9 +70,9 @@ Lists stored memories for the entity ids you supply. Turn on **Return All** to p ### 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. +Both operations take **User ID**, **Agent ID**, **App 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 four empty. -Supply several and they are combined with **OR**, so the result set is the union of the three scopes: +Supply several and they are combined with **OR**, so the result set is the union of those scopes: ```json { "OR": [{ "user_id": "alice" }, { "agent_id": "support-bot" }] } diff --git a/integrations/n8n-nodes-mem0/README.md b/integrations/n8n-nodes-mem0/README.md index 98d965aec..7677196c5 100644 --- a/integrations/n8n-nodes-mem0/README.md +++ b/integrations/n8n-nodes-mem0/README.md @@ -34,11 +34,11 @@ Two independent controls: - **Wait for Completion** (on by default) decides whether the node polls. Turn it off to return immediately with the event ID. - **Infer** (on by default, under Additional Fields) decides whether the API runs LLM extraction at all. Turn it off to store the messages verbatim. -**Custom Instructions** and **Custom Categories** (also under Additional Fields) steer what extraction keeps for that call. +**Custom Instructions**, **Custom Categories**, **Includes**, and **Excludes** (also under Additional Fields) steer what extraction keeps for that call. **Agent ID**, **App ID**, and **Run ID** live there too, and scope the memory alongside (or instead of) **User ID**. ### 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. +Both take **User ID**, **Agent ID**, **App 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. @@ -52,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` (or `Agent ID` / `Run ID`). +1. **Search** memory before answering, filtered by `User ID` (or `Agent ID` / `App 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 76452b1cf..bb3d27b18 100644 --- a/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts +++ b/integrations/n8n-nodes-mem0/nodes/Mem0/Mem0.node.ts @@ -163,6 +163,12 @@ export class Mem0 implements INodeType { type: 'string', default: '', }, + { + displayName: 'App ID', + name: 'app_id', + type: 'string', + default: '', + }, { displayName: 'Custom Categories', name: 'custom_categories', @@ -180,6 +186,20 @@ export class Mem0 implements INodeType { description: 'Optional instructions that steer what the extractor keeps or ignores', }, + { + displayName: 'Excludes', + name: 'excludes', + type: 'string', + default: '', + description: 'Optional: skip memories matching this description', + }, + { + displayName: 'Includes', + name: 'includes', + type: 'string', + default: '', + description: 'Optional: only extract memories matching this description', + }, { displayName: 'Infer', name: 'infer', @@ -221,7 +241,7 @@ export class Mem0 implements INodeType { default: '', displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, description: - 'Restrict the search to this user. Supply at least one of User ID, Agent ID, or Run ID.', + 'Restrict the search to this user. Supply at least one of User ID, Agent ID, App ID, or Run ID.', }, { displayName: 'Agent ID', @@ -231,6 +251,14 @@ export class Mem0 implements INodeType { displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, description: 'Restrict the search to memories scoped to this agent', }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['search'] } }, + description: 'Restrict the search to memories scoped to this app or project', + }, { displayName: 'Run ID', name: 'runId', @@ -257,7 +285,7 @@ export class Mem0 implements INodeType { default: '', displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, description: - 'Restrict the listing to this user. Supply at least one of User ID, Agent ID, or Run ID.', + 'Restrict the listing to this user. Supply at least one of User ID, Agent ID, App ID, or Run ID.', }, { displayName: 'Agent ID', @@ -267,6 +295,14 @@ export class Mem0 implements INodeType { displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, description: 'Restrict the listing to memories scoped to this agent', }, + { + displayName: 'App ID', + name: 'appId', + type: 'string', + default: '', + displayOptions: { show: { resource: ['memory'], operation: ['getAll'] } }, + description: 'Restrict the listing to memories scoped to this app or project', + }, { displayName: 'Run ID', name: 'runId', @@ -378,6 +414,7 @@ export class Mem0 implements INodeType { const userId = this.getNodeParameter('userId', i, '') as string; if (userId) body.user_id = userId; if (addFields.agent_id) body.agent_id = addFields.agent_id; + if (addFields.app_id) body.app_id = addFields.app_id; if (addFields.run_id) body.run_id = addFields.run_id; if (addFields.metadata) { try { @@ -411,11 +448,14 @@ export class Mem0 implements INodeType { } } + if (addFields.includes) body.includes = addFields.includes; + if (addFields.excludes) body.excludes = addFields.excludes; + // API requires at least one entity id — fail clearly instead of a raw 4xx. - if (!body.user_id && !body.agent_id && !body.run_id) { + if (!body.user_id && !body.agent_id && !body.run_id && !body.app_id) { throw new NodeOperationError( this.getNode(), - 'Add requires at least one of User ID, Agent ID, or Run ID', + 'Add requires at least one of User ID, Agent ID, Run ID, or App ID', { itemIndex: i }, ); } @@ -447,9 +487,12 @@ export class Mem0 implements INodeType { top_k: this.getNodeParameter('limit', i, 50) as number, }; body.filters = buildEntityFilters( - this.getNodeParameter('userId', i, '') as string, - this.getNodeParameter('agentId', i, '') as string, - this.getNodeParameter('runId', i, '') as string, + { + user_id: this.getNodeParameter('userId', i, '') as string, + agent_id: this.getNodeParameter('agentId', i, '') as string, + app_id: this.getNodeParameter('appId', i, '') as string, + run_id: this.getNodeParameter('runId', i, '') as string, + }, this, i, ); @@ -460,9 +503,12 @@ export class Mem0 implements INodeType { const pageSize = this.getNodeParameter('pageSize', i, 50) as number; const body: IDataObject = { filters: buildEntityFilters( - this.getNodeParameter('userId', i, '') as string, - this.getNodeParameter('agentId', i, '') as string, - this.getNodeParameter('runId', i, '') as string, + { + user_id: this.getNodeParameter('userId', i, '') as string, + agent_id: this.getNodeParameter('agentId', i, '') as string, + app_id: this.getNodeParameter('appId', i, '') as string, + run_id: this.getNodeParameter('runId', i, '') as string, + }, this, i, ), @@ -529,21 +575,18 @@ export class Mem0 implements INodeType { } function buildEntityFilters( - userId: string, - agentId: string, - runId: string, + ids: Record, 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 }); + const clauses: IDataObject[] = Object.entries(ids) + .filter(([, value]) => value) + .map(([key, value]) => ({ [key]: value })); if (clauses.length === 0) { throw new NodeOperationError( ctx.getNode(), - 'Provide at least one of User ID, Agent ID, or Run ID', + 'Provide at least one of User ID, Agent ID, App ID, or Run ID', { itemIndex }, ); } diff --git a/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts index 4e5c96719..908e41a8d 100644 --- a/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts +++ b/integrations/n8n-nodes-mem0/test/Mem0.node.test.ts @@ -53,6 +53,44 @@ describe('Mem0 node (offline)', () => { expect(out[0][0].json.error).toMatch(/at least one of User ID/i); }); + it('forwards app id, includes and excludes on Add', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: { + app_id: 'p1', + includes: 'only food preferences', + excludes: 'nothing about vehicles', + }, + userId: 'u1', + }, + async () => ({}), + ); + await run(ctx); + expect(ctx.requests[0].body).toMatchObject({ + app_id: 'p1', + includes: 'only food preferences', + excludes: 'nothing about vehicles', + }); + }); + + it('accepts an app id alone as the entity scope on Add', async () => { + const ctx = makeCtx( + 'add', + { + 'messages.message': [{ role: 'user', content: 'hi' }], + addFields: { app_id: 'p1' }, + userId: '', + }, + async () => ({}), + { continueOnFail: true }, + ); + const out: any = await run(ctx); + expect(out[0][0].json.error).toBeUndefined(); + expect(ctx.requests[0].body.app_id).toBe('p1'); + }); + it('reports a clear error on invalid JSON in Custom Categories', async () => { const ctx = makeCtx( 'add', @@ -103,15 +141,21 @@ describe('Mem0 node (offline)', () => { 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' }, + { query: 'x', userId: 'u1', agentId: 'a1', appId: 'p1', runId: 'r1' }, async () => ({ results: [] }), ); await run(ctx); expect(ctx.requests[0].body.filters).toEqual({ - OR: [{ user_id: 'u1' }, { agent_id: 'a1' }, { run_id: 'r1' }], + OR: [{ user_id: 'u1' }, { agent_id: 'a1' }, { app_id: 'p1' }, { run_id: 'r1' }], }); }); + it.each(['search', 'getAll'])('filters %s by app id alone', async (op) => { + const ctx = makeCtx(op, { query: 'x', appId: 'p1' }, async () => ({ results: [] })); + await run(ctx); + expect(ctx.requests[0].body.filters).toEqual({ app_id: 'p1' }); + }); + it('filters Get Many by agent id alone', async () => { const ctx = makeCtx('getAll', { agentId: 'a1' }, async () => ({ results: [] })); await run(ctx);