fix(n8n): restore App ID, Includes and Excludes, add App ID filters

Reverts the removal in 2a61fe1a. That commit dropped app_id, includes and
excludes from Add because docs/openapi.json does not declare them on
POST /v3/memories/add/. The schema is incomplete: probing the live endpoint
shows all three are accepted and honoured.

- app_id is persisted and is queryable via /v3/memories/search/ and
  /v3/memories/, so the entity-id guard accepts it again as a valid scope
- includes/excludes demonstrably steer extraction. The same message stored
  three memories unfiltered and one under
  includes: "only record food and diet preferences"

Search and Get Many gain an App ID filter alongside User/Agent/Run. Writing
an app-scoped memory was otherwise unreadable from this node, and app_id is
how the OpenCode and Pi Agent plugins express project scope.

buildEntityFilters now takes the ids as an object rather than four adjacent
positional strings, so a fifth entity is a one-key change.
This commit is contained in:
kartik-mem0
2026-07-29 22:18:19 +05:30
parent a8e2a49209
commit 29601e54f4
4 changed files with 120 additions and 26 deletions
+3 -3
View File
@@ -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.
@@ -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<string, 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 });
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 },
);
}
@@ -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);