node-red-contrib-ai-agent 0.0.4 → 0.0.6

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.
package/README.md CHANGED
@@ -36,6 +36,18 @@ Your feedback and contributions are highly appreciated!
36
36
  5. Connect to an AI Agent node to process messages
37
37
  6. (Optional) Connect more AI Agent nodes to process messages in a chain
38
38
 
39
+ ## Example: Today's Joke
40
+
41
+ Here's an example flow that tells a joke related to today's date using a custom tool:
42
+
43
+ ![Today's Joke Flow](https://raw.githubusercontent.com/lesichkovm/node-red-contrib-ai-agent/refs/heads/main/snapshots/todays-joke-flow.png "Example flow showing the Today's Joke implementation")
44
+
45
+ ### Flow Output
46
+
47
+ When executed, the flow will generate a joke related to the current date:
48
+
49
+ ![Today's Joke Output](https://raw.githubusercontent.com/lesichkovm/node-red-contrib-ai-agent/refs/heads/main/snapshots/todays-joke.png "Example output showing a date-related joke")
50
+
39
51
  ##
40
52
 
41
53
  ## Node Types
@@ -59,8 +71,11 @@ A configuration node that initializes the conversation context in memory. The ag
59
71
  A configuration node that initializes the conversation context with file-based persistence. The agent node uses this configuration to manage the conversation context across restarts.
60
72
 
61
73
  **Properties:**
62
- - **Max Items**: Maximum number of conversation turns to keep
63
- - **File Path**: Path to store the conversation history
74
+ - **Filename**: Path to store the memory file (relative to Node-RED user directory)
75
+ - **Max Conversations**: Maximum number of conversations to store
76
+ - **Max Messages Per Conversation**: Maximum messages per conversation history
77
+ - **Backups**: Enable/disable automatic backups
78
+ - **Backup Count**: Number of backups to keep
64
79
  - **Name**: Display name for the node
65
80
 
66
81
  ### AI Model
@@ -128,6 +143,8 @@ The AI Agent will automatically detect and use the tools in its processing. You
128
143
 
129
144
  In this example, the AI Agent has access to both an HTTP tool for making external API calls and a function tool for custom logic.
130
145
 
146
+
147
+
131
148
  ## Example: Chained Agents
132
149
 
133
150
  For more complex scenarios, you can chain multiple agents to process messages in sequence:
@@ -11,7 +11,11 @@
11
11
  validate: function(v) {
12
12
  return v.length > 0;
13
13
  }
14
- }
14
+ },
15
+ maxConversations: { value: 50, validate: RED.validators.number() },
16
+ maxMessagesPerConversation: { value: 100, validate: RED.validators.number() },
17
+ backupEnabled: { value: true },
18
+ backupCount: { value: 3, validate: RED.validators.number() }
15
19
  },
16
20
  inputs: 1,
17
21
  outputs: 1,
@@ -43,6 +47,23 @@
43
47
  <label for="node-input-filename"><i class="fa fa-file"></i> Filename</label>
44
48
  <input type="text" id="node-input-filename" placeholder="ai-memories.json">
45
49
  </div>
50
+ <div class="form-row">
51
+ <label for="node-input-maxConversations"><i class="fa fa-list"></i> Max Conversations</label>
52
+ <input type="number" id="node-input-maxConversations" placeholder="50">
53
+ </div>
54
+ <div class="form-row">
55
+ <label for="node-input-maxMessagesPerConversation"><i class="fa fa-commenting"></i> Max Messages/Conv</label>
56
+ <input type="number" id="node-input-maxMessagesPerConversation" placeholder="100">
57
+ </div>
58
+ <div class="form-row">
59
+ <label for="node-input-backupEnabled"><i class="fa fa-shield"></i> Backups</label>
60
+ <input type="checkbox" id="node-input-backupEnabled" style="display:inline-block; width:auto; vertical-align:top;">
61
+ <label for="node-input-backupEnabled" style="width: auto;">Enable automatic backups</label>
62
+ </div>
63
+ <div class="form-row" id="backupCount-row">
64
+ <label for="node-input-backupCount"><i class="fa fa-history"></i> Backup Count</label>
65
+ <input type="number" id="node-input-backupCount" placeholder="3">
66
+ </div>
46
67
  <div class="form-tips">
