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:
@@ -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" }] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user