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 +5 -2
- package/memory-file/memory-file.html +22 -1
- package/memory-file/memory-file.js +393 -30
- package/package.json +2 -2
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
|
-
- **
|
|
75
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
node.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
node.
|
|
24
|
-
node.status({
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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