@elizaos/core 1.5.1 → 1.5.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.
Files changed (88) hide show
  1. package/dist/browser/index.browser.js +120 -120
  2. package/dist/browser/index.browser.js.map +5 -21
  3. package/dist/browser/index.d.ts +3 -1
  4. package/dist/index.d.ts +2 -3
  5. package/dist/index.js +1 -5
  6. package/dist/node/index.d.ts +3 -1
  7. package/package.json +10 -4
  8. package/src/__tests__/action-chaining-simple.test.ts +203 -0
  9. package/src/__tests__/actions.test.ts +218 -0
  10. package/src/__tests__/buffer.test.ts +337 -0
  11. package/src/__tests__/character-validation.test.ts +309 -0
  12. package/src/__tests__/database.test.ts +750 -0
  13. package/src/__tests__/entities.test.ts +727 -0
  14. package/src/__tests__/env.test.ts +23 -0
  15. package/src/__tests__/environment.test.ts +285 -0
  16. package/src/__tests__/logger-browser-node.test.ts +716 -0
  17. package/src/__tests__/logger.test.ts +403 -0
  18. package/src/__tests__/messages.test.ts +196 -0
  19. package/src/__tests__/mockCharacter.ts +544 -0
  20. package/src/__tests__/parsing.test.ts +58 -0
  21. package/src/__tests__/prompts.test.ts +159 -0
  22. package/src/__tests__/roles.test.ts +331 -0
  23. package/src/__tests__/runtime-embedding.test.ts +343 -0
  24. package/src/__tests__/runtime.test.ts +978 -0
  25. package/src/__tests__/search.test.ts +15 -0
  26. package/src/__tests__/services-by-type.test.ts +204 -0
  27. package/src/__tests__/services.test.ts +136 -0
  28. package/src/__tests__/settings.test.ts +810 -0
  29. package/src/__tests__/utils.test.ts +1105 -0
  30. package/src/__tests__/uuid.test.ts +94 -0
  31. package/src/actions.ts +122 -0
  32. package/src/database.ts +579 -0
  33. package/src/entities.ts +406 -0
  34. package/src/index.browser.ts +48 -0
  35. package/src/index.node.ts +39 -0
  36. package/src/index.ts +50 -0
  37. package/src/logger.ts +527 -0
  38. package/src/prompts.ts +243 -0
  39. package/src/roles.ts +85 -0
  40. package/src/runtime.ts +2514 -0
  41. package/src/schemas/character.ts +149 -0
  42. package/src/search.ts +1543 -0
  43. package/src/sentry/instrument.browser.ts +65 -0
  44. package/src/sentry/instrument.node.ts +57 -0
  45. package/src/sentry/instrument.ts +82 -0
  46. package/src/services.ts +105 -0
  47. package/src/settings.ts +409 -0
  48. package/src/test_resources/constants.ts +12 -0
  49. package/src/test_resources/testSetup.ts +21 -0
  50. package/src/test_resources/types.ts +22 -0
  51. package/src/types/agent.ts +112 -0
  52. package/src/types/browser.ts +145 -0
  53. package/src/types/components.ts +184 -0
  54. package/src/types/database.ts +348 -0
  55. package/src/types/email.ts +162 -0
  56. package/src/types/environment.ts +129 -0
  57. package/src/types/events.ts +249 -0
  58. package/src/types/index.ts +29 -0
  59. package/src/types/knowledge.ts +65 -0
  60. package/src/types/lp.ts +124 -0
  61. package/src/types/memory.ts +228 -0
  62. package/src/types/message.ts +233 -0
  63. package/src/types/messaging.ts +57 -0
  64. package/src/types/model.ts +359 -0
  65. package/src/types/pdf.ts +77 -0
  66. package/src/types/plugin.ts +78 -0
  67. package/src/types/post.ts +271 -0
  68. package/src/types/primitives.ts +97 -0
  69. package/src/types/runtime.ts +190 -0
  70. package/src/types/service.ts +198 -0
  71. package/src/types/settings.ts +30 -0
  72. package/src/types/state.ts +60 -0
  73. package/src/types/task.ts +72 -0
  74. package/src/types/tee.ts +107 -0
  75. package/src/types/testing.ts +30 -0
  76. package/src/types/token.ts +96 -0
  77. package/src/types/transcription.ts +133 -0
  78. package/src/types/video.ts +108 -0
  79. package/src/types/wallet.ts +56 -0
  80. package/src/types/web-search.ts +146 -0
  81. package/src/utils/__tests__/buffer.test.ts +80 -0
  82. package/src/utils/__tests__/environment.test.ts +58 -0
  83. package/src/utils/__tests__/stringToUuid.test.ts +88 -0
  84. package/src/utils/buffer.ts +312 -0
  85. package/src/utils/environment.ts +316 -0
  86. package/src/utils/server-health.ts +117 -0
  87. package/src/utils.ts +1076 -0
  88. package/dist/tsconfig.build.tsbuildinfo +0 -1
