@projectservan8n/cnapse 0.8.2 → 0.10.0
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/dist/ProviderSelector-GZYF26LL.js +7 -0
- package/dist/autonomous-VGEVIXXQ.js +419 -0
- package/dist/browser-YLFWQXIY.js +87 -0
- package/dist/{chunk-OPX7FFL6.js → chunk-7SDY7OPA.js} +14 -55
- package/dist/chunk-COKO6V5J.js +50 -0
- package/dist/chunk-GP73OJCZ.js +377 -0
- package/dist/chunk-MOKGR7WE.js +344 -0
- package/dist/chunk-OIVTPXE4.js +307 -0
- package/dist/chunk-TFHK5CYF.js +650 -0
- package/dist/chunk-WSBJFRQH.js +366 -0
- package/dist/index.js +579 -1733
- package/dist/learner-KH3TFTD7.js +14 -0
- package/dist/vision-S57PWSCU.js +19 -0
- package/package.json +1 -2
- package/src/agents/autonomous.ts +515 -0
- package/src/agents/learner.ts +489 -0
- package/src/lib/tasks.ts +217 -153
- package/src/lib/vision.ts +139 -0
- package/src/services/browser.ts +336 -584
- package/src/services/screen-monitor.ts +288 -0
- package/src/services/telegram.ts +312 -5
- package/src/tools/computer.ts +226 -0
- package/dist/ProviderSelector-MXRZFAOB.js +0 -6
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Learner - Self-learning system that consults multiple AI sources
|
|
3
|
+
* and remembers successful solutions for future use
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
10
|
+
import { existsSync } from 'fs';
|
|
11
|
+
import { describeScreen, captureScreenshot } from '../lib/vision.js';
|
|
12
|
+
import { chat } from '../lib/api.js';
|
|
13
|
+
import * as computer from '../tools/computer.js';
|
|
14
|
+
|
|
15
|
+
const MEMORY_FILE = join(homedir(), '.cnapse', 'agent-memory.json');
|
|
16
|
+
const MAX_MEMORY_SIZE = 500; // Max learned actions to keep
|
|
17
|
+
|
|
18
|
+
export interface LearnedAction {
|
|
19
|
+
id: string;
|
|
20
|
+
situation: string; // What the screen looked like
|
|
21
|
+
goal: string; // What we were trying to do
|
|
22
|
+
solution: string; // What worked (action description)
|
|
23
|
+
actionType: string; // Action type (click, type, navigate, etc.)
|
|
24
|
+
actionValue: string; // Action value/params
|
|
25
|
+
source: string; // Where we learned it (perplexity, claude, chatgpt, web, self)
|
|
26
|
+
successCount: number; // How many times this worked
|
|
27
|
+
failCount: number; // How many times this failed
|
|
28
|
+
lastUsed: string; // ISO timestamp
|
|
29
|
+
created: string; // ISO timestamp
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Suggestion {
|
|
33
|
+
action: string;
|
|
34
|
+
value: string;
|
|
35
|
+
reasoning: string;
|
|
36
|
+
source: string;
|
|
37
|
+
confidence: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AgentMemory {
|
|
41
|
+
version: number;
|
|
42
|
+
learned: LearnedAction[];
|
|
43
|
+
stats: {
|
|
44
|
+
totalAttempts: number;
|
|
45
|
+
totalSuccesses: number;
|
|
46
|
+
totalLearned: number;
|
|
47
|
+
sourceCounts: Record<string, number>;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class AgentLearner extends EventEmitter {
|
|
52
|
+
private memory: AgentMemory;
|
|
53
|
+
private loaded: boolean = false;
|
|
54
|
+
|
|
55
|
+
constructor() {
|
|
56
|
+
super();
|
|
57
|
+
this.memory = this.createEmptyMemory();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private createEmptyMemory(): AgentMemory {
|
|
61
|
+
return {
|
|
62
|
+
version: 1,
|
|
63
|
+
learned: [],
|
|
64
|
+
stats: {
|
|
65
|
+
totalAttempts: 0,
|
|
66
|
+
totalSuccesses: 0,
|
|
67
|
+
totalLearned: 0,
|
|
68
|
+
sourceCounts: {},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load memory from disk
|
|
75
|
+
*/
|
|
76
|
+
async load(): Promise<void> {
|
|
77
|
+
if (this.loaded) return;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const dir = join(homedir(), '.cnapse');
|
|
81
|
+
if (!existsSync(dir)) {
|
|
82
|
+
await mkdir(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (existsSync(MEMORY_FILE)) {
|
|
86
|
+
const data = await readFile(MEMORY_FILE, 'utf-8');
|
|
87
|
+
this.memory = JSON.parse(data);
|
|
88
|
+
}
|
|
89
|
+
this.loaded = true;
|
|
90
|
+
this.emit('loaded', this.memory.learned.length);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('Failed to load agent memory:', error);
|
|
93
|
+
this.memory = this.createEmptyMemory();
|
|
94
|
+
this.loaded = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Save memory to disk
|
|
100
|
+
*/
|
|
101
|
+
async save(): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
const dir = join(homedir(), '.cnapse');
|
|
104
|
+
if (!existsSync(dir)) {
|
|
105
|
+
await mkdir(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Prune if too large (keep best performers)
|
|
109
|
+
if (this.memory.learned.length > MAX_MEMORY_SIZE) {
|
|
110
|
+
this.memory.learned.sort((a, b) => {
|
|
111
|
+
const aScore = a.successCount - a.failCount;
|
|
112
|
+
const bScore = b.successCount - b.failCount;
|
|
113
|
+
return bScore - aScore;
|
|
114
|
+
});
|
|
115
|
+
this.memory.learned = this.memory.learned.slice(0, MAX_MEMORY_SIZE);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await writeFile(MEMORY_FILE, JSON.stringify(this.memory, null, 2));
|
|
119
|
+
this.emit('saved', this.memory.learned.length);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('Failed to save agent memory:', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if we've solved something similar before
|
|
127
|
+
*/
|
|
128
|
+
async recall(goal: string, currentScreen: string): Promise<LearnedAction | null> {
|
|
129
|
+
await this.load();
|
|
130
|
+
|
|
131
|
+
const goalLower = goal.toLowerCase();
|
|
132
|
+
const screenLower = currentScreen.toLowerCase();
|
|
133
|
+
|
|
134
|
+
// Find candidates with similar goals and situations
|
|
135
|
+
const candidates = this.memory.learned.filter(m => {
|
|
136
|
+
const goalSimilarity = this.calculateSimilarity(goalLower, m.goal.toLowerCase());
|
|
137
|
+
const screenSimilarity = this.calculateSimilarity(screenLower, m.situation.toLowerCase());
|
|
138
|
+
|
|
139
|
+
// Need reasonable match on goal and some match on screen
|
|
140
|
+
return goalSimilarity > 0.5 && screenSimilarity > 0.3;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (candidates.length === 0) return null;
|
|
144
|
+
|
|
145
|
+
// Sort by success rate and recency
|
|
146
|
+
candidates.sort((a, b) => {
|
|
147
|
+
const aScore = (a.successCount - a.failCount) + (a.successCount * 0.1);
|
|
148
|
+
const bScore = (b.successCount - b.failCount) + (b.successCount * 0.1);
|
|
149
|
+
return bScore - aScore;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const best = candidates[0];
|
|
153
|
+
|
|
154
|
+
// Only return if it has a positive track record
|
|
155
|
+
if (best.successCount > best.failCount) {
|
|
156
|
+
this.emit('recalled', best);
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Simple word-based similarity (Jaccard-like)
|
|
165
|
+
*/
|
|
166
|
+
private calculateSimilarity(a: string, b: string): number {
|
|
167
|
+
const wordsA = new Set(a.split(/\s+/).filter(w => w.length > 2));
|
|
168
|
+
const wordsB = new Set(b.split(/\s+/).filter(w => w.length > 2));
|
|
169
|
+
|
|
170
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
171
|
+
|
|
172
|
+
let intersection = 0;
|
|
173
|
+
for (const word of wordsA) {
|
|
174
|
+
if (wordsB.has(word)) intersection++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
178
|
+
return intersection / union;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Learn from a successful action
|
|
183
|
+
*/
|
|
184
|
+
async learn(
|
|
185
|
+
situation: string,
|
|
186
|
+
goal: string,
|
|
187
|
+
actionType: string,
|
|
188
|
+
actionValue: string,
|
|
189
|
+
source: string
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
await this.load();
|
|
192
|
+
|
|
193
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
194
|
+
const now = new Date().toISOString();
|
|
195
|
+
|
|
196
|
+
// Check if we already have a similar entry
|
|
197
|
+
const existing = this.memory.learned.find(m =>
|
|
198
|
+
m.goal.toLowerCase() === goal.toLowerCase() &&
|
|
199
|
+
m.actionType === actionType &&
|
|
200
|
+
m.actionValue === actionValue
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (existing) {
|
|
204
|
+
existing.successCount++;
|
|
205
|
+
existing.lastUsed = now;
|
|
206
|
+
this.emit('reinforced', existing);
|
|
207
|
+
} else {
|
|
208
|
+
const learned: LearnedAction = {
|
|
209
|
+
id,
|
|
210
|
+
situation: situation.slice(0, 500), // Truncate long descriptions
|
|
211
|
+
goal,
|
|
212
|
+
solution: `${actionType}: ${actionValue}`,
|
|
213
|
+
actionType,
|
|
214
|
+
actionValue,
|
|
215
|
+
source,
|
|
216
|
+
successCount: 1,
|
|
217
|
+
failCount: 0,
|
|
218
|
+
lastUsed: now,
|
|
219
|
+
created: now,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
this.memory.learned.push(learned);
|
|
223
|
+
this.memory.stats.totalLearned++;
|
|
224
|
+
this.memory.stats.sourceCounts[source] = (this.memory.stats.sourceCounts[source] || 0) + 1;
|
|
225
|
+
this.emit('learned', learned);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await this.save();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Record a failed attempt
|
|
233
|
+
*/
|
|
234
|
+
async recordFailure(goal: string, actionType: string, actionValue: string): Promise<void> {
|
|
235
|
+
await this.load();
|
|
236
|
+
|
|
237
|
+
const existing = this.memory.learned.find(m =>
|
|
238
|
+
m.goal.toLowerCase() === goal.toLowerCase() &&
|
|
239
|
+
m.actionType === actionType &&
|
|
240
|
+
m.actionValue === actionValue
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (existing) {
|
|
244
|
+
existing.failCount++;
|
|
245
|
+
await this.save();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get help from multiple AI sources when stuck
|
|
251
|
+
*/
|
|
252
|
+
async getHelp(
|
|
253
|
+
goal: string,
|
|
254
|
+
currentScreen: string,
|
|
255
|
+
triedActions: string[]
|
|
256
|
+
): Promise<Suggestion[]> {
|
|
257
|
+
this.emit('seeking_help', { goal, triedActions: triedActions.length });
|
|
258
|
+
|
|
259
|
+
const query = this.buildHelpQuery(goal, currentScreen, triedActions);
|
|
260
|
+
const suggestions: Suggestion[] = [];
|
|
261
|
+
|
|
262
|
+
// Try multiple sources in parallel
|
|
263
|
+
const sources = await Promise.allSettled([
|
|
264
|
+
this.askOwnAI(query),
|
|
265
|
+
this.askPerplexity(goal, currentScreen),
|
|
266
|
+
this.askWebSearch(goal),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
for (const result of sources) {
|
|
270
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
271
|
+
suggestions.push(result.value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Sort by confidence
|
|
276
|
+
suggestions.sort((a, b) => b.confidence - a.confidence);
|
|
277
|
+
|
|
278
|
+
this.emit('got_help', suggestions.length);
|
|
279
|
+
return suggestions;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private buildHelpQuery(goal: string, screen: string, tried: string[]): string {
|
|
283
|
+
return `I'm trying to: ${goal}
|
|
284
|
+
|
|
285
|
+
Current screen shows: ${screen.slice(0, 500)}
|
|
286
|
+
|
|
287
|
+
Actions I've already tried:
|
|
288
|
+
${tried.length > 0 ? tried.slice(-5).join('\n') : 'None yet'}
|
|
289
|
+
|
|
290
|
+
What's the SINGLE next action I should take?
|
|
291
|
+
Be very specific - tell me exactly what to click, what to type, or what key to press.
|
|
292
|
+
|
|
293
|
+
Respond in this format:
|
|
294
|
+
ACTION: <click|type|press|navigate|scroll|wait>
|
|
295
|
+
VALUE: <what to click on / text to type / key to press / URL to navigate to>
|
|
296
|
+
REASONING: <brief explanation>`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Ask our configured AI provider for help
|
|
301
|
+
*/
|
|
302
|
+
private async askOwnAI(query: string): Promise<Suggestion | null> {
|
|
303
|
+
try {
|
|
304
|
+
const response = await chat([{ role: 'user', content: query }]);
|
|
305
|
+
return this.parseSuggestion(response.content, 'own_ai', 0.8);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Ask Perplexity by opening browser (uses existing browser.ts)
|
|
313
|
+
*/
|
|
314
|
+
private async askPerplexity(goal: string, screen: string): Promise<Suggestion | null> {
|
|
315
|
+
try {
|
|
316
|
+
// Import browser module dynamically to avoid circular deps
|
|
317
|
+
const browser = await import('../services/browser.js');
|
|
318
|
+
|
|
319
|
+
const question = `How do I ${goal}? I can see ${screen.slice(0, 200)}. Give me the exact next step.`;
|
|
320
|
+
const result = await browser.askAI('perplexity', question);
|
|
321
|
+
|
|
322
|
+
if (result.response) {
|
|
323
|
+
return {
|
|
324
|
+
action: 'suggested',
|
|
325
|
+
value: result.response.slice(0, 500),
|
|
326
|
+
reasoning: 'From Perplexity web search',
|
|
327
|
+
source: 'perplexity',
|
|
328
|
+
confidence: 0.7,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
// Perplexity might not be available
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Search Google for help
|
|
339
|
+
*/
|
|
340
|
+
private async askWebSearch(goal: string): Promise<Suggestion | null> {
|
|
341
|
+
try {
|
|
342
|
+
const browser = await import('../services/browser.js');
|
|
343
|
+
const searchQuery = `how to ${goal} step by step`;
|
|
344
|
+
const result = await browser.webSearch(searchQuery);
|
|
345
|
+
|
|
346
|
+
if (result.description) {
|
|
347
|
+
return {
|
|
348
|
+
action: 'suggested',
|
|
349
|
+
value: result.description.slice(0, 500),
|
|
350
|
+
reasoning: 'From web search results',
|
|
351
|
+
source: 'google',
|
|
352
|
+
confidence: 0.5,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// Web search might fail
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Parse AI response into structured suggestion
|
|
363
|
+
*/
|
|
364
|
+
private parseSuggestion(content: string, source: string, baseConfidence: number): Suggestion | null {
|
|
365
|
+
const actionMatch = content.match(/ACTION:\s*(\w+)/i);
|
|
366
|
+
const valueMatch = content.match(/VALUE:\s*(.+?)(?:\n|$)/i);
|
|
367
|
+
const reasoningMatch = content.match(/REASONING:\s*(.+?)(?:\n|$)/i);
|
|
368
|
+
|
|
369
|
+
if (!actionMatch) return null;
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
action: actionMatch[1].toLowerCase(),
|
|
373
|
+
value: valueMatch?.[1]?.trim() || '',
|
|
374
|
+
reasoning: reasoningMatch?.[1]?.trim() || 'No reasoning provided',
|
|
375
|
+
source,
|
|
376
|
+
confidence: baseConfidence,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Ask Claude.ai via browser automation
|
|
382
|
+
*/
|
|
383
|
+
async askClaude(query: string): Promise<Suggestion | null> {
|
|
384
|
+
try {
|
|
385
|
+
const browser = await import('../services/browser.js');
|
|
386
|
+
|
|
387
|
+
// Open Claude.ai
|
|
388
|
+
await browser.openUrl('https://claude.ai');
|
|
389
|
+
await this.sleep(3000);
|
|
390
|
+
|
|
391
|
+
// Type the query
|
|
392
|
+
await computer.typeText(query);
|
|
393
|
+
await this.sleep(500);
|
|
394
|
+
await computer.pressKey('Return');
|
|
395
|
+
|
|
396
|
+
// Wait for response
|
|
397
|
+
await this.sleep(8000);
|
|
398
|
+
|
|
399
|
+
// Capture and analyze the response
|
|
400
|
+
const screen = await describeScreen();
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
action: 'suggested',
|
|
404
|
+
value: screen.description.slice(0, 500),
|
|
405
|
+
reasoning: 'From Claude.ai',
|
|
406
|
+
source: 'claude_web',
|
|
407
|
+
confidence: 0.75,
|
|
408
|
+
};
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Ask ChatGPT via browser automation
|
|
416
|
+
*/
|
|
417
|
+
async askChatGPT(query: string): Promise<Suggestion | null> {
|
|
418
|
+
try {
|
|
419
|
+
const browser = await import('../services/browser.js');
|
|
420
|
+
|
|
421
|
+
// Open ChatGPT
|
|
422
|
+
await browser.openUrl('https://chat.openai.com');
|
|
423
|
+
await this.sleep(3000);
|
|
424
|
+
|
|
425
|
+
// Type the query
|
|
426
|
+
await computer.typeText(query);
|
|
427
|
+
await this.sleep(500);
|
|
428
|
+
await computer.pressKey('Return');
|
|
429
|
+
|
|
430
|
+
// Wait for response
|
|
431
|
+
await this.sleep(8000);
|
|
432
|
+
|
|
433
|
+
// Capture and analyze the response
|
|
434
|
+
const screen = await describeScreen();
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
action: 'suggested',
|
|
438
|
+
value: screen.description.slice(0, 500),
|
|
439
|
+
reasoning: 'From ChatGPT',
|
|
440
|
+
source: 'chatgpt_web',
|
|
441
|
+
confidence: 0.75,
|
|
442
|
+
};
|
|
443
|
+
} catch (error) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private sleep(ms: number): Promise<void> {
|
|
449
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Get memory stats
|
|
454
|
+
*/
|
|
455
|
+
getStats(): AgentMemory['stats'] & { memorySize: number } {
|
|
456
|
+
return {
|
|
457
|
+
...this.memory.stats,
|
|
458
|
+
memorySize: this.memory.learned.length,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Get all learned actions (for debugging/display)
|
|
464
|
+
*/
|
|
465
|
+
getAllLearned(): LearnedAction[] {
|
|
466
|
+
return [...this.memory.learned];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Clear all memory (for testing)
|
|
471
|
+
*/
|
|
472
|
+
async clearMemory(): Promise<void> {
|
|
473
|
+
this.memory = this.createEmptyMemory();
|
|
474
|
+
await this.save();
|
|
475
|
+
this.emit('cleared');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Singleton instance
|
|
480
|
+
let learnerInstance: AgentLearner | null = null;
|
|
481
|
+
|
|
482
|
+
export function getLearner(): AgentLearner {
|
|
483
|
+
if (!learnerInstance) {
|
|
484
|
+
learnerInstance = new AgentLearner();
|
|
485
|
+
}
|
|
486
|
+
return learnerInstance;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export default AgentLearner;
|