@stackflo-labs/n8n-nodes-retainr 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -451,14 +451,14 @@ class Retainr {
451
451
  description: 'Filter by agent ID',
452
452
  },
453
453
  {
454
- displayName: 'Limit',
455
- name: 'limit',
454
+ displayName: 'Max Memories',
455
+ name: 'maxMemories',
456
456
  type: 'number',
457
457
  typeOptions: {
458
458
  minValue: 1,
459
459
  },
460
- default: 50,
461
- description: 'Max number of results to return',
460
+ default: 5,
461
+ description: 'Maximum number of memories to include in the context (API max 20)',
462
462
  },
463
463
  {
464
464
  displayName: 'Namespace',
@@ -673,10 +673,10 @@ class Retainr {
673
673
  const returnData = [];
674
674
  const resource = this.getNodeParameter('resource', 0);
675
675
  const operation = this.getNodeParameter('operation', 0);
676
+ const credentials = await this.getCredentials('retainrApi');
677
+ const baseUrl = credentials.baseUrl.replace(/\/+$/, '');
676
678
  for (let i = 0; i < items.length; i++) {
677
679
  try {
678
- const credentials = await this.getCredentials('retainrApi');
679
- const baseUrl = credentials.baseUrl.replace(/\/+$/, '');
680
680
  let responseData;
681
681
  // --------------------------------------------------------------
682
682
  // Memory
@@ -837,8 +837,8 @@ async function getContext(i, baseUrl) {
837
837
  body.namespace = additional.namespace;
838
838
  if (additional.tags)
839
839
  body.tags = parseTags(additional.tags);
840
- if (additional.limit)
841
- body.limit = additional.limit;
840
+ if (additional.maxMemories)
841
+ body.limit = additional.maxMemories;
842
842
  if (additional.threshold)
843
843
  body.threshold = additional.threshold;
844
844
  return apiRequest.call(this, 'POST', baseUrl, '/v1/memories/context', body);
@@ -0,0 +1,503 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const Retainr_node_1 = require("../Retainr.node");
4
+ const BASE_URL = 'https://api.retainr.dev';
5
+ const API_KEY = 'rec_live_testkey_base58';
6
+ function createMock(parameters, mockResponse = { id: 'mem_test123' }, continueOnFail = false) {
7
+ const httpRequestWithAuthentication = jest
8
+ .fn()
9
+ .mockResolvedValue(mockResponse);
10
+ return {
11
+ getInputData: () => [{ json: {}, pairedItem: 0 }],
12
+ getNodeParameter: (name, _index) => parameters[name],
13
+ getCredentials: jest.fn().mockResolvedValue({
14
+ apiKey: API_KEY,
15
+ baseUrl: BASE_URL,
16
+ }),
17
+ helpers: { httpRequestWithAuthentication },
18
+ continueOnFail: () => continueOnFail,
19
+ getNode: () => ({
20
+ name: 'Retainr',
21
+ type: 'n8n-nodes-retainr.retainr',
22
+ id: 'n1',
23
+ position: [0, 0],
24
+ parameters: {},
25
+ }),
26
+ };
27
+ }
28
+ /** Extract the IHttpRequestOptions passed to httpRequestWithAuthentication */
29
+ function getRequestOptions(mock) {
30
+ return mock.helpers.httpRequestWithAuthentication.mock.calls[0][1];
31
+ }
32
+ describe('Retainr node', () => {
33
+ const node = new Retainr_node_1.Retainr();
34
+ // ── Memory > Store ──────────────────────────────────────────────────────
35
+ describe('Memory > Store', () => {
36
+ it('POSTs to /v1/memories with user scope', async () => {
37
+ const mock = createMock({
38
+ resource: 'memory',
39
+ operation: 'store',
40
+ content: 'User prefers dark mode',
41
+ scope: 'user',
42
+ userId: 'user-abc-123',
43
+ storeAdditionalFields: {},
44
+ });
45
+ const result = await node.execute.call(mock);
46
+ const opts = getRequestOptions(mock);
47
+ expect(opts.method).toBe('POST');
48
+ expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
49
+ expect(opts.body).toEqual({
50
+ content: 'User prefers dark mode',
51
+ scope: 'user',
52
+ user_id: 'user-abc-123',
53
+ });
54
+ expect(result[0][0].json).toEqual({ id: 'mem_test123' });
55
+ });
56
+ it('POSTs with session scope and session_id', async () => {
57
+ const mock = createMock({
58
+ resource: 'memory',
59
+ operation: 'store',
60
+ content: 'Workflow context',
61
+ scope: 'session',
62
+ sessionId: 'run-xyz-789',
63
+ storeAdditionalFields: {},
64
+ });
65
+ await node.execute.call(mock);
66
+ const opts = getRequestOptions(mock);
67
+ expect(opts.body).toEqual(expect.objectContaining({
68
+ scope: 'session',
69
+ session_id: 'run-xyz-789',
70
+ }));
71
+ });
72
+ it('POSTs with agent scope and agent_id', async () => {
73
+ const mock = createMock({
74
+ resource: 'memory',
75
+ operation: 'store',
76
+ content: 'Agent instruction',
77
+ scope: 'agent',
78
+ agentId: 'crm-bot',
79
+ storeAdditionalFields: {},
80
+ });
81
+ await node.execute.call(mock);
82
+ const opts = getRequestOptions(mock);
83
+ expect(opts.body).toEqual(expect.objectContaining({
84
+ scope: 'agent',
85
+ agent_id: 'crm-bot',
86
+ }));
87
+ });
88
+ it('POSTs with global scope (no scope-specific ID)', async () => {
89
+ const mock = createMock({
90
+ resource: 'memory',
91
+ operation: 'store',
92
+ content: 'Global note',
93
+ scope: 'global',
94
+ storeAdditionalFields: {},
95
+ });
96
+ await node.execute.call(mock);
97
+ const body = getRequestOptions(mock).body;
98
+ expect(body.scope).toBe('global');
99
+ expect(body).not.toHaveProperty('user_id');
100
+ expect(body).not.toHaveProperty('session_id');
101
+ expect(body).not.toHaveProperty('agent_id');
102
+ });
103
+ it('includes ttl_seconds from additional fields', async () => {
104
+ const mock = createMock({
105
+ resource: 'memory',
106
+ operation: 'store',
107
+ content: 'Ephemeral',
108
+ scope: 'global',
109
+ storeAdditionalFields: { ttlSeconds: 3600 },
110
+ });
111
+ await node.execute.call(mock);
112
+ const body = getRequestOptions(mock).body;
113
+ expect(body.ttl_seconds).toBe(3600);
114
+ });
115
+ it('omits ttl_seconds when 0 (falsy)', async () => {
116
+ const mock = createMock({
117
+ resource: 'memory',
118
+ operation: 'store',
119
+ content: 'Permanent',
120
+ scope: 'global',
121
+ storeAdditionalFields: { ttlSeconds: 0 },
122
+ });
123
+ await node.execute.call(mock);
124
+ const body = getRequestOptions(mock).body;
125
+ expect(body).not.toHaveProperty('ttl_seconds');
126
+ });
127
+ it('parses comma-separated tags into array', async () => {
128
+ const mock = createMock({
129
+ resource: 'memory',
130
+ operation: 'store',
131
+ content: 'Tagged memory',
132
+ scope: 'global',
133
+ storeAdditionalFields: {
134
+ tags: 'preference, communication, important',
135
+ },
136
+ });
137
+ await node.execute.call(mock);
138
+ const body = getRequestOptions(mock).body;
139
+ expect(body.tags).toEqual([
140
+ 'preference',
141
+ 'communication',
142
+ 'important',
143
+ ]);
144
+ });
145
+ it('omits tags when empty string', async () => {
146
+ const mock = createMock({
147
+ resource: 'memory',
148
+ operation: 'store',
149
+ content: 'No tags',
150
+ scope: 'global',
151
+ storeAdditionalFields: { tags: '' },
152
+ });
153
+ await node.execute.call(mock);
154
+ const body = getRequestOptions(mock).body;
155
+ expect(body).not.toHaveProperty('tags');
156
+ });
157
+ it('includes dedup_threshold when set', async () => {
158
+ const mock = createMock({
159
+ resource: 'memory',
160
+ operation: 'store',
161
+ content: 'Dedup test',
162
+ scope: 'global',
163
+ storeAdditionalFields: { dedupThreshold: 0.95 },
164
+ });
165
+ await node.execute.call(mock);
166
+ const body = getRequestOptions(mock).body;
167
+ expect(body.dedup_threshold).toBe(0.95);
168
+ });
169
+ it('includes namespace when set', async () => {
170
+ const mock = createMock({
171
+ resource: 'memory',
172
+ operation: 'store',
173
+ content: 'Namespaced',
174
+ scope: 'global',
175
+ storeAdditionalFields: { namespace: 'onboarding' },
176
+ });
177
+ await node.execute.call(mock);
178
+ const body = getRequestOptions(mock).body;
179
+ expect(body.namespace).toBe('onboarding');
180
+ });
181
+ it('parses metadata JSON string', async () => {
182
+ const mock = createMock({
183
+ resource: 'memory',
184
+ operation: 'store',
185
+ content: 'With metadata',
186
+ scope: 'global',
187
+ storeAdditionalFields: {
188
+ metadata: '{"source":"n8n","version":2}',
189
+ },
190
+ });
191
+ await node.execute.call(mock);
192
+ const body = getRequestOptions(mock).body;
193
+ expect(body.metadata).toEqual({ source: 'n8n', version: 2 });
194
+ });
195
+ });
196
+ // ── Memory > Search ─────────────────────────────────────────────────────
197
+ describe('Memory > Search', () => {
198
+ it('POSTs to /v1/memories/search with query', async () => {
199
+ const mock = createMock({
200
+ resource: 'memory',
201
+ operation: 'search',
202
+ query: 'user preferences',
203
+ searchAdditionalFields: {
204
+ scope: 'user',
205
+ userId: 'user-abc-123',
206
+ limit: 3,
207
+ threshold: 0.8,
208
+ },
209
+ }, {
210
+ results: [
211
+ { id: 'mem_1', content: 'dark mode', score: 0.92 },
212
+ ],
213
+ });
214
+ const result = await node.execute.call(mock);
215
+ const opts = getRequestOptions(mock);
216
+ expect(opts.method).toBe('POST');
217
+ expect(opts.url).toBe(`${BASE_URL}/v1/memories/search`);
218
+ expect(opts.body).toEqual({
219
+ query: 'user preferences',
220
+ scope: 'user',
221
+ user_id: 'user-abc-123',
222
+ limit: 3,
223
+ threshold: 0.8,
224
+ });
225
+ expect(result[0][0].json).toEqual({
226
+ results: [
227
+ { id: 'mem_1', content: 'dark mode', score: 0.92 },
228
+ ],
229
+ });
230
+ });
231
+ it('omits unset optional fields', async () => {
232
+ const mock = createMock({
233
+ resource: 'memory',
234
+ operation: 'search',
235
+ query: 'anything',
236
+ searchAdditionalFields: {},
237
+ });
238
+ await node.execute.call(mock);
239
+ const body = getRequestOptions(mock).body;
240
+ expect(body).toEqual({ query: 'anything' });
241
+ });
242
+ it('includes tags filter', async () => {
243
+ const mock = createMock({
244
+ resource: 'memory',
245
+ operation: 'search',
246
+ query: 'tagged search',
247
+ searchAdditionalFields: { tags: 'preference, urgent' },
248
+ });
249
+ await node.execute.call(mock);
250
+ const body = getRequestOptions(mock).body;
251
+ expect(body.tags).toEqual(['preference', 'urgent']);
252
+ });
253
+ });
254
+ // ── Memory > Get Context ────────────────────────────────────────────────
255
+ describe('Memory > Get Context', () => {
256
+ it('POSTs to /v1/memories/context with query and format', async () => {
257
+ const mock = createMock({
258
+ resource: 'memory',
259
+ operation: 'getContext',
260
+ query: 'customer summary',
261
+ format: 'system_prompt',
262
+ contextAdditionalFields: {
263
+ scope: 'user',
264
+ userId: 'u-42',
265
+ maxMemories: 10,
266
+ threshold: 0.35,
267
+ },
268
+ }, { context: 'You know the user prefers dark mode.' });
269
+ const result = await node.execute.call(mock);
270
+ const opts = getRequestOptions(mock);
271
+ expect(opts.method).toBe('POST');
272
+ expect(opts.url).toBe(`${BASE_URL}/v1/memories/context`);
273
+ expect(opts.body).toEqual({
274
+ query: 'customer summary',
275
+ format: 'system_prompt',
276
+ scope: 'user',
277
+ user_id: 'u-42',
278
+ limit: 10,
279
+ threshold: 0.35,
280
+ });
281
+ expect(result[0][0].json).toEqual({
282
+ context: 'You know the user prefers dark mode.',
283
+ });
284
+ });
285
+ it('sends only query and format when no additional fields', async () => {
286
+ const mock = createMock({
287
+ resource: 'memory',
288
+ operation: 'getContext',
289
+ query: 'basics',
290
+ format: 'bullet_list',
291
+ contextAdditionalFields: {},
292
+ });
293
+ await node.execute.call(mock);
294
+ const body = getRequestOptions(mock).body;
295
+ expect(body).toEqual({ query: 'basics', format: 'bullet_list' });
296
+ });
297
+ });
298
+ // ── Memory > List ───────────────────────────────────────────────────────
299
+ describe('Memory > List', () => {
300
+ it('GETs /v1/memories with query params', async () => {
301
+ const mock = createMock({
302
+ resource: 'memory',
303
+ operation: 'list',
304
+ listAdditionalFields: {
305
+ scope: 'session',
306
+ sessionId: 'sess-xyz',
307
+ limit: 10,
308
+ },
309
+ }, { memories: [] });
310
+ await node.execute.call(mock);
311
+ const opts = getRequestOptions(mock);
312
+ expect(opts.method).toBe('GET');
313
+ expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
314
+ expect(opts.qs).toEqual({
315
+ scope: 'session',
316
+ session_id: 'sess-xyz',
317
+ limit: 10,
318
+ });
319
+ });
320
+ it('includes offset and namespace', async () => {
321
+ const mock = createMock({
322
+ resource: 'memory',
323
+ operation: 'list',
324
+ listAdditionalFields: {
325
+ scope: 'agent',
326
+ agentId: 'support-bot',
327
+ limit: 20,
328
+ offset: 50,
329
+ namespace: 'tickets',
330
+ },
331
+ });
332
+ await node.execute.call(mock);
333
+ const opts = getRequestOptions(mock);
334
+ expect(opts.qs).toEqual(expect.objectContaining({
335
+ scope: 'agent',
336
+ agent_id: 'support-bot',
337
+ limit: 20,
338
+ offset: 50,
339
+ namespace: 'tickets',
340
+ }));
341
+ });
342
+ it('sends no qs when no additional fields', async () => {
343
+ const mock = createMock({
344
+ resource: 'memory',
345
+ operation: 'list',
346
+ listAdditionalFields: {},
347
+ });
348
+ await node.execute.call(mock);
349
+ const opts = getRequestOptions(mock);
350
+ // No qs should be set (or empty)
351
+ expect(opts.qs).toBeUndefined();
352
+ });
353
+ });
354
+ // ── Memory > Delete ─────────────────────────────────────────────────────
355
+ describe('Memory > Delete', () => {
356
+ it('DELETEs /v1/memories with scope and filter', async () => {
357
+ const mock = createMock({
358
+ resource: 'memory',
359
+ operation: 'delete',
360
+ deleteScope: 'agent',
361
+ deleteAdditionalFields: { agentId: 'crm-bot' },
362
+ }, { deleted: 5 });
363
+ await node.execute.call(mock);
364
+ const opts = getRequestOptions(mock);
365
+ expect(opts.method).toBe('DELETE');
366
+ expect(opts.url).toBe(`${BASE_URL}/v1/memories`);
367
+ expect(opts.body).toEqual({
368
+ scope: 'agent',
369
+ agent_id: 'crm-bot',
370
+ });
371
+ });
372
+ it('DELETEs with user scope', async () => {
373
+ const mock = createMock({
374
+ resource: 'memory',
375
+ operation: 'delete',
376
+ deleteScope: 'user',
377
+ deleteAdditionalFields: { userId: 'churned-user-99' },
378
+ }, { deleted: 12 });
379
+ await node.execute.call(mock);
380
+ const body = getRequestOptions(mock).body;
381
+ expect(body).toEqual({
382
+ scope: 'user',
383
+ user_id: 'churned-user-99',
384
+ });
385
+ });
386
+ it('DELETEs with global scope and namespace', async () => {
387
+ const mock = createMock({
388
+ resource: 'memory',
389
+ operation: 'delete',
390
+ deleteScope: 'global',
391
+ deleteAdditionalFields: { namespace: 'temp' },
392
+ }, { deleted: 3 });
393
+ await node.execute.call(mock);
394
+ const body = getRequestOptions(mock).body;
395
+ expect(body).toEqual({ scope: 'global', namespace: 'temp' });
396
+ });
397
+ });
398
+ // ── Workspace > Get Info ────────────────────────────────────────────────
399
+ describe('Workspace > Get Info', () => {
400
+ it('GETs /v1/workspace', async () => {
401
+ const mock = createMock({
402
+ resource: 'workspace',
403
+ operation: 'getInfo',
404
+ }, {
405
+ workspace_id: 'ws_abc',
406
+ plan: 'pro',
407
+ memory_count: 42,
408
+ });
409
+ const result = await node.execute.call(mock);
410
+ const opts = getRequestOptions(mock);
411
+ expect(opts.method).toBe('GET');
412
+ expect(opts.url).toBe(`${BASE_URL}/v1/workspace`);
413
+ expect(result[0][0].json).toEqual({
414
+ workspace_id: 'ws_abc',
415
+ plan: 'pro',
416
+ memory_count: 42,
417
+ });
418
+ });
419
+ });
420
+ // ── Error handling ──────────────────────────────────────────────────────
421
+ describe('error handling', () => {
422
+ it('returns error item when continueOnFail is true', async () => {
423
+ const mock = createMock({
424
+ resource: 'memory',
425
+ operation: 'store',
426
+ content: 'test',
427
+ scope: 'global',
428
+ storeAdditionalFields: {},
429
+ }, undefined, true);
430
+ mock.helpers.httpRequestWithAuthentication.mockRejectedValue(new Error('Connection refused'));
431
+ const result = await node.execute.call(mock);
432
+ expect(result[0][0].json).toEqual({
433
+ error: 'Connection refused',
434
+ });
435
+ });
436
+ it('throws NodeApiError when continueOnFail is false', async () => {
437
+ const mock = createMock({
438
+ resource: 'memory',
439
+ operation: 'store',
440
+ content: 'test',
441
+ scope: 'global',
442
+ storeAdditionalFields: {},
443
+ }, undefined, false);
444
+ mock.helpers.httpRequestWithAuthentication.mockRejectedValue(new Error('Unauthorized'));
445
+ await expect(node.execute.call(mock)).rejects.toThrow();
446
+ });
447
+ });
448
+ // ── Multiple items ──────────────────────────────────────────────────────
449
+ describe('multiple input items', () => {
450
+ it('processes each item independently', async () => {
451
+ const items = [
452
+ { content: 'first memory', userId: 'u1' },
453
+ { content: 'second memory', userId: 'u2' },
454
+ ];
455
+ const httpMock = jest.fn().mockResolvedValue({ id: 'ok' });
456
+ const mock = {
457
+ getInputData: () => items.map((item) => ({ json: item, pairedItem: 0 })),
458
+ getNodeParameter: (name, i) => {
459
+ if (name === 'resource')
460
+ return 'memory';
461
+ if (name === 'operation')
462
+ return 'store';
463
+ if (name === 'content')
464
+ return items[i].content;
465
+ if (name === 'scope')
466
+ return 'user';
467
+ if (name === 'userId')
468
+ return items[i].userId;
469
+ if (name === 'storeAdditionalFields')
470
+ return {};
471
+ return undefined;
472
+ },
473
+ getCredentials: jest.fn().mockResolvedValue({
474
+ apiKey: API_KEY,
475
+ baseUrl: BASE_URL,
476
+ }),
477
+ helpers: { httpRequestWithAuthentication: httpMock },
478
+ continueOnFail: () => false,
479
+ getNode: () => ({
480
+ name: 'Retainr',
481
+ type: 'n8n-nodes-retainr.retainr',
482
+ id: 'n1',
483
+ position: [0, 0],
484
+ parameters: {},
485
+ }),
486
+ };
487
+ const result = await node.execute.call(mock);
488
+ expect(httpMock).toHaveBeenCalledTimes(2);
489
+ expect(result[0]).toHaveLength(2);
490
+ });
491
+ });
492
+ // ── Uses httpRequestWithAuthentication ───────────────────────────────────
493
+ describe('authentication', () => {
494
+ it('calls httpRequestWithAuthentication with retainrApi credential name', async () => {
495
+ const mock = createMock({
496
+ resource: 'workspace',
497
+ operation: 'getInfo',
498
+ });
499
+ await node.execute.call(mock);
500
+ expect(mock.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('retainrApi', expect.any(Object));
501
+ });
502
+ });
503
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackflo-labs/n8n-nodes-retainr",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/stackflo-labs/n8n-nodes-retainr.git"
@@ -28,7 +28,14 @@
28
28
  "scripts": {
29
29
  "build": "tsc && copyfiles -u 1 \"src/**/*.svg\" dist",
30
30
  "lint": "npx @n8n/scan-community-package @stackflo-labs/n8n-nodes-retainr",
31
- "clean": "rimraf dist"
31
+ "clean": "rimraf dist",
32
+ "test": "jest",
33
+ "test:coverage": "jest --coverage",
34
+ "typecheck": "tsc --noEmit",
35
+ "e2e": "node e2e/integration-test.mjs",
36
+ "e2e:up": "docker compose -f e2e/docker-compose.yml up --build -d",
37
+ "e2e:down": "docker compose -f e2e/docker-compose.yml down",
38
+ "e2e:run": "npm run e2e:up && npm run e2e -- && npm run e2e:down"
32
39
  },
33
40
  "publishConfig": {
34
41
  "access": "public"
@@ -48,8 +55,12 @@
48
55
  "n8n-workflow": ">=1.0.0"
49
56
  },
50
57
  "devDependencies": {
58
+ "@types/jest": "^29.5.0",
51
59
  "copyfiles": "^2.4.1",
60
+ "jest": "^29.7.0",
52
61
  "n8n-workflow": "^1.70.0",
62
+ "rimraf": "^5.0.0",
63
+ "ts-jest": "^29.2.0",
53
64
  "typescript": "^5.7.0"
54
65
  }
55
66
  }