goblin-agents 2.1.0 → 2.2.2

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.
@@ -12,8 +12,9 @@ const {
12
12
  record,
13
13
  } = require('xcraft-core-stones');
14
14
  const {getMutex} = require('xcraft-core-utils/lib/locks.js');
15
- const LLMProvider = require('./providers.js');
15
+ const LLMProviders = require('./providers.js');
16
16
  const {OCR_SYSTEM_PROMPT, getPDFImages, splitThinkTags} = require('./utils.js');
17
+ const Logger = require('../logger.js');
17
18
 
18
19
  class MessageShape {
19
20
  role = enumeration('assistant', 'user', 'tool');
@@ -129,6 +130,17 @@ function vectorToSqlLiteralInt8(vec) {
129
130
  return hex;
130
131
  }
131
132
 
133
+ function cleanText(text) {
134
+ if (typeof text !== 'string') {
135
+ return text;
136
+ }
137
+ const {thinks, cleanText} = splitThinkTags(text);
138
+ for (const think of thinks) {
139
+ this.log.dbg(`🧐 ${think}`);
140
+ }
141
+ return cleanText;
142
+ }
143
+
132
144
  class AiAgentShape {
133
145
  id = id('aiAgent');
134
146
  version = number;
@@ -294,7 +306,7 @@ class AiAgent extends Elf {
294
306
 
295
307
  _provider;
296
308
  _conf;
297
- _batchLock = getMutex;
309
+ _mutex = getMutex;
298
310
 
299
311
  /**
300
312
  * @param {string} id
@@ -341,7 +353,7 @@ class AiAgent extends Elf {
341
353
  return;
342
354
  }
343
355
 
344
- const Instance = LLMProvider.get(this.state.provider);
356
+ const Instance = LLMProviders.get(this.state.provider);
345
357
  this._provider = new Instance(this.state.host, this.state.headers.toJS());
346
358
  this._conf = {provider, host, headersStr};
347
359
  }
@@ -367,27 +379,45 @@ class AiAgent extends Elf {
367
379
  await this.persist();
368
380
  }
369
381
 
370
- async gen(prompt) {
382
+ async gen(prompt, contextId = 'default') {
383
+ const logger = Logger.get(contextId, this);
384
+
385
+ await this._mutex.lock(this.id);
386
+ this.quest.defer(() => this._mutex.unlock(this.id));
387
+
371
388
  await this._loadProvider();
372
389
 
373
390
  let options;
374
391
  if (this.state.options) {
375
392
  options = this.state.options.toJS();
376
393
  }
377
- const res = await this._provider.gen({
394
+ const format = this.state.format
395
+ ? typeof this.state.format === 'string'
396
+ ? this.state.format
397
+ : this.state.format.toJS()
398
+ : undefined;
399
+ let res = await this._provider.gen({
378
400
  model: this.state.model,
379
401
  reasoning: this.state.reasoning,
380
402
  prompt,
381
403
  system: this.state.prompt,
382
- format: this.state.format?.toJS(),
404
+ format,
383
405
  options,
384
406
  stream: false,
385
407
  });
386
- const {thinks, cleanText} = splitThinkTags(res);
387
- for (const think of thinks) {
388
- this.log.dbg(`🧐 ${think}`);
389
- }
390
- return cleanText;
408
+
409
+ logger.push('aiAgent.gen', {
410
+ id: this.id,
411
+ model: this.state.model,
412
+ options: Object.entries(options).reduce(
413
+ (out, opt) => `${out} ${opt[0]}=${opt[1]}`,
414
+ ''
415
+ ),
416
+ reasoning: this.state.reasoning,
417
+ format: format || 'N/A',
418
+ });
419
+
420
+ return cleanText(res);
391
421
  }
392
422
 
393
423
  async reset(contextId, save = false) {
@@ -404,6 +434,9 @@ class AiAgent extends Elf {
404
434
  }
405
435
 
406
436
  async readPDFpages(pdfPath) {
437
+ await this._mutex.lock(this.id);
438
+ this.quest.defer(() => this._mutex.unlock(this.id));
439
+
407
440
  await this._loadProvider();
408
441
 
409
442
  const images = await getPDFImages(pdfPath);
@@ -440,6 +473,10 @@ class AiAgent extends Elf {
440
473
  if (!rawText) {
441
474
  return null;
442
475
  }
476
+
477
+ await this._mutex.lock(this.id);
478
+ this.quest.defer(() => this._mutex.unlock(this.id));
479
+
443
480
  await this._loadProvider();
444
481
  const vectors = await this._provider.embed({
445
482
  model: this.state.model,
@@ -457,8 +494,8 @@ class AiAgent extends Elf {
457
494
  return [];
458
495
  }
459
496
 
460
- await this._batchLock.lock(this.id);
461
- this.quest.defer(() => this._batchLock.unlock(this.id));
497
+ await this._mutex.lock(this.id);
498
+ this.quest.defer(() => this._mutex.unlock(this.id));
462
499
 
463
500
  await this._loadProvider();
464
501
  const res = await this._provider.embedInBatch({
@@ -477,6 +514,11 @@ class AiAgent extends Elf {
477
514
  }
478
515
 
479
516
  async chat(contextId, question, userDesktopId) {
517
+ const logger = Logger.get(contextId, this);
518
+
519
+ await this._mutex.lock(this.id);
520
+ this.quest.defer(() => this._mutex.unlock(this.id));
521
+
480
522
  await this._loadProvider();
481
523
  const contextMessages = this.state.messages[contextId] || [];
482
524
  const messages = [
@@ -509,6 +551,18 @@ class AiAgent extends Elf {
509
551
 
510
552
  const result = await this._provider.chat(chatCompletionRequest);
511
553
 
554
+ logger.push('aiAgent.chat', {
555
+ id: this.id,
556
+ model: chatCompletionRequest.model,
557
+ options: Object.entries(chatCompletionRequest.options).reduce(
558
+ (out, opt) => `${out} ${opt[0]}=${opt[1]}`,
559
+ ''
560
+ ),
561
+ question,
562
+ reasoning: result.message?.reasoning,
563
+ callTools: !!result.message.tool_calls?.length,
564
+ });
565
+
512
566
  if (this.state.toolServiceId && this.state.tools) {
513
567
  const used = await this.callTools(
514
568
  contextId,
@@ -522,23 +576,22 @@ class AiAgent extends Elf {
522
576
  }
523
577
  }
524
578
 
525
- //keep agent response
526
- const {thinks, cleanText} = splitThinkTags(result.message.content);
527
- this.logic.chat(contextId, 'assistant', cleanText);
528
- for (const think of thinks) {
529
- this.log.dbg(`🧐 ${think}`);
579
+ const res = cleanText(result.message.content);
580
+ if (typeof res === 'string') {
581
+ //keep agent response
582
+ this.logic.chat(contextId, 'assistant', cleanText);
530
583
  }
531
- this.log.dbg(`🐈 Chat session ended : ${contextId}`);
532
584
 
533
- return cleanText;
585
+ this.log.dbg(`🐈 Chat session ended : ${contextId}`);
586
+ return res;
534
587
  }
535
588
 
536
589
  async callTools(contextId, message, userDesktopId) {
537
- const {tool_calls, content} = message;
590
+ const {tool_calls} = message;
538
591
  if (!tool_calls || !userDesktopId) {
539
592
  return false;
540
593
  }
541
- this.logic.callTools(contextId, content, tool_calls);
594
+ this.logic.callTools(contextId, tool_calls);
542
595
  for (const tool of tool_calls) {
543
596
  const {
544
597
  id,
@@ -585,6 +638,10 @@ class AiAgent extends Elf {
585
638
  if (this.state.provider !== 'ollama') {
586
639
  throw new Error('Streaming API not impl.');
587
640
  }
641
+
642
+ await this._mutex.lock(this.id);
643
+ this.quest.defer(() => this._mutex.unlock(this.id));
644
+
588
645
  await this._loadProvider();
589
646
 
590
647
  const contextMessages = this.state.messages[contextId] || [];
@@ -619,6 +676,9 @@ class AiAgent extends Elf {
619
676
  }
620
677
 
621
678
  async resumeExchange(resumePrompt, contextId) {
679
+ await this._mutex.lock(this.id);
680
+ this.quest.defer(() => this._mutex.unlock(this.id));
681
+
622
682
  await this._loadProvider();
623
683
  const contextMessages = this.state.messages[contextId] || [];
624
684
 
@@ -643,6 +703,9 @@ class AiAgent extends Elf {
643
703
  }
644
704
 
645
705
  async react(reactPrompt, contextId, question) {
706
+ await this._mutex.lock(this.id);
707
+ this.quest.defer(() => this._mutex.unlock(this.id));
708
+
646
709
  await this._loadProvider();
647
710
  const feedId = await this.newQuestFeed();
648
711
  const contextMessages = this.state.messages[contextId] || [];
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ const {Ollama} = require('ollama');
3
+ const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
4
+ const LLMProvider = require('./provider.js');
5
+
6
+ class OllamaProvider extends LLMProvider {
7
+ #semEmbed = new Semaphore(4);
8
+ #ollama;
9
+
10
+ constructor(host, headers) {
11
+ super();
12
+ this.#ollama = new Ollama({
13
+ host,
14
+ headers,
15
+ });
16
+ }
17
+
18
+ async chat(chatRequest) {
19
+ const {
20
+ model,
21
+ messages,
22
+ options,
23
+ format,
24
+ tools,
25
+ stream,
26
+ reasoning,
27
+ } = chatRequest;
28
+ const useFormat = !!format;
29
+ const chat = await this.#ollama.chat({
30
+ model,
31
+ messages,
32
+ options,
33
+ format,
34
+ tools,
35
+ stream,
36
+ think: reasoning,
37
+ });
38
+ if (useFormat) {
39
+ chat.message.content = JSON.parse(chat.message.content);
40
+ return chat;
41
+ } else {
42
+ return chat;
43
+ }
44
+ }
45
+
46
+ async gen(genRequest) {
47
+ const {
48
+ model,
49
+ system,
50
+ prompt,
51
+ format,
52
+ options,
53
+ stream,
54
+ reasoning,
55
+ } = genRequest;
56
+ const useFormat = !!format;
57
+ const gen = await this.#ollama.generate({
58
+ model,
59
+ prompt,
60
+ system,
61
+ format,
62
+ options,
63
+ stream,
64
+ think: reasoning,
65
+ });
66
+ if (useFormat) {
67
+ return JSON.parse(gen.response);
68
+ } else {
69
+ return gen.response;
70
+ }
71
+ }
72
+
73
+ async embed(embedRequest) {
74
+ try {
75
+ await this.#semEmbed.wait();
76
+ const {model, input} = embedRequest;
77
+ const {embeddings} = await this.#ollama.embed({
78
+ model,
79
+ input,
80
+ });
81
+ return embeddings[0];
82
+ } finally {
83
+ this.#semEmbed.signal();
84
+ }
85
+ }
86
+
87
+ async embedInBatch(embedRequest) {
88
+ const {model, input} = embedRequest;
89
+ const {embeddings} = await this.#ollama.embed({
90
+ model,
91
+ input,
92
+ });
93
+ return embeddings;
94
+ }
95
+ }
96
+
97
+ module.exports = OllamaProvider;
@@ -0,0 +1,101 @@
1
+ // @ts-check
2
+ const {RestAPI} = require('xcraft-core-utils');
3
+ const LLMProvider = require('./provider.js');
4
+
5
+ class OpenAIProvider extends LLMProvider {
6
+ #baseUrl;
7
+ #openAI;
8
+
9
+ constructor(host = 'https://openrouter.ai/api/v1', headers) {
10
+ super();
11
+ this.#baseUrl = host;
12
+ this.#openAI = new RestAPI(300000, headers);
13
+ }
14
+
15
+ async chat(chatRequest) {
16
+ delete chatRequest.reasoning; //not yet supported
17
+ if (chatRequest.options) {
18
+ chatRequest = {...chatRequest, ...chatRequest.options};
19
+ delete chatRequest.options;
20
+ }
21
+ if (!chatRequest.tools) {
22
+ delete chatRequest.tools;
23
+ }
24
+ if (!chatRequest.format) {
25
+ delete chatRequest.format;
26
+ }
27
+ const useFormat = chatRequest?.format !== undefined;
28
+ if (useFormat) {
29
+ chatRequest.response_format = {
30
+ type: 'json_schema',
31
+ json_schema: {
32
+ name: 'default',
33
+ schema: {
34
+ type: 'object',
35
+ properties: {
36
+ results: {...chatRequest.format},
37
+ },
38
+ required: ['results'],
39
+ },
40
+ },
41
+ };
42
+
43
+ delete chatRequest.format;
44
+ }
45
+ const query = `${this.#baseUrl}/chat/completions`;
46
+ const chat = await this.#openAI._post(query, chatRequest, {
47
+ retry: {
48
+ methods: ['POST'],
49
+ },
50
+ });
51
+ const {choices} = chat;
52
+ if (chat.error) {
53
+ const err = new Error(
54
+ chat.error?.metadata
55
+ ? JSON.stringify(chat.error?.metadata)
56
+ : chat.error.message
57
+ );
58
+ err.code = chat.error.code;
59
+ throw err;
60
+ }
61
+ if (useFormat) {
62
+ choices[0].message.content = JSON.parse(
63
+ choices[0].message.content
64
+ ).results;
65
+ return choices[0];
66
+ } else {
67
+ return choices[0];
68
+ }
69
+ }
70
+
71
+ async gen(genRequest) {
72
+ const {model, system, prompt, format, tools, options, stream} = genRequest;
73
+ const chatRequest = {
74
+ model,
75
+ messages: [
76
+ {role: 'system', content: system},
77
+ {role: 'user', content: prompt},
78
+ ],
79
+ options,
80
+ format,
81
+ tools,
82
+ stream,
83
+ };
84
+ const chat = await this.chat(chatRequest);
85
+ return chat.message.content;
86
+ }
87
+
88
+ async embed(embedRequest) {
89
+ const query = `${this.#baseUrl}/v1/embeddings`;
90
+ const {data} = await this.#openAI._post(query, embedRequest);
91
+ return data[0].embedding;
92
+ }
93
+
94
+ async embedInBatch(embedRequest) {
95
+ const query = `${this.#baseUrl}/v1/embeddings`;
96
+ const {data} = await this.#openAI._post(query, embedRequest);
97
+ return data;
98
+ }
99
+ }
100
+
101
+ module.exports = OpenAIProvider;
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+
3
+ class LLMProvider {
4
+ constructor() {
5
+ if (new.target === LLMProvider) {
6
+ throw new Error('Cannot instantiate an abstract class.');
7
+ }
8
+ }
9
+
10
+ async chatStream() {
11
+ throw new Error('not impl.');
12
+ }
13
+
14
+ /**
15
+ * @returns {Promise<*>}
16
+ */
17
+ async chat() {
18
+ throw new Error('not impl.');
19
+ }
20
+
21
+ /**
22
+ * @returns {Promise<*>}
23
+ */
24
+ async gen() {
25
+ throw new Error('not impl.');
26
+ }
27
+
28
+ /**
29
+ * @returns {Promise<*>}
30
+ */
31
+ async embed() {
32
+ throw new Error('not impl.');
33
+ }
34
+ }
35
+
36
+ module.exports = LLMProvider;
@@ -1,8 +1,8 @@
1
- const {Ollama} = require('ollama');
2
- const {RestAPI} = require('xcraft-core-utils');
3
- const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
1
+ // @ts-check
2
+ const OllamaProvider = require('./providers/ollama.js');
3
+ const OpenAIProvider = require('./providers/opanAi.js');
4
4
 
5
- class LLMProvider {
5
+ class LLMProviders {
6
6
  static get(name) {
7
7
  switch (name) {
8
8
  default:
@@ -12,215 +12,6 @@ class LLMProvider {
12
12
  return OpenAIProvider;
13
13
  }
14
14
  }
15
-
16
- constructor() {
17
- if (new.target === LLMProvider) {
18
- throw new Error('Cannot instantiate an abstract class.');
19
- }
20
- }
21
-
22
- async chatStream() {
23
- throw new Error('not impl.');
24
- }
25
-
26
- async chat() {
27
- throw new Error('not impl.');
28
- }
29
-
30
- async gen() {
31
- throw new Error('not impl.');
32
- }
33
-
34
- async embed() {
35
- throw new Error('not impl.');
36
- }
37
- }
38
-
39
- class OllamaProvider extends LLMProvider {
40
- #semEmbed = new Semaphore(4);
41
- #ollama;
42
-
43
- constructor(host, headers) {
44
- super();
45
- this.#ollama = new Ollama({
46
- host,
47
- headers,
48
- });
49
- }
50
-
51
- async chat(chatRequest) {
52
- const {
53
- model,
54
- messages,
55
- options,
56
- format,
57
- tools,
58
- stream,
59
- reasoning,
60
- } = chatRequest;
61
- const useFormat = !!format;
62
- const chat = await this.#ollama.chat({
63
- model,
64
- messages,
65
- options,
66
- format,
67
- tools,
68
- stream,
69
- think: reasoning,
70
- });
71
- if (useFormat) {
72
- chat.message.content = JSON.parse(chat.message.content);
73
- return chat;
74
- } else {
75
- return chat;
76
- }
77
- }
78
-
79
- async gen(genRequest) {
80
- const {
81
- model,
82
- system,
83
- prompt,
84
- format,
85
- options,
86
- stream,
87
- reasoning,
88
- } = genRequest;
89
- const useFormat = !!format;
90
- const gen = await this.#ollama.generate({
91
- model,
92
- prompt,
93
- system,
94
- format,
95
- options,
96
- stream,
97
- think: reasoning,
98
- });
99
- if (useFormat) {
100
- return JSON.parse(gen.response);
101
- } else {
102
- return gen.response;
103
- }
104
- }
105
-
106
- async embed(embedRequest) {
107
- try {
108
- await this.#semEmbed.wait();
109
- const {model, input} = embedRequest;
110
- const {embeddings} = await this.#ollama.embed({
111
- model,
112
- input,
113
- });
114
- return embeddings[0];
115
- } finally {
116
- this.#semEmbed.signal();
117
- }
118
- }
119
-
120
- async embedInBatch(embedRequest) {
121
- const {model, input} = embedRequest;
122
- const {embeddings} = await this.#ollama.embed({
123
- model,
124
- input,
125
- });
126
- return embeddings;
127
- }
128
- }
129
-
130
- class OpenAIProvider extends LLMProvider {
131
- #baseUrl;
132
- #openAI;
133
-
134
- constructor(host = 'https://openrouter.ai/api/v1', headers) {
135
- super();
136
- this.#baseUrl = host;
137
- this.#openAI = new RestAPI(300000, headers);
138
- }
139
-
140
- async chat(chatRequest) {
141
- delete chatRequest.reasoning; //not yet supported
142
- if (chatRequest.options) {
143
- chatRequest = {...chatRequest, ...chatRequest.options};
144
- delete chatRequest.options;
145
- }
146
- if (!chatRequest.tools) {
147
- delete chatRequest.tools;
148
- }
149
- if (!chatRequest.format) {
150
- delete chatRequest.format;
151
- }
152
- const useFormat = chatRequest?.format !== undefined;
153
- if (useFormat) {
154
- chatRequest.response_format = {
155
- type: 'json_schema',
156
- json_schema: {
157
- name: 'default',
158
- schema: {
159
- type: 'object',
160
- properties: {
161
- results: {...chatRequest.format},
162
- },
163
- required: ['results'],
164
- },
165
- },
166
- };
167
-
168
- delete chatRequest.format;
169
- }
170
- const query = `${this.#baseUrl}/chat/completions`;
171
- const chat = await this.#openAI._post(query, chatRequest, {
172
- retry: {
173
- methods: ['POST'],
174
- },
175
- });
176
- const {choices} = chat;
177
- if (chat.error) {
178
- const err = new Error(
179
- chat.error?.metadata
180
- ? JSON.stringify(chat.error?.metadata)
181
- : chat.error.message
182
- );
183
- err.code = chat.error.code;
184
- throw err;
185
- }
186
- if (useFormat) {
187
- choices[0].message.content = JSON.parse(
188
- choices[0].message.content
189
- ).results;
190
- return choices[0];
191
- } else {
192
- return choices[0];
193
- }
194
- }
195
-
196
- async gen(genRequest) {
197
- const {model, system, prompt, format, tools, options, stream} = genRequest;
198
- const chatRequest = {
199
- model,
200
- messages: [
201
- {role: 'system', content: system},
202
- {role: 'user', content: prompt},
203
- ],
204
- options,
205
- format,
206
- tools,
207
- stream,
208
- };
209
- const chat = await this.chat(chatRequest);
210
- return chat.message.content;
211
- }
212
-
213
- async embed(embedRequest) {
214
- const query = `${this.#baseUrl}/v1/embeddings`;
215
- const {data} = await this.#openAI._post(query, embedRequest);
216
- return data[0].embedding;
217
- }
218
-
219
- async embedInBatch(embedRequest) {
220
- const query = `${this.#baseUrl}/v1/embeddings`;
221
- const {data} = await this.#openAI._post(query, embedRequest);
222
- return data;
223
- }
224
15
  }
