node-red-contrib-ai-agent 0.0.5 → 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
@@ -71,8 +71,11 @@ A configuration node that initializes the conversation context in memory. The ag
71
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.
72
72
 
73
73
  **Properties:**
74
- - **Max Items**: Maximum number of conversation turns to keep
75
- - **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
76
79
  - **Name**: Display name for the node
77
80
 
78
81
  ### AI Model
@@ -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.5",
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
+ }