@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.
- package/dist/browser/index.browser.js +120 -120
- package/dist/browser/index.browser.js.map +5 -21
- package/dist/browser/index.d.ts +3 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -5
- package/dist/node/index.d.ts +3 -1
- package/package.json +10 -4
- package/src/__tests__/action-chaining-simple.test.ts +203 -0
- package/src/__tests__/actions.test.ts +218 -0
- package/src/__tests__/buffer.test.ts +337 -0
- package/src/__tests__/character-validation.test.ts +309 -0
- package/src/__tests__/database.test.ts +750 -0
- package/src/__tests__/entities.test.ts +727 -0
- package/src/__tests__/env.test.ts +23 -0
- package/src/__tests__/environment.test.ts +285 -0
- package/src/__tests__/logger-browser-node.test.ts +716 -0
- package/src/__tests__/logger.test.ts +403 -0
- package/src/__tests__/messages.test.ts +196 -0
- package/src/__tests__/mockCharacter.ts +544 -0
- package/src/__tests__/parsing.test.ts +58 -0
- package/src/__tests__/prompts.test.ts +159 -0
- package/src/__tests__/roles.test.ts +331 -0
- package/src/__tests__/runtime-embedding.test.ts +343 -0
- package/src/__tests__/runtime.test.ts +978 -0
- package/src/__tests__/search.test.ts +15 -0
- package/src/__tests__/services-by-type.test.ts +204 -0
- package/src/__tests__/services.test.ts +136 -0
- package/src/__tests__/settings.test.ts +810 -0
- package/src/__tests__/utils.test.ts +1105 -0
- package/src/__tests__/uuid.test.ts +94 -0
- package/src/actions.ts +122 -0
- package/src/database.ts +579 -0
- package/src/entities.ts +406 -0
- package/src/index.browser.ts +48 -0
- package/src/index.node.ts +39 -0
- package/src/index.ts +50 -0
- package/src/logger.ts +527 -0
- package/src/prompts.ts +243 -0
- package/src/roles.ts +85 -0
- package/src/runtime.ts +2514 -0
- package/src/schemas/character.ts +149 -0
- package/src/search.ts +1543 -0
- package/src/sentry/instrument.browser.ts +65 -0
- package/src/sentry/instrument.node.ts +57 -0
- package/src/sentry/instrument.ts +82 -0
- package/src/services.ts +105 -0
- package/src/settings.ts +409 -0
- package/src/test_resources/constants.ts +12 -0
- package/src/test_resources/testSetup.ts +21 -0
- package/src/test_resources/types.ts +22 -0
- package/src/types/agent.ts +112 -0
- package/src/types/browser.ts +145 -0
- package/src/types/components.ts +184 -0
- package/src/types/database.ts +348 -0
- package/src/types/email.ts +162 -0
- package/src/types/environment.ts +129 -0
- package/src/types/events.ts +249 -0
- package/src/types/index.ts +29 -0
- package/src/types/knowledge.ts +65 -0
- package/src/types/lp.ts +124 -0
- package/src/types/memory.ts +228 -0
- package/src/types/message.ts +233 -0
- package/src/types/messaging.ts +57 -0
- package/src/types/model.ts +359 -0
- package/src/types/pdf.ts +77 -0
- package/src/types/plugin.ts +78 -0
- package/src/types/post.ts +271 -0
- package/src/types/primitives.ts +97 -0
- package/src/types/runtime.ts +190 -0
- package/src/types/service.ts +198 -0
- package/src/types/settings.ts +30 -0
- package/src/types/state.ts +60 -0
- package/src/types/task.ts +72 -0
- package/src/types/tee.ts +107 -0
- package/src/types/testing.ts +30 -0
- package/src/types/token.ts +96 -0
- package/src/types/transcription.ts +133 -0
- package/src/types/video.ts +108 -0
- package/src/types/wallet.ts +56 -0
- package/src/types/web-search.ts +146 -0
- package/src/utils/__tests__/buffer.test.ts +80 -0
- package/src/utils/__tests__/environment.test.ts +58 -0
- package/src/utils/__tests__/stringToUuid.test.ts +88 -0
- package/src/utils/buffer.ts +312 -0
- package/src/utils/environment.ts +316 -0
- package/src/utils/server-health.ts +117 -0
- package/src/utils.ts +1076 -0
- 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(/</g, '<')
|
|
541
|
+
.replace(/>/g, '>')
|
|
542
|
+
.replace(/&/g, '&')
|
|
543
|
+
.replace(/"/g, '"')
|
|
544
|
+
.replace(/'/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
|
+
}
|