47
68
  <p>Memories will be stored in Node-RED's user directory.</p>
48
69
  </div>
@@ -1,55 +1,419 @@
1
- module.exports = function(RED) {
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ class SimpleFileStorage {
5
+ constructor(options = {}) {
6
+ this.filePath = options.filePath;
7
+ this.backupEnabled = options.backupEnabled !== false;
8
+ this.backupCount = options.backupCount || 3;
9
+ }
10
+
11
+ async save(data) {
12
+ try {
13
+ const dir = path.dirname(this.filePath);
14
+ if (!fs.existsSync(dir)) {
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ }
17
+
18
+ data.metadata = data.metadata || {};
19
+ data.metadata.lastUpdated = new Date().toISOString();
20
+
21
+ await fs.promises.writeFile(
22
+ this.filePath,
23
+ JSON.stringify(data, null, 2)
24
+ );
25
+
26
+ if (this.backupEnabled) {
27
+ await this.createBackup();
28
+ }
29
+
30
+ return true;
31
+ } catch (error) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ loadSync() {
37
+ try {
38
+ if (fs.existsSync(this.filePath)) {
39
+ const data = fs.readFileSync(this.filePath, 'utf8');
40
+ return JSON.parse(data);
41
+ }
42
+ return null;
43
+ } catch (error) {
44
+ // Backup recovery is still async, but for initial load sync is safer
45
+ return null;
46
+ }
47
+ }
48
+
49
+ async load() {
50
+ try {
51
+ if (fs.existsSync(this.filePath)) {
52
+ const data = await fs.promises.readFile(this.filePath, 'utf8');
53
+ return JSON.parse(data);
54
+ }
55
+ return null;
56
+ } catch (error) {
57
+ return await this.recoverFromBackup();
58
+ }
59
+ }
60
+
61
+ async createBackup() {
62
+ try {
63
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
64
+ const backupPath = `${this.filePath}.${timestamp}.bak`;
65
+
66
+ await fs.promises.copyFile(this.filePath, backupPath);
67
+
68
+ const backups = await this.listBackups();
69
+ if (backups.length > this.backupCount) {
70
+ const oldestBackups = backups
71
+ .sort((a, b) => a.time - b.time)
72
+ .slice(0, backups.length - this.backupCount);
73
+
74
+ for (const backup of oldestBackups) {
75
+ await fs.promises.unlink(backup.path);
76
+ }
77
+ }
78
+
79
+ return true;
80
+ } catch (error) {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ async listBackups() {
86
+ try {
87
+ const dir = path.dirname(this.filePath);
88
+ const base = path.basename(this.filePath);
89
+
90
+ const files = await fs.promises.readdir(dir);
91
+
92
+ return files
93
+ .filter(file => file.startsWith(`${base}.`) && file.endsWith('.bak'))
94
+ .map(file => {
95
+ const match = file.match(/\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.bak$/);
96
+ const timestamp = match ? match[1].replace(/-/g, ':').replace(/-(\d{3})Z$/, '.$1Z') : null;
97
+
98
+ return {
99
+ path: path.join(dir, file),
100
+ time: timestamp ? new Date(timestamp).getTime() : 0
101
+ };
102
+ });
103
+ } catch (error) {
104
+ return [];
105
+ }
106
+ }
107
+
108
+ async recoverFromBackup() {
109
+ try {
110
+ const backups = await this.listBackups();
111
+
112
+ if (backups.length === 0) {
113
+ return null;
114
+ }
115
+
116
+ const latestBackup = backups.sort((a, b) => b.time - a.time)[0];
117
+ const data = await fs.promises.readFile(latestBackup.path, 'utf8');
118
+ return JSON.parse(data);
119
+ } catch (error) {
120
+ return null;
121
+ }
122
+ }
123
+ }
124
+
125
+ class SimpleMemoryManager {
126
+ constructor(options = {}) {
127
+ this.maxConversations = options.maxConversations || 50;
128
+ this.maxMessagesPerConversation = options.maxMessagesPerConversation || 100;
129
+ this.conversations = [];
130
+ }
131
+
132
+ addMessage(conversationId, message) {
133
+ let conversation = this.conversations.find(c => c.id === conversationId);
134
+
135
+ if (!conversation) {
136
+ conversation = {
137
+ id: conversationId,
138
+ messages: [],
139
+ createdAt: new Date().toISOString(),
140
+ updatedAt: new Date().toISOString()
141
+ };
142
+
143
+ this.conversations.push(conversation);
144
+
145
+ if (this.conversations.length > this.maxConversations) {
146
+ this.conversations = this.conversations
147
+ .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
148
+ .slice(0, this.maxConversations);
149
+ }
150
+ }
151
+
152
+ conversation.messages.push({
153
+ ...message,
154
+ timestamp: new Date().toISOString()
155
+ });
156
+
157
+ conversation.updatedAt = new Date().toISOString();
158
+
159
+ if (conversation.messages.length > this.maxMessagesPerConversation) {
160
+ conversation.messages = conversation.messages.slice(-this.maxMessagesPerConversation);
161
+ }
162
+
163
+ return conversation;
164
+ }
165
+
166
+ getConversation(conversationId) {
167
+ return this.conversations.find(c => c.id === conversationId) || null;
168
+ }
169
+
170
+ getConversationMessages(conversationId, limit = null) {
171
+ const conversation = this.getConversation(conversationId);
172
+
173
+ if (!conversation) {
174
+ return [];
175
+ }
176
+
177
+ const messages = conversation.messages;
178
+
179
+ if (limit && messages.length > limit) {
180
+ return messages.slice(-limit);
181
+ }
182
+
183
+ return messages;
184
+ }
185
+
186
+ searchConversations(query, options = {}) {
187
+ const results = [];
188
+
189
+ for (const conversation of this.conversations) {
190
+ const matchingMessages = conversation.messages.filter(message =>
191
+ message.content && message.content.toLowerCase().includes(query.toLowerCase())
192
+ );
193
+
194
+ if (matchingMessages.length > 0) {
195
+ results.push({
196
+ conversation,
197
+ matchingMessages: options.includeMessages ? matchingMessages : matchingMessages.length
198
+ });
199
+ }
200
+ }
201
+
202
+ return results.sort((a, b) =>
203
+ new Date(b.conversation.updatedAt) - new Date(a.conversation.updatedAt)
204
+ );
205
+ }
206
+
207
+ deleteConversation(conversationId) {
208
+ const index = this.conversations.findIndex(c => c.id === conversationId);
209
+
210
+ if (index !== -1) {
211
+ this.conversations.splice(index, 1);
212
+ return true;
213
+ }
214
+
215
+ return false;
216
+ }
217
+
218
+ clearAllConversations() {
219
+ this.conversations = [];
220
+ return true;
221
+ }
222
+
223
+ toJSON() {
224
+ return {
225
+ conversations: this.conversations,
226
+ metadata: {
227
+ version: '1.0',
228
+ lastUpdated: new Date().toISOString(),
229
+ stats: {
230
+ conversationCount: this.conversations.length,
231
+ messageCount: this.conversations.reduce((count, conv) => count + conv.messages.length, 0)
232
+ }
233
+ }
234
+ };
235
+ }
236
+
237
+ fromJSON(data) {
238
+ if (data && data.conversations) {
239
+ this.conversations = data.conversations;
240
+ } else {
241
+ this.conversations = [];
242
+ }
243
+ }
244
+ }
245
+
246
+ module.exports = function (RED) {
2
247
  'use strict';
3
248
 
4
249
  function MemoryFileNode(config) {
5
250
  RED.nodes.createNode(this, config);
6
251
  const node = this;
7
-
252
+
8
253
  // Configuration
9
254
  node.name = config.name || 'AI Memory (File)';
10
255
  node.filename = config.filename || 'ai-memories.json';
11
-
12
- // Initialize empty memories array
13
- node.memories = [];
14
-
15
- // Load existing memories from file if they exist
16
- const fs = require('fs');
17
- const path = require('path');
18
- const filePath = path.join(RED.settings.userDir, node.filename);
19
-
256
+ node.maxConversations = parseInt(config.maxConversations) || 50;
257
+ node.maxMessagesPerConversation = parseInt(config.maxMessagesPerConversation) || 100;
258
+ node.backupEnabled = config.backupEnabled !== false;
259
+ node.backupCount = parseInt(config.backupCount) || 3;
260
+
261
+ const userDir = (RED.settings && RED.settings.userDir) || process.cwd();
262
+ const filePath = path.join(userDir, node.filename);
263
+
264
+ // Create storage and memory manager
265
+ node.fileStorage = new SimpleFileStorage({
266
+ filePath,
267
+ backupEnabled: node.backupEnabled,
268
+ backupCount: node.backupCount
269
+ });
270
+
271
+ node.memoryManager = new SimpleMemoryManager({
272
+ maxConversations: node.maxConversations,
273
+ maxMessagesPerConversation: node.maxMessagesPerConversation
274
+ });
275
+
276
+ // Load existing memories synchronously at startup
20
277
  try {
21
- if (fs.existsSync(filePath)) {
22
- const data = fs.readFileSync(filePath, 'utf8');
23
- node.memories = JSON.parse(data);
24
- node.status({fill:"green",shape:"dot",text:"Ready"});
278
+ const data = node.fileStorage.loadSync();
279
+ if (data) {
280
+ node.memoryManager.fromJSON(data);
281
+ node.status({
282
+ fill: "green",
283
+ shape: "dot",
284
+ text: `${node.memoryManager.conversations.length} conversations`
285
+ });
25
286
  } else {
26
- node.status({fill:"blue",shape:"ring",text:"New file will be created"});
287
+ node.status({ fill: "blue", shape: "ring", text: "New memory file will be created" });
27
288
  }
28
289
  } catch (err) {
29
290
  node.error("Error loading memory file: " + err.message);
30
- node.status({fill:"red",shape:"ring",text:"Error loading"});
291
+ node.status({ fill: "red", shape: "ring", text: "Error loading" });
31
292
  }
32
293
 
33
294
  // Handle incoming messages
34
- node.on('input', function(msg) {
295
+ node.on('input', async function (msg, send, done) {
296
+ // Use send and done for Node-RED 1.0+ compatibility
297
+ send = send || function () { node.send.apply(node, arguments) };
298
+
35
299
  try {
36
- // For now, just pass through the message
37
- // We'll add memory operations in the next iteration
38
- node.send(msg);
39
-
40
- // Update status
41
- node.status({fill:"green",shape:"dot",text:node.memories.length + " memories"});
300
+ msg.aimemory = msg.aimemory || {};
301
+
302
+ if (msg.command) {
303
+ await processCommand(node, msg);
304
+ } else {
305
+ const conversationId = msg.conversationId || 'default';
306
+ const messages = node.memoryManager.getConversationMessages(conversationId);
307
+
308
+ msg.aimemory = {
309
+ type: 'file',
310
+ conversationId,
311
+ context: messages
312
+ };
313
+ }
314
+
315
+ send(msg);
316
+
317
+ node.status({
318
+ fill: "green",
319
+ shape: "dot",
320
+ text: `${node.memoryManager.conversations.length} conversations`
321
+ });
322
+
323
+ if (done) done();
42
324
  } catch (err) {
43
325
  node.error("Error in memory node: " + err.message, msg);
44
- node.status({fill:"red",shape:"ring",text:"Error"});
326
+ node.status({ fill: "red", shape: "ring", text: "Error" });
327
+ if (done) done(err);
45
328
  }
46
329
  });
47
330
 
48
- // Cleanup on node removal
49
- node.on('close', function() {
50
- // Save memories to file
331
+ async function processCommand(node, msg) {
332
+ const command = msg.command;
333
+
334
+ switch (command) {
335
+ case 'add':
336
+ if (!msg.message) {
337
+ throw new Error('No message content provided');
338
+ }
339
+
340
+ const conversationId = msg.conversationId || 'default';
341
+ const conversation = node.memoryManager.addMessage(conversationId, msg.message);
342
+
343
+ msg.result = {
344
+ success: true,
345
+ operation: 'add',
346
+ conversationId,
347
+ messageCount: conversation.messages.length
348
+ };
349
+
350
+ await node.fileStorage.save(node.memoryManager.toJSON());
351
+ break;
352
+
353
+ case 'get':
354
+ const getConversationId = msg.conversationId || 'default';
355
+ const limit = msg.limit || null;
356
+
357
+ msg.result = {
358
+ success: true,
359
+ operation: 'get',
360
+ conversationId: getConversationId,
361
+ messages: node.memoryManager.getConversationMessages(getConversationId, limit)
362
+ };
363
+ break;
364
+
365
+ case 'search':
366
+ if (!msg.query) {
367
+ throw new Error('No search query provided');
368
+ }
369
+
370
+ msg.result = {
371
+ success: true,
372
+ operation: 'search',
373
+ query: msg.query,
374
+ results: node.memoryManager.searchConversations(msg.query, {
375
+ includeMessages: msg.includeMessages !== false
376
+ })
377
+ };
378
+ break;
379
+
380
+ case 'delete':
381
+ if (!msg.conversationId) {
382
+ throw new Error('No conversation ID provided');
383
+ }
384
+
385
+ const deleted = node.memoryManager.deleteConversation(msg.conversationId);
386
+
387
+ msg.result = {
388
+ success: deleted,
389
+ operation: 'delete',
390
+ conversationId: msg.conversationId
391
+ };
392
+
393
+ if (deleted) {
394
+ await node.fileStorage.save(node.memoryManager.toJSON());
395
+ }
396
+ break;
397
+
398
+ case 'clear':
399
+ node.memoryManager.clearAllConversations();
400
+
401
+ msg.result = {
402
+ success: true,
403
+ operation: 'clear'
404
+ };
405
+
406
+ await node.fileStorage.save(node.memoryManager.toJSON());
407
+ break;
408
+
409
+ default:
410
+ throw new Error(`Unknown command: ${command}`);
411
+ }
412
+ }
413
+
414
+ node.on('close', async function () {
51
415
  try {
52
- fs.writeFileSync(filePath, JSON.stringify(node.memories, null, 2));
416
+ await node.fileStorage.save(node.memoryManager.toJSON());
53
417
  } catch (err) {
54
418
  node.error("Error saving memory file: " + err.message);
55
419
  }
@@ -57,6 +421,5 @@ module.exports = function(RED) {
57
421
  });
58
422
  }
59
423
 
60
- // Register the node type
61
424
  RED.nodes.registerType("ai-memory-file", MemoryFileNode);
62
425
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ai-agent",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "AI Agent for Node-RED",
5
5
  "repository": {
6
6
  "type": "git",
@@ -63,4 +63,4 @@
63
63
  "ai-memory-inmem": "./memory-inmem/memory-inmem.js"
64
64
  }
65
65
  }
66
- }
66
+ }