225
16
 
226
- module.exports = LLMProvider;
17
+ module.exports = LLMProviders;
package/lib/llm/utils.js CHANGED
@@ -236,8 +236,23 @@ function groupSentences(text, maxSize) {
236
236
  return chunks;
237
237
  }
238
238
 
239
+ /**
240
+ * Construit un prompt à partir d'une liste d'articles, avec gestion du format et de la limite de tokens.
241
+ *
242
+ * @param {ArticleShape[]} articles - Liste des articles sources utilisés pour générer le prompt.
243
+ * @param {"text"|"json"} [format="text"] - Format de sortie attendu :
244
+ * - `"text"` → prompt en texte brut
245
+ * - `"json"` → prompt structuré en JSON
246
+ * @param {number} [maxTotalTokens] - Nombre maximal de tokens autorisés dans le prompt.
247
+ * Si non défini, aucun contrôle n'est appliqué.
248
+ * @param {function} [dbg=() => null] - Fonction de débogage optionnelle appelée
249
+ * avec des informations intermédiaires (signature `(info: any) => void`).
250
+ * @returns {string} - Le prompt généré, soit en texte brut (string),
251
+ * soit en objet JSON stringifié selon le format choisi.
252
+ */
239
253
  function buildPromptFromArticlesMulti(
240
254
  articles,
255
+ format = 'text',
241
256
  maxTotalTokens,
242
257
  dbg = () => null
243
258
  ) {
@@ -247,15 +262,23 @@ function buildPromptFromArticlesMulti(
247
262
  let sections = [];
248
263
  let sectionTokenCount = 0;
249
264
  for (const article of articles) {
250
- const {kind, title, sourceId, distance, combinedScore, chunk} = article;
251
- const articleBlock = JSON.stringify({
252
- kind,
253
- title,
254
- url: sourceId.startsWith('http') ? sourceId : null,
255
- distance,
256
- combinedScore,
257
- chunk,
258
- });
265
+ const {kind, title, sourceId, description, finalScore} = article;
266
+
267
+ let articleBlock = '';
268
+ if (format === 'json') {
269
+ articleBlock = JSON.stringify({
270
+ kind,
271
+ title,
272
+ url: sourceId.startsWith('http') ? sourceId : null,
273
+ finalScore,
274
+ description,
275
+ });
276
+ } else {
277
+ articleBlock += `# ${title}\n\n`;
278
+ articleBlock += `**score :** ${finalScore}\n\n`;
279
+ articleBlock += `\`\`\`html\n${description}\n\`\`\`\n\n`;
280
+ }
281
+
259
282
  dbg(articleBlock);
260
283
  const tokensForArticle = estimateTokenCount(articleBlock);
261
284
 
@@ -268,7 +291,10 @@ function buildPromptFromArticlesMulti(
268
291
  sectionTokenCount += tokensForArticle;
269
292
  }
270
293
 
271
- return `[${sections.join(',')}]`;
294
+ if (format === 'json') {
295
+ return `[${sections.join(',')}]`;
296
+ }
297
+ return sections.join('---\n\n');
272
298
  }
273
299
 
274
300
  /**
package/lib/logger.js ADDED
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ const path = require('node:path');
3
+ const fse = require('fs-extra');
4
+ const {string, Sculpt, dateTime, object} = require('xcraft-core-stones');
5
+ const xConfig = require('xcraft-core-etc')().load('xcraft');
6
+
7
+ class MessageShape {
8
+ timestamp = dateTime;
9
+ step = string;
10
+ payload = object;
11
+ }
12
+
13
+ class Message extends Sculpt(MessageShape) {}
14
+
15
+ class Logger {
16
+ #id;
17
+
18
+ /** @type {Set<Message>} */
19
+ #messages = new Set();
20
+
21
+ /** @type {Map<string, Logger>} */
22
+ static #loggers = new Map();
23
+ static #logDir = path.join(xConfig.xcraftRoot, 'var/log/ai');
24
+
25
+ constructor(id) {
26
+ this.#id = id;
27
+ fse.ensureDirSync(Logger.#logDir);
28
+ Logger.#loggers.set(id, this);
29
+ }
30
+
31
+ /**
32
+ * @param {string} step
33
+ * @param {object} payload
34
+ */
35
+ push(step, payload) {
36
+ const timestamp = new Date().toISOString();
37
+ const msg = new Message({timestamp, step, payload});
38
+ this.#messages.add(msg);
39
+ }
40
+
41
+ async #report() {
42
+ let timestamp = new Date().toISOString();
43
+ let messages = '';
44
+
45
+ for (const message of this.#messages) {
46
+ timestamp = message.timestamp;
47
+
48
+ const messageFile = path.join(__dirname, 'report/message.html');
49
+ let reportMessage = await fse.readFile(messageFile, 'utf8');
50
+
51
+ let payload = '';
52
+ for (const [key, value] of Object.entries(message.payload)) {
53
+ const itemFile = path.join(__dirname, 'report/item.html');
54
+ let reportItem = await fse.readFile(itemFile, 'utf8');
55
+ payload += reportItem
56
+ .replace('${KEY}', key)
57
+ .replace('${VALUE}', `${value}`.replaceAll('\n', '<br/>'));
58
+ }
59
+
60
+ messages += reportMessage
61
+ .replace('${TIMESTAMP}', message.timestamp)
62
+ .replace('${STEP}', message.step)
63
+ .replace('${PAYLOAD}', payload);
64
+ }
65
+
66
+ const reportFile = path.join(__dirname, 'report/report.html');
67
+ let report = await fse.readFile(reportFile, 'utf8');
68
+ report = report
69
+ .replace('${MESSAGES}', messages)
70
+ .replace('${TITLE}', `AI reporting for ${this.#id}`);
71
+
72
+ const ts = timestamp.split(':').slice(0, 2).join('').split('-').join('');
73
+ const reportOutput = path.join(Logger.#logDir, `${this.#id}_${ts}Z.html`);
74
+ await fse.writeFile(reportOutput, report, 'utf8');
75
+ }
76
+
77
+ async dispose() {
78
+ if (this.#id && this.#id !== 'default') {
79
+ await this.#report();
80
+ }
81
+ Logger.#loggers.delete(this.#id);
82
+ }
83
+
84
+ static get(id, quest) {
85
+ if (quest.quest) {
86
+ quest = quest.quest;
87
+ }
88
+ if (Logger.#loggers.has(id)) {
89
+ const logger = Logger.#loggers.get(id);
90
+ if (logger) {
91
+ return logger;
92
+ }
93
+ }
94
+ const logger = new Logger(id);
95
+ quest.defer(async () => await logger.dispose());
96
+ return logger;
97
+ }
98
+
99
+ static get logDir() {
100
+ return Logger.#logDir;
101
+ }
102
+ }
103
+
104
+ module.exports = Logger;
@@ -0,0 +1,2 @@
1
+ <dt><strong>${KEY}:</strong></dt>
2
+ <dd>${VALUE}</dd>
@@ -0,0 +1,8 @@
1
+ <h2>${STEP}</h2>
2
+ <p>
3
+ <small><em>${TIMESTAMP}</em></small>
4
+ </p>
5
+ <dl>
6
+ ${PAYLOAD}
7
+ </dl>
8
+ <hr />
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>${TITLE}</title>
6
+ </head>
7
+ <body>
8
+ ${MESSAGES}
9
+ </body>
10
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goblin-agents",
3
- "version": "2.1.0",
3
+ "version": "2.2.2",
4
4
  "description": "AI Agents framework",
5
5
  "author": "Epsitec SA",
6
6
  "contributors": [