handhold-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +427 -0
  2. package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
  3. package/dist/fonts/Phosphor.ttf +0 -0
  4. package/dist/fonts/Phosphor.woff +0 -0
  5. package/dist/fonts/Phosphor.woff2 +0 -0
  6. package/dist/handhold.min.js +1 -0
  7. package/package.json +114 -0
  8. package/src/HandHold.js +1599 -0
  9. package/src/agent/VENDOR.md +203 -0
  10. package/src/agent/agent/AgentLoop.js +366 -0
  11. package/src/agent/agent/prompts.js +163 -0
  12. package/src/agent/agent/tools.js +148 -0
  13. package/src/agent/controller.js +235 -0
  14. package/src/agent/dom/actions.js +456 -0
  15. package/src/agent/dom/domTree.js +1761 -0
  16. package/src/agent/dom/redact.js +98 -0
  17. package/src/agent/dom/serializer.js +332 -0
  18. package/src/agent/dom/utils.js +99 -0
  19. package/src/agent/index.js +169 -0
  20. package/src/agent/llm/connector.js +143 -0
  21. package/src/agent/package.json +3 -0
  22. package/src/agent/policy/PolicyGuard.js +172 -0
  23. package/src/agent/site/manifest.js +145 -0
  24. package/src/agent/ui/ChatWidget.js +219 -0
  25. package/src/agent-mode/AgentMode.js +429 -0
  26. package/src/api/APIClient.js +258 -0
  27. package/src/core/Config.js +207 -0
  28. package/src/core/UiState.js +83 -0
  29. package/src/core/defaults.js +20 -0
  30. package/src/events.js +59 -0
  31. package/src/index.js +77 -0
  32. package/src/intent/ActionVerbMap.js +324 -0
  33. package/src/intent/IntentExtractor.js +317 -0
  34. package/src/observers/ElementScanner.js +284 -0
  35. package/src/observers/NavigationObserver.js +182 -0
  36. package/src/observers/PageObserver.js +293 -0
  37. package/src/session/SessionManager.js +402 -0
  38. package/src/session/SessionStorage.js +148 -0
  39. package/src/ui/HelpWidget.js +1189 -0
  40. package/src/ui/UICoach.js +1725 -0
  41. package/src/ui/components/Button.css +137 -0
  42. package/src/ui/components/Button.js +102 -0
  43. package/src/ui/widget.css +599 -0
  44. package/src/utils/Logger.js +132 -0
  45. package/src/utils/MarkdownRenderer.js +39 -0
  46. package/src/utils/domUtils.js +350 -0
  47. package/src/utils/eventBus.js +102 -0
  48. package/src/utils/index.js +8 -0
  49. package/src/utils/stringUtils.js +202 -0
  50. package/types/index.d.ts +538 -0