package/src/utils.ts ADDED
@@ -0,0 +1,1076 @@
1
+ import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
2
+ import Handlebars from 'handlebars';
3
+ import { names, uniqueNamesGenerator } from 'unique-names-generator';
4
+ import { z } from 'zod';
5
+
6
+ import logger from './logger';
7
+ import { getEnv } from './utils/environment';
8
+ import type { Content, Entity, IAgentRuntime, Memory, State, TemplateType } from './types';
9
+ import { ModelType, UUID, ContentType } from './types';
10
+
11
+ // Text Utils
12
+
13
+ /**
14
+ * Convert all double-brace bindings in a Handlebars template
15
+ * to triple-brace bindings, so the output is NOT HTML-escaped.
16
+ *
17
+ * - Ignores block/partial/comment tags that start with # / ! >.
18
+ * - Ignores the else keyword.
19
+ * - Ignores bindings that are already triple-braced.
20
+ *
21
+ * @param tpl Handlebars template source
22
+ * @return Transformed template
23
+ */
24
+ function upgradeDoubleToTriple(tpl: string) {
25
+ return tpl.replace(
26
+ // ────────╮ negative-LB: not already "{{{"
27
+ // │ {{ ─ opening braces
28
+ // │ ╰──── negative-LA: not {, #, /, !, >
29
+ // ▼
30
+ /(?<!{){{(?![{#\/!>])([\s\S]*?)}}/g,
31
+ (_match: string, inner: string) => {
32
+ // keep the block keyword {{else}} unchanged
33
+ if (inner.trim() === 'else') return `{{${inner}}}`;
34
+ return `{{{${inner}}}}`;
35
+ }
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Composes a context string by replacing placeholders in a template with corresponding values from the state.
41
+ *
42
+ * This function takes a template string with placeholders in the format `{{placeholder}}` and a state object.
43
+ * It replaces each placeholder with the value from the state object that matches the placeholder's name.
44
+ * If a matching key is not found in the state object for a given placeholder, the placeholder is replaced with an empty string.
45
+ *
46
+ * @param {Object} params - The parameters for composing the context.
47
+ * @param {State} params.state - The state object containing values to replace the placeholders in the template.
48
+ * @param {TemplateType} params.template - The template string or function containing placeholders to be replaced with state values.
49
+ * @returns {string} The composed context string with placeholders replaced by corresponding state values.
50
+ *
51
+ * @example
52
+ * // Given a state object and a template
53
+ * const state = { userName: "Alice", userAge: 30 };
54
+ * const template = "Hello, {{userName}}! You are {{userAge}} years old";
55
+ *
56
+ * // Composing the context with simple string replacement will result in:
57
+ * // "Hello, Alice! You are 30 years old."
58
+ * const contextSimple = composePromptFromState({ state, template });
59
+ *
60
+ * // Using composePromptFromState with a template function for dynamic template
61
+ * const template = ({ state }) => {
62
+ * const tone = Math.random() > 0.5 ? "kind" : "rude";
63
+ * return `Hello, {{userName}}! You are {{userAge}} years old. Be ${tone}`;
64
+ * };
65
+ * const contextSimple = composePromptFromState({ state, template });
66
+ */
67
+
68
+ /**
69
+ * Function to compose a prompt using a provided template and state.
70
+ * It compiles the template (upgrading double braces to triple braces for non-HTML escaping)
71
+ * and then populates it with values from the state. Additionally, it processes the
72
+ * resulting string with `composeRandomUser` to replace placeholders like `{{nameX}}`.
73
+ *
74
+ * @param {Object} options - Object containing state and template information.
75
+ * @param {State} options.state - The state object containing values to fill the template.
76
+ * @param {TemplateType} options.template - The template string or function to be used for composing the prompt.
77
+ * @returns {string} The composed prompt output, with state values and random user names populated.
78
+ */
79
+ export const composePrompt = ({
80
+ state,
81
+ template,
82
+ }: {
83
+ state: { [key: string]: string };
84
+ template: TemplateType;
85
+ }) => {
86
+ const templateStr = typeof template === 'function' ? template({ state }) : template;
87
+ const templateFunction = Handlebars.compile(upgradeDoubleToTriple(templateStr));
88
+ const output = composeRandomUser(templateFunction(state), 10);
89
+ return output;
90
+ };
91
+
92
+ /**
93
+ * Function to compose a prompt using a provided template and state.
94
+ *
95
+ * @param {Object} options - Object containing state and template information.
96
+ * @param {State} options.state - The state object containing values to fill the template.
97
+ * @param {TemplateType} options.template - The template to be used for composing the prompt.
98
+ * @returns {string} The composed prompt output.
99
+ */
100
+ export const composePromptFromState = ({
101
+ state,
102
+ template,
103
+ }: {
104
+ state: State;
105
+ template: TemplateType;
106
+ }) => {
107
+ const templateStr = typeof template === 'function' ? template({ state }) : template;
108
+ const templateFunction = Handlebars.compile(upgradeDoubleToTriple(templateStr));
109
+
110
+ // get any keys that are in state but are not named text, values or data
111
+ const stateKeys = Object.keys(state);
112
+ const filteredKeys = stateKeys.filter((key) => !['text', 'values', 'data'].includes(key));
113
+
114
+ // this flattens out key/values in text/values/data
115
+ const filteredState = filteredKeys.reduce((acc: Record<string, any>, key) => {
116
+ acc[key] = state[key];
117
+ return acc;
118
+ }, {});
119
+
120
+ // and then we flat state.values again
121
+ const output = composeRandomUser(templateFunction({ ...filteredState, ...state.values }), 10);
122
+ return output;
123
+ };
124
+
125
+ /**
126
+ * Adds a header to a body of text.
127
+ *
128
+ * This function takes a header string and a body string and returns a new string with the header prepended to the body.
129
+ * If the body string is empty, the header is returned as is.
130
+ *
131
+ * @param {string} header - The header to add to the body.
132
+ * @param {string} body - The body to which to add the header.
133
+ * @returns {string} The body with the header prepended.
134
+ *
135
+ * @example
136
+ * // Given a header and a body
137
+ * const header = "Header";
138
+ * const body = "Body";
139
+ *
140
+ * // Adding the header to the body will result in:
141
+ * // "Header\nBody"
142
+ * const text = addHeader(header, body);
143
+ */
144
+ export const addHeader = (header: string, body: string) => {
145
+ return body.length > 0 ? `${header ? `${header}\n` : header}${body}\n` : '';
146
+ };
147
+
148
+ /**
149
+ * Generates a string with random user names populated in a template.
150
+ *
151
+ * This function generates random user names and populates placeholders
152
+ * in the provided template with these names. Placeholders in the template should follow the format `{{userX}}`
153
+ * where `X` is the position of the user (e.g., `{{name1}}`, `{{name2}}`).
154
+ *
155
+ * @param {string} template - The template string containing placeholders for random user names.
156
+ * @param {number} length - The number of random user names to generate.
157
+ * @returns {string} The template string with placeholders replaced by random user names.
158
+ *
159
+ * @example
160
+ * // Given a template and a length
161
+ * const template = "Hello, {{name1}}! Meet {{name2}} and {{name3}}.";
162
+ * const length = 3;
163
+ *
164
+ * // Composing the random user string will result in:
165
+ * // "Hello, John! Meet Alice and Bob."
166
+ * const result = composeRandomUser(template, length);
167
+ */
168
+ const composeRandomUser = (template: string, length: number) => {
169
+ const exampleNames = Array.from({ length }, () =>
170
+ uniqueNamesGenerator({ dictionaries: [names] })
171
+ );
172
+ let result = template;
173
+ for (let i = 0; i < exampleNames.length; i++) {
174
+ result = result.replaceAll(`{{name${i + 1}}}`, exampleNames[i]);
175
+ }
176
+
177
+ return result;
178
+ };
179
+
180
+ export const formatPosts = ({
181
+ messages,
182
+ entities,
183
+ conversationHeader = true,
184
+ }: {
185
+ messages: Memory[];
186
+ entities: Entity[];
187
+ conversationHeader?: boolean;
188
+ }) => {
189
+ // Group messages by roomId
190
+ const groupedMessages: { [roomId: string]: Memory[] } = {};
191
+ messages.forEach((message) => {
192
+ if (message.roomId) {
193
+ if (!groupedMessages[message.roomId]) {
194
+ groupedMessages[message.roomId] = [];
195
+ }
196
+ groupedMessages[message.roomId].push(message);
197
+ }
198
+ });
199
+
200
+ // Sort messages within each roomId by createdAt (oldest to newest)
201
+ Object.values(groupedMessages).forEach((roomMessages) => {
202
+ roomMessages.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
203
+ });
204
+
205
+ // Sort rooms by the newest message's createdAt
206
+ const sortedRooms = Object.entries(groupedMessages).sort(
207
+ ([, messagesA], [, messagesB]) =>
208
+ (messagesB[messagesB.length - 1]?.createdAt || 0) -
209
+ (messagesA[messagesA.length - 1]?.createdAt || 0)
210
+ );
211
+
212
+ const formattedPosts = sortedRooms.map(([roomId, roomMessages]) => {
213
+ const messageStrings = roomMessages
214
+ .filter((message: Memory) => message.entityId)
215
+ .map((message: Memory) => {
216
+ const entity = entities.find((entity: Entity) => entity.id === message.entityId);
217
+ if (!entity) {
218
+ logger.warn({ entityId: message.entityId }, 'core::prompts:formatPosts - no entity for');
219
+ }
220
+ // TODO: These are okay but not great
221
+ const userName = entity?.names[0] || 'Unknown User';
222
+ const displayName = entity?.names[0] || 'unknown';
223
+
224
+ return `Name: ${userName} (@${displayName} EntityID:${message.entityId})
225
+ MessageID: ${message.id}${message.content.inReplyTo ? `\nIn reply to: ${message.content.inReplyTo}` : ''}
226
+ Source: ${message.content.source}
227
+ Date: ${formatTimestamp(message.createdAt || 0)}
228
+ Text:
229
+ ${message.content.text}`;
230
+ });
231
+
232
+ const header = conversationHeader ? `Conversation: ${roomId.slice(-5)}\n` : '';
233
+ return `${header}${messageStrings.join('\n\n')}`;
234
+ });
235
+
236
+ return formattedPosts.join('\n\n');
237
+ };
238
+
239
+ /**
240
+ * Format messages into a string
241
+ * @param {Object} params - The formatting parameters
242
+ * @param {Memory[]} params.messages - List of messages to format
243
+ * @param {Entity[]} params.entities - List of entities for name resolution
244
+ * @returns {string} Formatted message string with timestamps and user information
245
+ */
246
+ export const formatMessages = ({
247
+ messages,
248
+ entities,
249
+ }: {
250
+ messages: Memory[];
251
+ entities: Entity[];
252
+ }) => {
253
+ const messageStrings = messages
254
+ .reverse()
255
+ .filter((message: Memory) => message.entityId)
256
+ .map((message: Memory) => {
257
+ const messageText = (message.content as Content).text;
258
+
259
+ const messageActions = (message.content as Content).actions;
260
+ const messageThought = (message.content as Content).thought;
261
+ const formattedName =
262
+ entities.find((entity: Entity) => entity.id === message.entityId)?.names[0] ||
263
+ 'Unknown User';
264
+
265
+ const attachments = (message.content as Content).attachments;
266
+
267
+ const attachmentString =
268
+ attachments && attachments.length > 0
269
+ ? ` (Attachments: ${attachments
270
+ .map((media) => {
271
+ const lines = [`[${media.id} - ${media.title} (${media.url})]`];
272
+ if (media.text) lines.push(`Text: ${media.text}`);
273
+ if (media.description) lines.push(`Description: ${media.description}`);
274
+ return lines.join('\n');
275
+ })
276
+ .join(
277
+ // Use comma separator only if all attachments are single-line (no text/description)
278
+ attachments.every((media) => !media.text && !media.description) ? ', ' : '\n'
279
+ )})`
280
+ : null;
281
+
282
+ const messageTime = new Date(message.createdAt || 0);
283
+ const hours = messageTime.getHours().toString().padStart(2, '0');
284
+ const minutes = messageTime.getMinutes().toString().padStart(2, '0');
285
+ const timeString = `${hours}:${minutes}`;
286
+
287
+ const timestamp = formatTimestamp(message.createdAt || 0);
288
+
289
+ // const shortId = message.entityId.slice(-5);
290
+
291
+ const thoughtString = messageThought
292
+ ? `(${formattedName}'s internal thought: ${messageThought})`
293
+ : null;
294
+
295
+ const timestampString = `${timeString} (${timestamp}) [${message.entityId}]`;
296
+ const textString = messageText ? `${timestampString} ${formattedName}: ${messageText}` : null;
297
+ const actionString =
298
+ messageActions && messageActions.length > 0
299
+ ? `${
300
+ textString ? '' : timestampString
301
+ } (${formattedName}'s actions: ${messageActions.join(', ')})`
302
+ : null;
303
+
304
+ // for each thought, action, text or attachment, add a new line, with text first, then thought, then action, then attachment
305
+ const messageString = [textString, thoughtString, actionString, attachmentString]
306
+ .filter(Boolean)
307
+ .join('\n');
308
+
309
+ return messageString;
310
+ })
311
+ .join('\n');
312
+ return messageStrings;
313
+ };
314
+
315
+ export const formatTimestamp = (messageDate: number) => {
316
+ const now = new Date();
317
+ const diff = now.getTime() - messageDate;
318
+
319
+ const absDiff = Math.abs(diff);
320
+ const seconds = Math.floor(absDiff / 1000);
321
+ const minutes = Math.floor(seconds / 60);
322
+ const hours = Math.floor(minutes / 60);
323
+ const days = Math.floor(hours / 24);
324
+
325
+ if (absDiff < 60000) {
326
+ return 'just now';
327
+ }
328
+ if (minutes < 60) {
329
+ return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
330
+ }
331
+ if (hours < 24) {
332
+ return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
333
+ }
334
+ return `${days} day${days !== 1 ? 's' : ''} ago`;
335
+ };
336
+
337
+ const jsonBlockPattern = /```json\n([\s\S]*?)\n```/;
338
+
339
+ /**
340
+ * Parses key-value pairs from a simple XML structure within a given text.
341
+ * It looks for an XML block (e.g., <response>...</response>) and extracts
342
+ * text content from direct child elements (e.g., <key>value</key>).
343
+ *
344
+ * Note: This uses regex and is suitable for simple, predictable XML structures.
345
+ * For complex XML, a proper parsing library is recommended.
346
+ *
347
+ * @param text - The input text containing the XML structure.
348
+ * @returns An object with key-value pairs extracted from the XML, or null if parsing fails.
349
+ */
350
+ export function parseKeyValueXml(text: string): Record<string, any> | null {
351
+ if (!text) return null;
352
+
353
+ // First, try to find a specific <response> block (the one we actually want)
354
+ // Use a more permissive regex to handle cases where there might be multiple XML blocks
355
+ let xmlBlockMatch = text.match(/<response>([\s\S]*?)<\/response>/);
356
+ let xmlContent: string;
357
+
358
+ if (xmlBlockMatch) {
359
+ xmlContent = xmlBlockMatch[1];
360
+ logger.debug('Found response XML block');
361
+ } else {
362
+ // Fall back: perform a linear scan to find the first simple XML element and its matching close tag
363
+ // This avoids potentially expensive backtracking on crafted inputs
364
+ const findFirstXmlBlock = (input: string): { tag: string; content: string } | null => {
365
+ let i = 0;
366
+ const length = input.length;
367
+ while (i < length) {
368
+ const openIdx = input.indexOf('<', i);
369
+ if (openIdx === -1) break;
370
+ // Skip closing tags and comments/decls
371
+ if (
372
+ input.startsWith('</', openIdx) ||
373
+ input.startsWith('<!--', openIdx) ||
374
+ input.startsWith('<?', openIdx)
375
+ ) {
376
+ i = openIdx + 1;
377
+ continue;
378
+ }
379
+ // Extract tag name [letters, digits, dash, underscore]
380
+ let j = openIdx + 1;
381
+ let tag = '';
382
+ while (j < length) {
383
+ const ch = input[j];
384
+ if (/^[A-Za-z0-9_-]$/.test(ch)) {
385
+ tag += ch;
386
+ j++;
387
+ continue;
388
+ }
389
+ break;
390
+ }
391
+ if (!tag) {
392
+ i = openIdx + 1;
393
+ continue;
394
+ }
395
+ // Find end of start tag '>' (skip attributes if present)
396
+ const startTagEnd = input.indexOf('>', j);
397
+ if (startTagEnd === -1) break;
398
+ // Self-closing tag? tolerate whitespace before '/>'
399
+ const startTagText = input.slice(openIdx, startTagEnd + 1);
400
+ if (/\/\s*>$/.test(startTagText)) {
401
+ i = startTagEnd + 1;
402
+ continue;
403
+ }
404
+ const closeSeq = `</${tag}>`;
405
+ // Implement nested tag counting for same-named tags
406
+ let depth = 1;
407
+ let searchStart = startTagEnd + 1;
408
+ while (depth > 0 && searchStart < length) {
409
+ const nextOpen = input.indexOf(`<${tag}`, searchStart);
410
+ const nextClose = input.indexOf(closeSeq, searchStart);
411
+ if (nextClose === -1) {
412
+ break;
413
+ }
414
+ if (nextOpen !== -1 && nextOpen < nextClose) {
415
+ // Determine if the next open is self-closing; if so, do not increase depth
416
+ const nestedStartEnd = input.indexOf('>', nextOpen + 1);
417
+ if (nestedStartEnd === -1) {
418
+ break;
419
+ }
420
+ const nestedStartText = input.slice(nextOpen, nestedStartEnd + 1);
421
+ if (/\/\s*>$/.test(nestedStartText)) {
422
+ // self-closing; skip without changing depth
423
+ searchStart = nestedStartEnd + 1;
424
+ } else {
425
+ depth++;
426
+ searchStart = nestedStartEnd + 1;
427
+ }
428
+ } else {
429
+ depth--;
430
+ searchStart = nextClose + closeSeq.length;
431
+ }
432
+ }
433
+ if (depth === 0) {
434
+ const closeIdx = searchStart - closeSeq.length;
435
+ const inner = input.slice(startTagEnd + 1, closeIdx);
436
+ return { tag, content: inner };
437
+ }
438
+ i = startTagEnd + 1;
439
+ }
440
+ return null;
441
+ };
442
+
443
+ const fb = findFirstXmlBlock(text);
444
+ if (!fb) {
445
+ logger.warn('Could not find XML block in text');
446
+ logger.debug({ textPreview: text.substring(0, 200) + '...' }, 'Text content');
447
+ return null;
448
+ }
449
+ xmlContent = fb.content;
450
+ logger.debug(`Found XML block with tag: ${fb.tag}`);
451
+ }
452
+
453
+ const result: Record<string, any> = {};
454
+
455
+ // Safer linear scan to extract direct child <key>value</key> elements
456
+ // Avoids potentially expensive backtracking from broad regexes
457
+ const extractDirectChildren = (input: string): Array<{ key: string; value: string }> => {
458
+ const pairs: Array<{ key: string; value: string }> = [];
459
+ const length = input.length;
460
+ let i = 0;
461
+
462
+ while (i < length) {
463
+ const openIdx = input.indexOf('<', i);
464
+ if (openIdx === -1) break;
465
+
466
+ // Skip closing tags and comments/decls
467
+ if (
468
+ input.startsWith('</', openIdx) ||
469
+ input.startsWith('<!--', openIdx) ||
470
+ input.startsWith('<?', openIdx)
471
+ ) {
472
+ i = openIdx + 1;
473
+ continue;
474
+ }
475
+
476
+ // Extract tag name [letters, digits, dash, underscore]
477
+ let j = openIdx + 1;
478
+ let tag = '';
479
+ while (j < length) {
480
+ const ch = input[j];
481
+ if (/^[A-Za-z0-9_-]$/.test(ch)) {
482
+ tag += ch;
483
+ j++;
484
+ continue;
485
+ }
486
+ break;
487
+ }
488
+ if (!tag) {
489
+ i = openIdx + 1;
490
+ continue;
491
+ }
492
+
493
+ // Find end of start tag '>' (skip attributes if present)
494
+ const startTagEnd = input.indexOf('>', j);
495
+ if (startTagEnd === -1) break;
496
+
497
+ // Self-closing tag? tolerate whitespace before '/>'
498
+ const startTagText = input.slice(openIdx, startTagEnd + 1);
499
+ if (/\/\s*>$/.test(startTagText)) {
500
+ i = startTagEnd + 1;
501
+ continue;
502
+ }
503
+
504
+ // Find the matching close tag, handling nested tags with the same name
505
+ const closeSeq = `</${tag}>`;
506
+ let depth = 1;
507
+ let searchStart = startTagEnd + 1;
508
+ while (depth > 0 && searchStart < length) {
509
+ const nextOpen = input.indexOf(`<${tag}`, searchStart);
510
+ const nextClose = input.indexOf(closeSeq, searchStart);
511
+ if (nextClose === -1) {
512
+ break;
513
+ }
514
+ if (nextOpen !== -1 && nextOpen < nextClose) {
515
+ const nestedStartEnd = input.indexOf('>', nextOpen + 1);
516
+ if (nestedStartEnd === -1) {
517
+ break;
518
+ }
519
+ const nestedStartText = input.slice(nextOpen, nestedStartEnd + 1);
520
+ if (!/\/\s*>$/.test(nestedStartText)) {
521
+ depth++;
522
+ }
523
+ searchStart = nestedStartEnd + 1;
524
+ } else {
525
+ depth--;
526
+ searchStart = nextClose + closeSeq.length;
527
+ }
528
+ }
529
+ if (depth !== 0) {
530
+ // Unbalanced tag, advance to avoid infinite loops
531
+ i = startTagEnd + 1;
532
+ continue;
533
+ }
534
+
535
+ const closeIdx = searchStart - closeSeq.length;
536
+ const innerRaw = input.slice(startTagEnd + 1, closeIdx);
537
+
538
+ // Basic unescaping for common XML entities (add more as needed)
539
+ const unescaped = innerRaw
540
+ .replace(/&lt;/g, '<')
541
+ .replace(/&gt;/g, '>')
542
+ .replace(/&amp;/g, '&')
543
+ .replace(/&quot;/g, '"')
544
+ .replace(/&apos;/g, "'")
545
+ .trim();
546
+
547
+ pairs.push({ key: tag, value: unescaped });
548
+ // Move cursor past this element to avoid processing nested children as direct siblings
549
+ i = searchStart;
550
+ }
551
+
552
+ return pairs;
553
+ };
554
+
555
+ const children = extractDirectChildren(xmlContent);
556
+ for (const { key, value } of children) {
557
+ if (key === 'actions' || key === 'providers' || key === 'evaluators') {
558
+ result[key] = value ? value.split(',').map((s) => s.trim()) : [];
559
+ } else if (key === 'simple') {
560
+ result[key] = value.toLowerCase() === 'true';
561
+ } else {
562
+ result[key] = value;
563
+ }
564
+ }
565
+
566
+ // Return null if no key-value pairs were found
567
+ if (Object.keys(result).length === 0) {
568
+ logger.warn('No key-value pairs extracted from XML content');
569
+ logger.debug({ xmlPreview: xmlContent.substring(0, 200) + '...' }, 'XML content was');
570
+ return null;
571
+ }
572
+
573
+ return result;
574
+ }
575
+
576
+ /**
577
+ * Parses a JSON object from a given text. The function looks for a JSON block wrapped in triple backticks
578
+ * with `json` language identifier, and if not found, it searches for an object pattern within the text.
579
+ * It then attempts to parse the JSON string into a JavaScript object. If parsing is successful and the result
580
+ * is an object (but not an array), it returns the object; otherwise, it tries to parse an array if the result
581
+ * is an array, or returns null if parsing is unsuccessful or the result is neither an object nor an array.
582
+ *
583
+ * @param text - The input text from which to extract and parse the JSON object.
584
+ * @returns An object parsed from the JSON string if successful; otherwise, null or the result of parsing an array.
585
+ */
586
+ export function parseJSONObjectFromText(text: string): Record<string, any> | null {
587
+ let jsonData = null;
588
+ const jsonBlockMatch = text.match(jsonBlockPattern);
589
+
590
+ try {
591
+ if (jsonBlockMatch) {
592
+ // Parse the JSON from inside the code block
593
+ jsonData = JSON.parse(normalizeJsonString(jsonBlockMatch[1].trim()));
594
+ } else {
595
+ // Try to parse the text directly if it's not in a code block
596
+ jsonData = JSON.parse(normalizeJsonString(text.trim()));
597
+ }
598
+ } catch (_e) {
599
+ // logger.warn("Could not parse text as JSON, returning null");
600
+ return null; // Keep null return on error
601
+ }
602
+
603
+ // Ensure we have a non-null object that's not an array
604
+ if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) {
605
+ return jsonData;
606
+ }
607
+
608
+ // logger.warn("Could not parse text as JSON object, returning null");
609
+ return null; // Return null if not a valid object
610
+ }
611
+
612
+ /**
613
+ * Normalizes a JSON-like string by correcting formatting issues:
614
+ * - Removes extra spaces after '{' and before '}'.
615
+ * - Wraps unquoted values in double quotes.
616
+ * - Converts single-quoted values to double-quoted.
617
+ * - Ensures consistency in key-value formatting.
618
+ * - Normalizes mixed adjacent quote pairs.
619
+ *
620
+ * This is useful for cleaning up improperly formatted JSON strings
621
+ * before parsing them into valid JSON.
622
+ *
623
+ * @param str - The JSON-like string to normalize.
624
+ * @returns A properly formatted JSON string.
625
+ */
626
+
627
+ export const normalizeJsonString = (str: string) => {
628
+ // Remove extra spaces after '{' and before '}'
629
+ str = str.replace(/\{\s+/, '{').replace(/\s+\}/, '}').trim();
630
+
631
+ // "key": unquotedValue → "key": "unquotedValue"
632
+ str = str.replace(/("[\w\d_-]+")\s*: \s*(?!"|\[)([\s\S]+?)(?=(,\s*"|\}$))/g, '$1: "$2"');
633
+
634
+ // "key": 'value' → "key": "value"
635
+ str = str.replace(/"([^"]+)"\s*:\s*'([^']*)'/g, (_, key, value) => `"${key}": "${value}"`);
636
+
637
+ // "key": someWord → "key": "someWord"
638
+ str = str.replace(/("[\w\d_-]+")\s*:\s*([A-Za-z_]+)(?!["\w])/g, '$1: "$2"');
639
+
640
+ return str;
641
+ };
642
+
643
+ // why is this here? maybe types.ts is more appropriate
644
+ // and shouldn't the name include x/twitter
645
+ /*
646
+ export type ActionResponse = {
647
+ like: boolean;
648
+ retweet: boolean;
649
+ quote?: boolean;
650
+ reply?: boolean;
651
+ };
652
+ */
653
+
654
+ /**
655
+ * Truncate text to fit within the character limit, ensuring it ends at a complete sentence.
656
+ */
657
+ export function truncateToCompleteSentence(text: string, maxLength: number): string {
658
+ if (text.length <= maxLength) {
659
+ return text;
660
+ }
661
+
662
+ // Attempt to truncate at the last period within the limit
663
+ const lastPeriodIndex = text.lastIndexOf('.', maxLength - 1);
664
+ if (lastPeriodIndex !== -1) {
665
+ const truncatedAtPeriod = text.slice(0, lastPeriodIndex + 1).trim();
666
+ if (truncatedAtPeriod.length > 0) {
667
+ return truncatedAtPeriod;
668
+ }
669
+ }
670
+
671
+ // If no period, truncate to the nearest whitespace within the limit
672
+ const lastSpaceIndex = text.lastIndexOf(' ', maxLength - 1);
673
+ if (lastSpaceIndex !== -1) {
674
+ const truncatedAtSpace = text.slice(0, lastSpaceIndex).trim();
675
+ if (truncatedAtSpace.length > 0) {
676
+ return `${truncatedAtSpace}...`;
677
+ }
678
+ }
679
+
680
+ // Fallback: Hard truncate and add ellipsis
681
+ const hardTruncated = text.slice(0, maxLength - 3).trim();
682
+ return `${hardTruncated}...`;
683
+ }
684
+
685
+ export async function splitChunks(content: string, chunkSize = 512, bleed = 20): Promise<string[]> {
686
+ logger.debug('[splitChunks] Starting text split');
687
+
688
+ const characterstoTokens = 3.5;
689
+
690
+ const textSplitter = new RecursiveCharacterTextSplitter({
691
+ chunkSize: Number(Math.floor(chunkSize * characterstoTokens)),
692
+ chunkOverlap: Number(Math.floor(bleed * characterstoTokens)),
693
+ });
694
+
695
+ const chunks = await textSplitter.splitText(content);
696
+ logger.debug(
697
+ {
698
+ numberOfChunks: chunks.length,
699
+ averageChunkSize: chunks.reduce((acc, chunk) => acc + chunk.length, 0) / chunks.length,
700
+ },
701
+ '[splitChunks] Split complete'
702
+ );
703
+
704
+ return chunks;
705
+ }
706
+
707
+ /**
708
+ * Trims the provided text prompt to a specified token limit using a tokenizer model and type.
709
+ */
710
+ export async function trimTokens(prompt: string, maxTokens: number, runtime: IAgentRuntime) {
711
+ if (!prompt) throw new Error('Trim tokens received a null prompt');
712
+
713
+ // if prompt is less than of maxtokens / 5, skip
714
+ if (prompt.length < maxTokens / 5) return prompt;
715
+
716
+ if (maxTokens <= 0) throw new Error('maxTokens must be positive');
717
+
718
+ const tokens = await runtime.useModel(ModelType.TEXT_TOKENIZER_ENCODE, {
719
+ prompt,
720
+ });
721
+
722
+ // If already within limits, return unchanged
723
+ if (tokens.length <= maxTokens) {
724
+ return prompt;
725
+ }
726
+
727
+ // Keep the most recent tokens by slicing from the end
728
+ const truncatedTokens = tokens.slice(-maxTokens);
729
+
730
+ // Decode back to text
731
+ return await runtime.useModel(ModelType.TEXT_TOKENIZER_DECODE, {
732
+ tokens: truncatedTokens,
733
+ });
734
+ }
735
+
736
+ export function safeReplacer() {
737
+ const seen = new WeakSet();
738
+ return function (_key: string, value: any) {
739
+ if (typeof value === 'object' && value !== null) {
740
+ if (seen.has(value)) {
741
+ return '[Circular]';
742
+ }
743
+ seen.add(value);
744
+ }
745
+ return value;
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Parses a string to determine its boolean equivalent.
751
+ *
752
+ * Recognized affirmative values: "YES", "Y", "TRUE", "T", "1", "ON", "ENABLE"
753
+ * Recognized negative values: "NO", "N", "FALSE", "F", "0", "OFF", "DISABLE"
754
+ *
755
+ * @param {string | undefined | null} value - The input text to parse
756
+ * @returns {boolean} - Returns `true` for affirmative inputs, `false` for negative or unrecognized inputs
757
+ */
758
+ export function parseBooleanFromText(value: string | undefined | null): boolean {
759
+ if (!value) return false;
760
+
761
+ const affirmative = ['YES', 'Y', 'TRUE', 'T', '1', 'ON', 'ENABLE'];
762
+ const negative = ['NO', 'N', 'FALSE', 'F', '0', 'OFF', 'DISABLE'];
763
+
764
+ const normalizedText = value.trim().toUpperCase();
765
+
766
+ if (affirmative.includes(normalizedText)) {
767
+ return true;
768
+ }
769
+ if (negative.includes(normalizedText)) {
770
+ return false;
771
+ }
772
+
773
+ // For environment variables, we'll treat unrecognized values as false
774
+ return false;
775
+ }
776
+
777
+ // UUID Utils
778
+
779
+ const uuidSchema = z.string().uuid() as z.ZodType<UUID>;
780
+
781
+ /**
782
+ * Validates a UUID value.
783
+ *
784
+ * @param {unknown} value - The value to validate.
785
+ * @returns {UUID | null} Returns the validated UUID value or null if validation fails.
786
+ */
787
+ export function validateUuid(value: unknown): UUID | null {
788
+ const result = uuidSchema.safeParse(value);
789
+ return result.success ? result.data : null;
790
+ }
791
+
792
+ /**
793
+ * Converts a string or number to a UUID.
794
+ *
795
+ * @param {string | number} target - The string or number to convert to a UUID.
796
+ * @returns {UUID} The UUID generated from the input target.
797
+ * @throws {TypeError} Throws an error if the input target is not a string.
798
+ */
799
+ export function stringToUuid(target: string | number): UUID {
800
+ if (typeof target === 'number') {
801
+ target = (target as number).toString();
802
+ }
803
+
804
+ if (typeof target !== 'string') {
805
+ throw TypeError('Value must be string');
806
+ }
807
+
808
+ // If already a UUID, return as-is to avoid re-hashing
809
+ const maybeUuid = validateUuid(target);
810
+ if (maybeUuid) return maybeUuid;
811
+
812
+ const escapedStr = encodeURIComponent(target);
813
+
814
+ // Deterministic UUID derived from SHA-1(escapedStr)
815
+ // Use WebCrypto if available (sync via cache), otherwise pure JS
816
+ const digest = getCachedSha1(escapedStr); // 20 bytes
817
+ const bytes = digest.slice(0, 16);
818
+
819
+ // Set RFC4122 variant bits: 10xxxxxx
820
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
821
+ // Set custom version nibble to 0x0 to indicate legacy/custom (matches prior tests expecting '0')
822
+ bytes[6] = (bytes[6] & 0x0f) | 0x00;
823
+
824
+ return bytesToUuid(bytes) as UUID;
825
+ }
826
+
827
+ /**
828
+ * Pre-warm the SHA-1 cache with common values using WebCrypto
829
+ * Call this during initialization to improve performance
830
+ */
831
+ export async function prewarmUuidCache(values: string[]): Promise<void> {
832
+ if (!checkWebCrypto()) return;
833
+
834
+ const promises = values.map(async (value) => {
835
+ const escapedStr = encodeURIComponent(value);
836
+ const digest = await sha1BytesAsync(escapedStr);
837
+ sha1Cache.set(escapedStr, digest);
838
+ });
839
+
840
+ await Promise.all(promises);
841
+ }
842
+
843
+ // Cache for SHA-1 digests to enable synchronous WebCrypto usage
844
+ const sha1Cache = new Map<string, Uint8Array>();
845
+ let webCryptoAvailable: boolean | null = null;
846
+
847
+ /**
848
+ * Check if WebCrypto is available for SHA-1
849
+ */
850
+ function checkWebCrypto(): boolean {
851
+ if (webCryptoAvailable !== null) return webCryptoAvailable;
852
+
853
+ try {
854
+ // Check for crypto.subtle (WebCrypto API)
855
+ if (
856
+ typeof globalThis !== 'undefined' &&
857
+ globalThis.crypto &&
858
+ globalThis.crypto.subtle &&
859
+ typeof globalThis.crypto.subtle.digest === 'function'
860
+ ) {
861
+ webCryptoAvailable = true;
862
+ return true;
863
+ }
864
+ } catch {
865
+ // Ignore errors
866
+ }
867
+
868
+ webCryptoAvailable = false;
869
+ return false;
870
+ }
871
+
872
+ /**
873
+ * Get SHA-1 digest using cache for synchronous operation
874
+ * Uses WebCrypto when available (via background pre-computation), falls back to pure JS
875
+ */
876
+ function getCachedSha1(message: string): Uint8Array {
877
+ // Check cache first
878
+ const cached = sha1Cache.get(message);
879
+ if (cached) return cached;
880
+
881
+ // Use synchronous pure JS implementation for immediate result
882
+ const digest = sha1Bytes(message);
883
+ sha1Cache.set(message, digest);
884
+
885
+ // Asynchronously compute with WebCrypto for next time (if available)
886
+ if (checkWebCrypto()) {
887
+ sha1BytesAsync(message)
888
+ .then((webDigest) => {
889
+ // Update cache with WebCrypto result (should be identical)
890
+ sha1Cache.set(message, webDigest);
891
+ })
892
+ .catch(() => {
893
+ // Ignore errors, we already have the pure JS result
894
+ });
895
+ }
896
+
897
+ // Limit cache size to prevent memory leaks
898
+ if (sha1Cache.size > 10000) {
899
+ // Remove oldest entries (first ones in iteration order)
900
+ const keysToDelete = Array.from(sha1Cache.keys()).slice(0, 5000);
901
+ keysToDelete.forEach((key) => sha1Cache.delete(key));
902
+ }
903
+
904
+ return digest;
905
+ }
906
+
907
+ /**
908
+ * Async SHA-1 using WebCrypto when available
909
+ * This can be used to pre-warm the cache
910
+ */
911
+ async function sha1BytesAsync(message: string): Promise<Uint8Array> {
912
+ if (checkWebCrypto()) {
913
+ try {
914
+ const encoder = new TextEncoder();
915
+ const data = encoder.encode(message);
916
+ const hashBuffer = await globalThis.crypto.subtle.digest('SHA-1', data);
917
+ return new Uint8Array(hashBuffer);
918
+ } catch {
919
+ // Fall through to pure JS
920
+ }
921
+ }
922
+
923
+ // Fallback to pure JS implementation
924
+ return sha1Bytes(message);
925
+ }
926
+
927
+ /**
928
+ * Minimal SHA-1 implementation returning raw bytes.
929
+ * Source adapted from public-domain references for portability (browser/Node).
930
+ * Used as fallback when WebCrypto is not available.
931
+ */
932
+ function sha1Bytes(message: string): Uint8Array {
933
+ const bytes = utf8Encode(message);
934
+ const ml = bytes.length;
935
+
936
+ // Pre-processing (padding)
937
+ const withOne = new Uint8Array(((ml + 9 + 63) >>> 6) << 6); // multiple of 64
938
+ withOne.set(bytes);
939
+ withOne[ml] = 0x80;
940
+ const bitLen = ml * 8;
941
+ // Append length as 64-bit big-endian
942
+ const dv = new DataView(withOne.buffer);
943
+ dv.setUint32(withOne.length - 4, bitLen >>> 0, false);
944
+ dv.setUint32(withOne.length - 8, Math.floor(bitLen / 2 ** 32) >>> 0, false);
945
+
946
+ // Initialize hash values
947
+ let h0 = 0x67452301;
948
+ let h1 = 0xefcdab89;
949
+ let h2 = 0x98badcfe;
950
+ let h3 = 0x10325476;
951
+ let h4 = 0xc3d2e1f0;
952
+
953
+ const w = new Uint32Array(80);
954
+
955
+ for (let i = 0; i < withOne.length; i += 64) {
956
+ // Break chunk into sixteen 32-bit big-endian words
957
+ for (let j = 0; j < 16; j++) {
958
+ w[j] = dv.getUint32(i + j * 4, false);
959
+ }
960
+ // Extend to 80 words
961
+ for (let j = 16; j < 80; j++) {
962
+ const t = w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16];
963
+ w[j] = (t << 1) | (t >>> 31);
964
+ }
965
+
966
+ // Initialize working vars
967
+ let a = h0;
968
+ let b = h1;
969
+ let c = h2;
970
+ let d = h3;
971
+ let e = h4;
972
+
973
+ for (let j = 0; j < 80; j++) {
974
+ let f: number;
975
+ let k: number;
976
+ if (j < 20) {
977
+ f = (b & c) | (~b & d);
978
+ k = 0x5a827999;
979
+ } else if (j < 40) {
980
+ f = b ^ c ^ d;
981
+ k = 0x6ed9eba1;
982
+ } else if (j < 60) {
983
+ f = (b & c) | (b & d) | (c & d);
984
+ k = 0x8f1bbcdc;
985
+ } else {
986
+ f = b ^ c ^ d;
987
+ k = 0xca62c1d6;
988
+ }
989
+ const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[j]) >>> 0;
990
+ e = d;
991
+ d = c;
992
+ c = ((b << 30) | (b >>> 2)) >>> 0;
993
+ b = a;
994
+ a = temp;
995
+ }
996
+
997
+ h0 = (h0 + a) >>> 0;
998
+ h1 = (h1 + b) >>> 0;
999
+ h2 = (h2 + c) >>> 0;
1000
+ h3 = (h3 + d) >>> 0;
1001
+ h4 = (h4 + e) >>> 0;
1002
+ }
1003
+
1004
+ const out = new Uint8Array(20);
1005
+ const outDv = new DataView(out.buffer);
1006
+ outDv.setUint32(0, h0, false);
1007
+ outDv.setUint32(4, h1, false);
1008
+ outDv.setUint32(8, h2, false);
1009
+ outDv.setUint32(12, h3, false);
1010
+ outDv.setUint32(16, h4, false);
1011
+ return out;
1012
+ }
1013
+
1014
+ function utf8Encode(str: string): Uint8Array {
1015
+ if (typeof TextEncoder !== 'undefined') {
1016
+ return new TextEncoder().encode(str);
1017
+ }
1018
+ // Fallback
1019
+ const utf8: number[] = [];
1020
+ for (let i = 0; i < str.length; i++) {
1021
+ let charcode = str.charCodeAt(i);
1022
+ if (charcode < 0x80) utf8.push(charcode);
1023
+ else if (charcode < 0x800) {
1024
+ utf8.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f));
1025
+ } else if (charcode < 0xd800 || charcode >= 0xe000) {
1026
+ utf8.push(0xe0 | (charcode >> 12), 0x80 | ((charcode >> 6) & 0x3f), 0x80 | (charcode & 0x3f));
1027
+ } else {
1028
+ // surrogate pair
1029
+ i++;
1030
+ // UTF-16 to Unicode code point
1031
+ const codePoint = 0x10000 + (((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
1032
+ utf8.push(
1033
+ 0xf0 | (codePoint >> 18),
1034
+ 0x80 | ((codePoint >> 12) & 0x3f),
1035
+ 0x80 | ((codePoint >> 6) & 0x3f),
1036
+ 0x80 | (codePoint & 0x3f)
1037
+ );
1038
+ }
1039
+ }
1040
+ return new Uint8Array(utf8);
1041
+ }
1042
+
1043
+ function bytesToUuid(bytes: Uint8Array): string {
1044
+ const hex: string[] = [];
1045
+ for (let i = 0; i < bytes.length; i++) {
1046
+ const h = bytes[i].toString(16).padStart(2, '0');
1047
+ hex.push(h);
1048
+ }
1049
+ // Format: 8-4-4-4-12 hexadecimal digits
1050
+ return (
1051
+ hex.slice(0, 4).join('') +
1052
+ '-' +
1053
+ hex.slice(4, 6).join('') +
1054
+ '-' +
1055
+ hex.slice(6, 8).join('') +
1056
+ '-' +
1057
+ hex.slice(8, 10).join('') +
1058
+ '-' +
1059
+ hex.slice(10, 16).join('')
1060
+ );
1061
+ }
1062
+
1063
+ export const getContentTypeFromMimeType = (mimeType: string): ContentType | undefined => {
1064
+ if (mimeType.startsWith('image/')) return ContentType.IMAGE;
1065
+ if (mimeType.startsWith('video/')) return ContentType.VIDEO;
1066
+ if (mimeType.startsWith('audio/')) return ContentType.AUDIO;
1067
+ if (mimeType.includes('pdf') || mimeType.includes('document') || mimeType.startsWith('text/')) {
1068
+ return ContentType.DOCUMENT;
1069
+ }
1070
+ return undefined;
1071
+ };
1072
+
1073
+ export function getLocalServerUrl(path: string): string {
1074
+ const port = getEnv('SERVER_PORT', '3000');
1075
+ return `http://localhost:${port}${path}`;
1076
+ }