@@ -0,0 +1,39 @@
1
+ import { marked } from 'marked';
2
+
3
+ /**
4
+ * Utility to render Markdown to HTML safely
5
+ */
6
+ export const MarkdownRenderer = {
7
+ /**
8
+ * Configure marked options
9
+ */
10
+ init() {
11
+ marked.setOptions({
12
+ renderer: new marked.Renderer(),
13
+ gfm: true, // GitHub Flavored Markdown
14
+ breaks: true, // Convert \n to <br>
15
+ pedantic: false,
16
+ sanitize: false, // We'll assume the input is safe or sanitize it elsewhere if needed, but for now we trust the backend/AI
17
+ smartLists: true,
18
+ smartypants: false,
19
+ });
20
+ },
21
+
22
+ /**
23
+ * Parse markdown string to HTML
24
+ * @param {string} text - Markdown text
25
+ * @returns {string} - HTML string
26
+ */
27
+ parse(text) {
28
+ if (!text) return '';
29
+ try {
30
+ return marked.parse(text);
31
+ } catch (e) {
32
+ console.error('[HandHold] Markdown parsing error:', e);
33
+ return text; // Fallback to raw text
34
+ }
35
+ }
36
+ };
37
+
38
+ // Initialize on load
39
+ MarkdownRenderer.init();
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Hand Hold SDK - DOM Utilities
3
+ * Helper functions for DOM manipulation and element scanning
4
+ */
5
+
6
+ /**
7
+ * Generate a stable CSS selector for an element
8
+ * Priority: data-testid > id > unique class > nth-child path
9
+ * @param {Element} element - DOM element
10
+ * @returns {string} - CSS selector
11
+ */
12
+ export function generateSelector(element) {
13
+ if (!element || element === document.body) {
14
+ return 'body';
15
+ }
16
+
17
+ // Priority 1: data-testid
18
+ const testId = element.getAttribute('data-testid');
19
+ if (testId) {
20
+ return `[data-testid="${testId}"]`;
21
+ }
22
+
23
+ // Priority 2: ID (if unique)
24
+ if (element.id && document.querySelectorAll(`#${CSS.escape(element.id)}`).length === 1) {
25
+ return `#${CSS.escape(element.id)}`;
26
+ }
27
+
28
+ // Priority 3: data-id or data-key
29
+ const dataId = element.getAttribute('data-id') || element.getAttribute('data-key');
30
+ if (dataId) {
31
+ const attr = element.hasAttribute('data-id') ? 'data-id' : 'data-key';
32
+ return `[${attr}="${dataId}"]`;
33
+ }
34
+
35
+ // Priority 4: Unique class combination
36
+ if (element.classList.length > 0) {
37
+ const selector = `.${Array.from(element.classList).map(c => CSS.escape(c)).join('.')}`;
38
+ if (document.querySelectorAll(selector).length === 1) {
39
+ return selector;
40
+ }
41
+ }
42
+
43
+ // Priority 5: Tag + text content for buttons/links
44
+ if (['BUTTON', 'A'].includes(element.tagName)) {
45
+ const text = element.textContent?.trim();
46
+ if (text && text.length < 50) {
47
+ // Use XPath-style or build path
48
+ const tag = element.tagName.toLowerCase();
49
+ const selector = buildPathSelector(element);
50
+ return selector;
51
+ }
52
+ }
53
+
54
+ // Fallback: Build path selector
55
+ return buildPathSelector(element);
56
+ }
57
+
58
+ /**
59
+ * Build a path-based selector using nth-child
60
+ * @param {Element} element - DOM element
61
+ * @returns {string} - Path selector
62
+ */
63
+ function buildPathSelector(element) {
64
+ const path = [];
65
+ let current = element;
66
+
67
+ while (current && current !== document.body && current !== document.documentElement) {
68
+ let selector = current.tagName.toLowerCase();
69
+
70
+ // Add ID if present
71
+ if (current.id) {
72
+ selector = `#${CSS.escape(current.id)}`;
73
+ path.unshift(selector);
74
+ break;
75
+ }
76
+
77
+ // Add data-testid if present
78
+ const testId = current.getAttribute('data-testid');
79
+ if (testId) {
80
+ selector = `[data-testid="${testId}"]`;
81
+ path.unshift(selector);
82
+ break;
83
+ }
84
+
85
+ // Add class if unique among siblings
86
+ if (current.classList.length > 0) {
87
+ const classSelector = `.${Array.from(current.classList).slice(0, 2).map(c => CSS.escape(c)).join('.')}`;
88
+ const siblings = current.parentElement?.querySelectorAll(`:scope > ${current.tagName.toLowerCase()}${classSelector}`);
89
+ if (siblings?.length === 1) {
90
+ selector = `${current.tagName.toLowerCase()}${classSelector}`;
91
+ }
92
+ }
93
+
94
+ // Add nth-child if needed
95
+ if (current.parentElement) {
96
+ const siblings = Array.from(current.parentElement.children).filter(
97
+ child => child.tagName === current.tagName
98
+ );
99
+ if (siblings.length > 1) {
100
+ const index = siblings.indexOf(current) + 1;
101
+ selector += `:nth-of-type(${index})`;
102
+ }
103
+ }
104
+
105
+ path.unshift(selector);
106
+ current = current.parentElement;
107
+ }
108
+
109
+ return path.join(' > ');
110
+ }
111
+
112
+ /**
113
+ * Get element's visible text content
114
+ * @param {Element} element - DOM element
115
+ * @returns {string} - Trimmed text content
116
+ */
117
+ export function getElementText(element) {
118
+ // For inputs, get value or placeholder
119
+ if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
120
+ return element.value || element.placeholder || '';
121
+ }
122
+
123
+ // For select, get selected option text
124
+ if (element.tagName === 'SELECT') {
125
+ return element.options[element.selectedIndex]?.text || '';
126
+ }
127
+
128
+ // Prefer an explicit accessible name when present (Phase 3.3).
129
+ const ariaLabel = element.getAttribute && element.getAttribute('aria-label');
130
+ if (ariaLabel && ariaLabel.trim()) {
131
+ return ariaLabel.trim().substring(0, 100);
132
+ }
133
+
134
+ // Collect only direct text nodes first — avoids picking up badge/icon/counter text
135
+ // e.g. <button>Approve <span class="badge">3</span> Timesheets</button> → "Approve Timesheets"
136
+ const directTexts = Array.from(element.childNodes)
137
+ .filter(node => node.nodeType === Node.TEXT_NODE)
138
+ .map(node => node.textContent.trim())
139
+ .filter(t => t.length > 0);
140
+
141
+ if (directTexts.length > 0) {
142
+ return directTexts.join(' ').replace(/\s+/g, ' ').substring(0, 100);
143
+ }
144
+
145
+ // Fallback: when the button text is wrapped in a child element (e.g. <button><span>Text</span></button>)
146
+ // Skip SVG icons and aria-hidden decorations (icon glyphs, counter badges).
147
+ const childTexts = Array.from(element.children)
148
+ .filter(child => {
149
+ if (child.tagName && child.tagName.toLowerCase() === 'svg') return false;
150
+ if (child.getAttribute && child.getAttribute('aria-hidden') === 'true') return false;
151
+ const style = window.getComputedStyle(child);
152
+ return style.display !== 'none' && style.visibility !== 'hidden';
153
+ })
154
+ .map(child => {
155
+ // Only get direct text nodes of this child, not further nested content
156
+ return Array.from(child.childNodes)
157
+ .filter(node => node.nodeType === Node.TEXT_NODE)
158
+ .map(node => node.textContent.trim())
159
+ .filter(t => t.length > 0)
160
+ .join(' ');
161
+ })
162
+ .filter(t => t.length > 0);
163
+
164
+ return childTexts.join(' ').replace(/\s+/g, ' ').substring(0, 100);
165
+ }
166
+
167
+ /**
168
+ * Get element's role (button, link, input, etc.)
169
+ * @param {Element} element - DOM element
170
+ * @returns {string} - Element role
171
+ */
172
+ export function getElementRole(element) {
173
+ // Check explicit ARIA role
174
+ const ariaRole = element.getAttribute('role');
175
+ if (ariaRole) return ariaRole;
176
+
177
+ // Infer from tag
178
+ const tagMap = {
179
+ 'BUTTON': 'button',
180
+ 'A': 'link',
181
+ 'INPUT': 'input',
182
+ 'SELECT': 'combobox',
183
+ 'TEXTAREA': 'textbox',
184
+ 'IMG': 'image',
185
+ 'H1': 'heading',
186
+ 'H2': 'heading',
187
+ 'H3': 'heading',
188
+ 'H4': 'heading',
189
+ 'H5': 'heading',
190
+ 'H6': 'heading',
191
+ 'NAV': 'navigation',
192
+ 'MAIN': 'main',
193
+ 'SECTION': 'region',
194
+ 'ARTICLE': 'article',
195
+ 'ASIDE': 'complementary',
196
+ 'FOOTER': 'contentinfo',
197
+ 'HEADER': 'banner',
198
+ 'FORM': 'form',
199
+ 'TABLE': 'table',
200
+ 'UL': 'list',
201
+ 'OL': 'list',
202
+ 'LI': 'listitem',
203
+ };
204
+
205
+ // Check for button-like behavior
206
+ if (element.onclick || element.getAttribute('onclick')) {
207
+ return 'button';
208
+ }
209
+
210
+ // Check for clickable divs/spans with click handlers
211
+ if (['DIV', 'SPAN'].includes(element.tagName)) {
212
+ if (element.style.cursor === 'pointer' || element.classList.contains('btn') ||
213
+ element.classList.contains('button') || element.getAttribute('tabindex') === '0') {
214
+ return 'button';
215
+ }
216
+ }
217
+
218
+ return tagMap[element.tagName] || 'generic';
219
+ }
220
+
221
+ /**
222
+ * Get element's bounding position
223
+ * @param {Element} element - DOM element
224
+ * @returns {Object} - { x, y, width, height }
225
+ */
226
+ export function getElementPosition(element) {
227
+ const rect = element.getBoundingClientRect();
228
+ return {
229
+ x: Math.round(rect.left + window.scrollX),
230
+ y: Math.round(rect.top + window.scrollY),
231
+ width: Math.round(rect.width),
232
+ height: Math.round(rect.height),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Check if element is visible
238
+ * @param {Element} element - DOM element
239
+ * @returns {boolean}
240
+ */
241
+ export function isElementVisible(element) {
242
+ if (!element) return false;
243
+
244
+ const style = window.getComputedStyle(element);
245
+
246
+ if (style.display === 'none') return false;
247
+ if (style.visibility === 'hidden') return false;
248
+ if (style.opacity === '0') return false;
249
+
250
+ const rect = element.getBoundingClientRect();
251
+ if (rect.width === 0 || rect.height === 0) return false;
252
+
253
+ return true;
254
+ }
255
+
256
+ /**
257
+ * Check if element is interactive
258
+ * @param {Element} element - DOM element
259
+ * @returns {boolean}
260
+ */
261
+ export function isInteractiveElement(element) {
262
+ const interactiveTags = ['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'];
263
+ const interactiveRoles = ['button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem', 'tab'];
264
+
265
+ if (interactiveTags.includes(element.tagName)) return true;
266
+
267
+ const role = element.getAttribute('role');
268
+ if (role && interactiveRoles.includes(role)) return true;
269
+
270
+ // Check for tabindex
271
+ const tabindex = element.getAttribute('tabindex');
272
+ if (tabindex && parseInt(tabindex, 10) >= 0) return true;
273
+
274
+ // Check for onclick
275
+ if (element.onclick || element.getAttribute('onclick')) return true;
276
+
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Get all interactive elements on page
282
+ * @param {Element} [root=document] - Root element to search from
283
+ * @returns {Element[]} - Array of interactive elements
284
+ */
285
+ export function getInteractiveElements(root = document) {
286
+ const selectors = [
287
+ 'button',
288
+ 'a[href]',
289
+ 'input:not([type="hidden"])',
290
+ 'select',
291
+ 'textarea',
292
+ '[role="button"]',
293
+ '[role="link"]',
294
+ '[role="checkbox"]',
295
+ '[role="radio"]',
296
+ '[role="menuitem"]',
297
+ '[role="tab"]',
298
+ '[onclick]',
299
+ '[tabindex]:not([tabindex="-1"])',
300
+ ];
301
+
302
+ const elements = Array.from(root.querySelectorAll(selectors.join(', ')));
303
+
304
+ return elements.filter(el => isElementVisible(el));
305
+ }
306
+
307
+ /**
308
+ * Create element from HTML string
309
+ * @param {string} html - HTML string
310
+ * @returns {Element}
311
+ */
312
+ export function createElement(html) {
313
+ const template = document.createElement('template');
314
+ template.innerHTML = html.trim();
315
+ return template.content.firstChild;
316
+ }
317
+
318
+ /**
319
+ * Wait for element to appear in DOM
320
+ * @param {string} selector - CSS selector
321
+ * @param {number} timeout - Timeout in ms
322
+ * @returns {Promise<Element>}
323
+ */
324
+ export function waitForElement(selector, timeout = 5000) {
325
+ return new Promise((resolve, reject) => {
326
+ const element = document.querySelector(selector);
327
+ if (element) {
328
+ resolve(element);
329
+ return;
330
+ }
331
+
332
+ const observer = new MutationObserver((mutations, obs) => {
333
+ const element = document.querySelector(selector);
334
+ if (element) {
335
+ obs.disconnect();
336
+ resolve(element);
337
+ }
338
+ });
339
+
340
+ observer.observe(document.body, {
341
+ childList: true,
342
+ subtree: true,
343
+ });
344
+
345
+ setTimeout(() => {
346
+ observer.disconnect();
347
+ reject(new Error(`Element ${selector} not found within ${timeout}ms`));
348
+ }, timeout);
349
+ });
350
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Hand Hold SDK - Event Bus
3
+ * Simple pub/sub event system for internal communication
4
+ */
5
+
6
+ class EventBus {
7
+ constructor() {
8
+ this.events = {};
9
+ }
10
+
11
+ /**
12
+ * Subscribe to an event
13
+ * @param {string} event - Event name
14
+ * @param {Function} callback - Callback function
15
+ * @returns {Function} - Unsubscribe function
16
+ */
17
+ on(event, callback) {
18
+ if (!this.events[event]) {
19
+ this.events[event] = [];
20
+ }
21
+
22
+ this.events[event].push(callback);
23
+
24
+ // Return unsubscribe function
25
+ return () => this.off(event, callback);
26
+ }
27
+
28
+ /**
29
+ * Subscribe to an event (one time only)
30
+ * @param {string} event - Event name
31
+ * @param {Function} callback - Callback function
32
+ */
33
+ once(event, callback) {
34
+ const onceCallback = (...args) => {
35
+ callback(...args);
36
+ this.off(event, onceCallback);
37
+ };
38
+ this.on(event, onceCallback);
39
+ }
40
+
41
+ /**
42
+ * Unsubscribe from an event
43
+ * @param {string} event - Event name
44
+ * @param {Function} callback - Callback function to remove
45
+ */
46
+ off(event, callback) {
47
+ if (!this.events[event]) return;
48
+
49
+ this.events[event] = this.events[event].filter(cb => cb !== callback);
50
+ }
51
+
52
+ /**
53
+ * Emit an event
54
+ * @param {string} event - Event name
55
+ * @param {...any} args - Arguments to pass to callbacks
56
+ */
57
+ emit(event, ...args) {
58
+ if (!this.events[event]) return;
59
+
60
+ this.events[event].forEach(callback => {
61
+ try {
62
+ callback(...args);
63
+ } catch (error) {
64
+ console.error(`[HandHold EventBus] Error in event handler for '${event}':`, error);
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Remove all listeners for an event (or all events)
71
+ * @param {string} [event] - Event name (optional)
72
+ */
73
+ clear(event) {
74
+ if (event) {
75
+ delete this.events[event];
76
+ } else {
77
+ this.events = {};
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Get all registered events
83
+ * @returns {string[]} - Array of event names
84
+ */
85
+ getEvents() {
86
+ return Object.keys(this.events);
87
+ }
88
+
89
+ /**
90
+ * Get listener count for an event
91
+ * @param {string} event - Event name
92
+ * @returns {number} - Number of listeners
93
+ */
94
+ listenerCount(event) {
95
+ return this.events[event]?.length || 0;
96
+ }
97
+ }
98
+
99
+ // Export singleton instance
100
+ const eventBus = new EventBus();
101
+ export default eventBus;
102
+ export { EventBus };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hand Hold SDK - Utils Index
3
+ */
4
+
5
+ export { default as eventBus, EventBus } from './eventBus.js';
6
+ export { default as logger, Logger, LOG_LEVELS } from './Logger.js';
7
+ export * as domUtils from './domUtils.js';
8
+ export * as stringUtils from './stringUtils.js';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Hand Hold SDK - String Utilities
3
+ * Helper functions for string manipulation
4
+ */
5
+
6
+ /**
7
+ * Convert string to snake_case
8
+ * @param {string} str - Input string
9
+ * @returns {string}
10
+ */
11
+ export function toSnakeCase(str) {
12
+ return str
13
+ .toLowerCase()
14
+ .replace(/\s+/g, '_')
15
+ .replace(/[^\w_]/g, '')
16
+ .replace(/_+/g, '_')
17
+ .replace(/^_|_$/g, '');
18
+ }
19
+
20
+ /**
21
+ * Convert snake_case to Title Case
22
+ * @param {string} str - Input string in snake_case
23
+ * @returns {string}
24
+ */
25
+ export function toTitleCase(str) {
26
+ return String(str)
27
+ // insert a boundary at camelCase transitions: "createOkr" -> "create Okr"
28
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
29
+ // split on underscores, hyphens, and whitespace
30
+ .split(/[_\-\s]+/)
31
+ .filter(Boolean)
32
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
33
+ .join(' ');
34
+ }
35
+
36
+ /**
37
+ * Convert to camelCase
38
+ * @param {string} str - Input string
39
+ * @returns {string}
40
+ */
41
+ export function toCamelCase(str) {
42
+ return str
43
+ .toLowerCase()
44
+ .replace(/[_\s]+(.)/g, (_, char) => char.toUpperCase());
45
+ }
46
+
47
+ /**
48
+ * Truncate string with ellipsis
49
+ * @param {string} str - Input string
50
+ * @param {number} maxLength - Maximum length
51
+ * @returns {string}
52
+ */
53
+ export function truncate(str, maxLength = 50) {
54
+ if (!str || str.length <= maxLength) return str;
55
+ return str.substring(0, maxLength - 3) + '...';
56
+ }
57
+
58
+ /**
59
+ * Capitalize first letter
60
+ * @param {string} str - Input string
61
+ * @returns {string}
62
+ */
63
+ export function capitalize(str) {
64
+ if (!str) return '';
65
+ return str.charAt(0).toUpperCase() + str.slice(1);
66
+ }
67
+
68
+ /**
69
+ * Generate hash from string
70
+ * @param {string} str - Input string
71
+ * @returns {string} - Hash string
72
+ */
73
+ export function hashString(str) {
74
+ let hash = 0;
75
+ if (!str || str.length === 0) return hash.toString(36);
76
+
77
+ for (let i = 0; i < str.length; i++) {
78
+ const char = str.charCodeAt(i);
79
+ hash = ((hash << 5) - hash) + char;
80
+ hash = hash & hash; // Convert to 32bit integer
81
+ }
82
+
83
+ return Math.abs(hash).toString(36);
84
+ }
85
+
86
+ /**
87
+ * Extract keywords from text
88
+ * @param {string} text - Input text
89
+ * @returns {string[]} - Array of keywords
90
+ */
91
+ export function extractKeywords(text) {
92
+ const stopWords = new Set([
93
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
94
+ 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
95
+ 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
96
+ 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'this',
97
+ 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they',
98
+ 'what', 'which', 'who', 'when', 'where', 'why', 'how', 'all', 'each',
99
+ 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no',
100
+ 'not', 'only', 'same', 'so', 'than', 'too', 'very', 'just', 'also',
101
+ ]);
102
+
103
+ return text
104
+ .toLowerCase()
105
+ .replace(/[^\w\s]/g, '')
106
+ .split(/\s+/)
107
+ .filter(word => word.length > 2 && !stopWords.has(word));
108
+ }
109
+
110
+ /**
111
+ * Calculate similarity between two strings (Levenshtein distance based)
112
+ * @param {string} str1 - First string
113
+ * @param {string} str2 - Second string
114
+ * @returns {number} - Similarity score (0-1)
115
+ */
116
+ export function similarity(str1, str2) {
117
+ if (!str1 || !str2) return 0;
118
+
119
+ str1 = str1.toLowerCase();
120
+ str2 = str2.toLowerCase();
121
+
122
+ if (str1 === str2) return 1;
123
+
124
+ const maxLength = Math.max(str1.length, str2.length);
125
+ if (maxLength === 0) return 1;
126
+
127
+ const distance = levenshteinDistance(str1, str2);
128
+ return 1 - (distance / maxLength);
129
+ }
130
+
131
+ /**
132
+ * Levenshtein distance between two strings
133
+ * @param {string} str1 - First string
134
+ * @param {string} str2 - Second string
135
+ * @returns {number} - Edit distance
136
+ */
137
+ function levenshteinDistance(str1, str2) {
138
+ const m = str1.length;
139
+ const n = str2.length;
140
+
141
+ const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
142
+
143
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
144
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
145
+
146
+ for (let i = 1; i <= m; i++) {
147
+ for (let j = 1; j <= n; j++) {
148
+ if (str1[i - 1] === str2[j - 1]) {
149
+ dp[i][j] = dp[i - 1][j - 1];
150
+ } else {
151
+ dp[i][j] = Math.min(
152
+ dp[i - 1][j] + 1, // deletion
153
+ dp[i][j - 1] + 1, // insertion
154
+ dp[i - 1][j - 1] + 1 // substitution
155
+ );
156
+ }
157
+ }
158
+ }
159
+
160
+ return dp[m][n];
161
+ }
162
+
163
+ /**
164
+ * Check if string contains all keywords
165
+ * @param {string} str - String to search
166
+ * @param {string[]} keywords - Keywords to find
167
+ * @returns {boolean}
168
+ */
169
+ export function containsAllKeywords(str, keywords) {
170
+ const lowerStr = str.toLowerCase();
171
+ return keywords.every(keyword => lowerStr.includes(keyword.toLowerCase()));
172
+ }
173
+
174
+ /**
175
+ * Pluralize a word
176
+ * @param {string} word - Word to pluralize
177
+ * @param {number} count - Count
178
+ * @returns {string}
179
+ */
180
+ export function pluralize(word, count) {
181
+ if (count === 1) return word;
182
+
183
+ // Simple rules
184
+ if (word.endsWith('y')) {
185
+ return word.slice(0, -1) + 'ies';
186
+ }
187
+ if (word.endsWith('s') || word.endsWith('x') || word.endsWith('ch') || word.endsWith('sh')) {
188
+ return word + 'es';
189
+ }
190
+ return word + 's';
191
+ }
192
+
193
+ /**
194
+ * Generate a unique ID
195
+ * @param {string} [prefix=''] - Prefix for the ID
196
+ * @returns {string}
197
+ */
198
+ export function generateId(prefix = '') {
199
+ const random = Math.random().toString(36).substring(2, 9);
200
+ const timestamp = Date.now().toString(36);
201
+ return prefix ? `${prefix}_${random}${timestamp}` : `${random}${timestamp}`;
202
+ }