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,293 @@
1
+ /**
2
+ * Hand Hold SDK - Page Observer
3
+ * Main observer that combines navigation detection and element scanning
4
+ * Uses MutationObserver to detect DOM changes
5
+ */
6
+
7
+ import NavigationObserver from './NavigationObserver.js';
8
+ import ElementScanner from './ElementScanner.js';
9
+ import eventBus from '../utils/eventBus.js';
10
+
11
+ class PageObserver {
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.navigationObserver = new NavigationObserver(config);
15
+ this.elementScanner = new ElementScanner(config);
16
+ this.mutationObserver = null;
17
+ this.isObserving = false;
18
+ this.debounceTimer = null;
19
+ this.debounceDelay = 250; // ms
20
+ this.lastScanResult = null;
21
+ }
22
+
23
+ /**
24
+ * Start observing page changes
25
+ */
26
+ observe() {
27
+ if (this.isObserving) return;
28
+
29
+ // Start navigation observer
30
+ this.navigationObserver.observe();
31
+
32
+ // Start mutation observer for DOM changes
33
+ this._startMutationObserver();
34
+
35
+ // Listen for navigation changes
36
+ eventBus.on('navigation:changed', (data) => this._onNavigationChanged(data));
37
+
38
+ this.isObserving = true;
39
+ }
40
+
41
+ /**
42
+ * Stop all observation
43
+ */
44
+ disconnect() {
45
+ if (!this.isObserving) return;
46
+
47
+ this.navigationObserver.disconnect();
48
+
49
+ if (this.mutationObserver) {
50
+ this.mutationObserver.disconnect();
51
+ this.mutationObserver = null;
52
+ }
53
+
54
+ if (this.debounceTimer) {
55
+ clearTimeout(this.debounceTimer);
56
+ this.debounceTimer = null;
57
+ }
58
+
59
+ this.isObserving = false;
60
+ }
61
+
62
+ /**
63
+ * Start MutationObserver for DOM changes
64
+ */
65
+ _startMutationObserver() {
66
+ this.mutationObserver = new MutationObserver((mutations) => {
67
+ this._onDOMChange(mutations);
68
+ });
69
+
70
+ this.mutationObserver.observe(document.body, {
71
+ childList: true,
72
+ subtree: true,
73
+ attributes: true,
74
+ attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden'],
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Handle DOM changes (debounced)
80
+ * @param {MutationRecord[]} mutations
81
+ */
82
+ _onDOMChange(mutations) {
83
+ // Skip Hand Hold's own changes
84
+ const isHandHoldChange = mutations.some(m => {
85
+ const target = m.target;
86
+ if (target.id?.startsWith('handhold-')) return true;
87
+ if (target.closest?.('#handhold-container')) return true;
88
+ return false;
89
+ });
90
+
91
+ if (isHandHoldChange) return;
92
+
93
+ // Debounce to avoid excessive processing
94
+ if (this.debounceTimer) {
95
+ clearTimeout(this.debounceTimer);
96
+ }
97
+
98
+ this.debounceTimer = setTimeout(() => {
99
+ this._emitDOMChanged();
100
+ }, this.debounceDelay);
101
+ }
102
+
103
+ /**
104
+ * Emit DOM changed event
105
+ */
106
+ _emitDOMChanged() {
107
+ eventBus.emit('page:dom_changed', {
108
+ route: this.navigationObserver.getRoute(),
109
+ timestamp: Date.now(),
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Handle navigation change
115
+ * @param {Object} data - Navigation data
116
+ */
117
+ _onNavigationChanged(data) {
118
+ // Clear cached scan result
119
+ this.lastScanResult = null;
120
+
121
+ // Emit page changed event after a short delay for content to load
122
+ setTimeout(() => {
123
+ eventBus.emit('page:changed', {
124
+ route: data.route,
125
+ previousRoute: data.previousRoute,
126
+ pathname: data.pathname,
127
+ timestamp: Date.now(),
128
+ });
129
+ }, 100);
130
+ }
131
+
132
+ /**
133
+ * Scan the current page
134
+ * @param {boolean} [forceRescan=false] - Force rescan even if cached
135
+ * @returns {Object} - { route, title, headings, ui_elements, forms }
136
+ */
137
+ scanPage(forceRescan = false) {
138
+ const currentRoute = this.navigationObserver.getRoute();
139
+
140
+ // Return cached result if available and route hasn't changed
141
+ if (!forceRescan && this.lastScanResult && this.lastScanResult.route === currentRoute) {
142
+ return this.lastScanResult;
143
+ }
144
+
145
+ // Get page context
146
+ const context = this.elementScanner.getPageContext();
147
+
148
+ // Scan UI elements
149
+ const uiElements = this.elementScanner.scan();
150
+
151
+ this.lastScanResult = {
152
+ route: context.route,
153
+ full_route: currentRoute,
154
+ title: context.title,
155
+ headings: context.headings,
156
+ forms: context.forms,
157
+ ui_elements: uiElements,
158
+ scanned_at: Date.now(),
159
+ };
160
+
161
+ return this.lastScanResult;
162
+ }
163
+
164
+ /**
165
+ * Get current route
166
+ * @returns {string}
167
+ */
168
+ getRoute() {
169
+ return this.navigationObserver.getRoute();
170
+ }
171
+
172
+ /**
173
+ * Get current pathname
174
+ * @returns {string}
175
+ */
176
+ getPathname() {
177
+ return this.navigationObserver.getPathname();
178
+ }
179
+
180
+ /**
181
+ * Check if element exists
182
+ * @param {string} selector
183
+ * @returns {boolean}
184
+ */
185
+ elementExists(selector) {
186
+ return this.elementScanner.elementExists(selector);
187
+ }
188
+
189
+ /**
190
+ * Find element by selector
191
+ * @param {string} selector
192
+ * @returns {Element|null}
193
+ */
194
+ findElement(selector) {
195
+ return this.elementScanner.findElement(selector);
196
+ }
197
+
198
+ /**
199
+ * Get buttons on current page
200
+ * @returns {Array}
201
+ */
202
+ getButtons() {
203
+ const result = this.scanPage();
204
+ return result.ui_elements.filter(el => el.role === 'button');
205
+ }
206
+
207
+ /**
208
+ * Get links on current page
209
+ * @returns {Array}
210
+ */
211
+ getLinks() {
212
+ const result = this.scanPage();
213
+ return result.ui_elements.filter(el => el.role === 'link');
214
+ }
215
+
216
+ /**
217
+ * Get primary actions (prominent buttons)
218
+ * @returns {Array}
219
+ */
220
+ getPrimaryActions() {
221
+ const result = this.scanPage();
222
+
223
+ // Filter for likely primary actions
224
+ return result.ui_elements.filter(el => {
225
+ if (el.role !== 'button') return false;
226
+ if (el.disabled) return false;
227
+
228
+ // Check for primary styling indicators
229
+ const selector = el.selector.toLowerCase();
230
+ const text = (el.text + ' ' + el.aria_label).toLowerCase();
231
+
232
+ const isPrimary =
233
+ selector.includes('primary') ||
234
+ selector.includes('submit') ||
235
+ selector.includes('main') ||
236
+ text.includes('save') ||
237
+ text.includes('submit') ||
238
+ text.includes('create') ||
239
+ text.includes('approve') ||
240
+ text.includes('confirm');
241
+
242
+ return isPrimary || el.position.y < 500; // Above fold
243
+ }).slice(0, 6);
244
+ }
245
+
246
+ /**
247
+ * Wait for an element to appear
248
+ * @param {string} selector
249
+ * @param {number} timeout
250
+ * @returns {Promise<Element>}
251
+ */
252
+ waitForElement(selector, timeout = 5000) {
253
+ return new Promise((resolve, reject) => {
254
+ // Check if already exists
255
+ const existing = this.findElement(selector);
256
+ if (existing) {
257
+ resolve(existing);
258
+ return;
259
+ }
260
+
261
+ const startTime = Date.now();
262
+
263
+ const observer = new MutationObserver(() => {
264
+ const element = this.findElement(selector);
265
+ if (element) {
266
+ observer.disconnect();
267
+ resolve(element);
268
+ } else if (Date.now() - startTime > timeout) {
269
+ observer.disconnect();
270
+ reject(new Error(`Element ${selector} not found within ${timeout}ms`));
271
+ }
272
+ });
273
+
274
+ observer.observe(document.body, {
275
+ childList: true,
276
+ subtree: true,
277
+ });
278
+
279
+ // Fallback timeout
280
+ setTimeout(() => {
281
+ observer.disconnect();
282
+ const element = this.findElement(selector);
283
+ if (element) {
284
+ resolve(element);
285
+ } else {
286
+ reject(new Error(`Element ${selector} not found within ${timeout}ms`));
287
+ }
288
+ }, timeout);
289
+ });
290
+ }
291
+ }
292
+
293
+ export default PageObserver;
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Hand Hold SDK - Session Manager
3
+ * Manages guidance sessions with multi-page tracking support
4
+ */
5
+
6
+ import SessionStorage from './SessionStorage.js';
7
+ import eventBus from '../utils/eventBus.js';
8
+
9
+ const SESSION_KEY = 'session';
10
+ const SESSION_HISTORY_KEY = 'session_history';
11
+
12
+ // Bump when the persisted session shape changes. On load, a session written by
13
+ // an older build (different or absent version) is discarded so a stale shape
14
+ // can't break new code (Phase 2.3). v2 added agent_checkpoint (H7).
15
+ const SESSION_SCHEMA_VERSION = 2;
16
+
17
+ class SessionManager {
18
+ constructor(config) {
19
+ this.config = config;
20
+ this.storage = new SessionStorage();
21
+ this.currentSession = null;
22
+ }
23
+
24
+ /**
25
+ * Generate unique session ID
26
+ * @returns {string}
27
+ */
28
+ _generateSessionId() {
29
+ const random = Math.random().toString(36).substring(2, 11);
30
+ const timestamp = Date.now().toString(36);
31
+ return `ses_${random}${timestamp}`;
32
+ }
33
+
34
+ /**
35
+ * Get or create session ID
36
+ * @returns {string}
37
+ */
38
+ getSessionId() {
39
+ const session = this.getActiveSession();
40
+ if (session) {
41
+ return session.session_id;
42
+ }
43
+ return this._generateSessionId();
44
+ }
45
+
46
+ /**
47
+ * Start a new guidance session
48
+ * @param {Object} data - { intent, steps, original_route }
49
+ * @returns {Object} - Session object
50
+ */
51
+ startSession(data) {
52
+ // End any existing session
53
+ if (this.currentSession) {
54
+ this.endSession('abandoned');
55
+ }
56
+
57
+ const session = {
58
+ schema_version: SESSION_SCHEMA_VERSION,
59
+ session_id: data.session_id || this._generateSessionId(),
60
+ intent: data.intent,
61
+ intent_label: data.intent_label || data.intent,
62
+ steps: data.steps || [],
63
+ current_step: 0,
64
+ steps_completed: 0,
65
+ total_steps: data.steps?.length || 0,
66
+ original_route: data.original_route,
67
+ current_route: data.original_route,
68
+ started_at: Date.now(),
69
+ last_activity: Date.now(),
70
+ status: 'active',
71
+ navigation_history: [data.original_route],
72
+ };
73
+
74
+ this.currentSession = session;
75
+ this._saveSession(session);
76
+
77
+ eventBus.emit('session:started', session);
78
+
79
+ return session;
80
+ }
81
+
82
+ /**
83
+ * Get the active session
84
+ * @returns {Object|null}
85
+ */
86
+ getActiveSession() {
87
+ // Check memory first
88
+ if (this.currentSession) {
89
+ return this.currentSession;
90
+ }
91
+
92
+ // Check storage
93
+ const session = this.storage.get(SESSION_KEY);
94
+
95
+ if (!session) {
96
+ return null;
97
+ }
98
+
99
+ // Discard sessions written by an older build (Phase 2.3): a stale shape must
100
+ // not flow into new code.
101
+ if (session.schema_version !== SESSION_SCHEMA_VERSION) {
102
+ this.storage.remove(SESSION_KEY);
103
+ return null;
104
+ }
105
+
106
+ // Check if session has expired
107
+ // Use internal property access instead of getActiveSession to prevent recursion
108
+ if (this._isSessionExpired(session)) {
109
+ this.endSession('expired');
110
+ return null;
111
+ }
112
+
113
+ this.currentSession = session;
114
+ return session;
115
+ }
116
+
117
+ /**
118
+ * Check if there's an active session
119
+ * @returns {boolean}
120
+ */
121
+ hasActiveSession() {
122
+ return this.getActiveSession() !== null;
123
+ }
124
+
125
+ /**
126
+ * Check if session has expired due to inactivity
127
+ * @param {Object} session
128
+ * @returns {boolean}
129
+ */
130
+ _isSessionExpired(session) {
131
+ const now = Date.now();
132
+ const lastActivity = session.last_activity || session.started_at;
133
+ const inactiveTime = now - lastActivity;
134
+ return inactiveTime > this.config.sessionInactivityTimeout;
135
+ }
136
+
137
+ /**
138
+ * Update the current session
139
+ * @param {Object} updates - Fields to update
140
+ */
141
+ updateSession(updates) {
142
+ const session = this.getActiveSession();
143
+ if (!session) return null;
144
+
145
+ Object.assign(session, updates, {
146
+ last_activity: Date.now(),
147
+ });
148
+
149
+ this.currentSession = session;
150
+ this._saveSession(session);
151
+
152
+ eventBus.emit('session:updated', session);
153
+
154
+ return session;
155
+ }
156
+
157
+ /**
158
+ * Record a navigation event
159
+ * @param {string} newRoute - New route
160
+ */
161
+ recordNavigation(newRoute) {
162
+ const session = this.getActiveSession();
163
+ if (!session) return;
164
+
165
+ session.current_route = newRoute;
166
+ session.last_activity = Date.now();
167
+
168
+ if (!session.navigation_history.includes(newRoute)) {
169
+ session.navigation_history.push(newRoute);
170
+ }
171
+
172
+ this._saveSession(session);
173
+ eventBus.emit('session:navigation', { session, route: newRoute });
174
+ }
175
+
176
+ /**
177
+ * Advance to the next step
178
+ * @returns {Object|null} - Next step or null if completed
179
+ */
180
+ advanceStep() {
181
+ const session = this.getActiveSession();
182
+ if (!session) return null;
183
+
184
+ session.steps_completed++;
185
+ session.current_step++;
186
+ session.last_activity = Date.now();
187
+
188
+ // Check if all steps completed
189
+ if (session.current_step >= session.total_steps) {
190
+ session.status = 'completed';
191
+ this._saveSession(session);
192
+ eventBus.emit('session:completed', session);
193
+ return null;
194
+ }
195
+
196
+ this._saveSession(session);
197
+ eventBus.emit('session:step_advanced', {
198
+ session,
199
+ step: session.steps[session.current_step],
200
+ stepIndex: session.current_step,
201
+ });
202
+
203
+ return session.steps[session.current_step];
204
+ }
205
+
206
+ /**
207
+ * Get current step
208
+ * @returns {Object|null}
209
+ */
210
+ getCurrentStep() {
211
+ const session = this.getActiveSession();
212
+ if (!session || session.current_step >= session.total_steps) {
213
+ return null;
214
+ }
215
+ return session.steps[session.current_step];
216
+ }
217
+
218
+ /**
219
+ * Get step by index
220
+ * @param {number} index - Step index
221
+ * @returns {Object|null}
222
+ */
223
+ getStep(index) {
224
+ const session = this.getActiveSession();
225
+ if (!session || index < 0 || index >= session.total_steps) {
226
+ return null;
227
+ }
228
+ return session.steps[index];
229
+ }
230
+
231
+ /**
232
+ * Update step selector (after re-matching on new page)
233
+ * @param {number} stepIndex - Step index
234
+ * @param {Object} updates - { selector, confidence, match_type }
235
+ */
236
+ updateStep(stepIndex, updates) {
237
+ const session = this.getActiveSession();
238
+ if (!session || stepIndex < 0 || stepIndex >= session.total_steps) {
239
+ return;
240
+ }
241
+
242
+ Object.assign(session.steps[stepIndex], updates);
243
+ session.last_activity = Date.now();
244
+
245
+ this._saveSession(session);
246
+ }
247
+
248
+ // ============================================
249
+ // Agent checkpoint (H7)
250
+ // ============================================
251
+ // The "Do it for me" agent persists a run checkpoint on the session object
252
+ // so a task survives full page loads. The checkpoint rides the session in
253
+ // per-tab sessionStorage (same trust boundary as the rest of the session);
254
+ // schema/TTL/size validation happens in AgentMode on restore.
255
+
256
+ /**
257
+ * Attach an agent checkpoint to the active session.
258
+ * @param {Object} checkpoint - {v, task, stepsUsed, history, audit, savedAt}
259
+ * @returns {boolean} - false when there is no active session to carry it
260
+ */
261
+ saveAgentCheckpoint(checkpoint) {
262
+ const session = this.getActiveSession();
263
+ if (!session) return false;
264
+
265
+ session.agent_checkpoint = checkpoint;
266
+ session.last_activity = Date.now();
267
+ this._saveSession(session);
268
+ return true;
269
+ }
270
+
271
+ /**
272
+ * The active session's agent checkpoint, if any.
273
+ * @returns {Object|null}
274
+ */
275
+ getAgentCheckpoint() {
276
+ const session = this.getActiveSession();
277
+ return session?.agent_checkpoint ?? null;
278
+ }
279
+
280
+ /** Remove the agent checkpoint from the active session. */
281
+ clearAgentCheckpoint() {
282
+ const session = this.getActiveSession();
283
+ if (!session || session.agent_checkpoint === undefined) return;
284
+
285
+ delete session.agent_checkpoint;
286
+ this._saveSession(session);
287
+ }
288
+
289
+ /**
290
+ * End the current session
291
+ * @param {string} [reason='closed'] - Reason for ending
292
+ */
293
+ endSession(reason = 'closed') {
294
+ // Get session directly from property or storage to avoid recursion loop
295
+ const session = this.currentSession || this.storage.get(SESSION_KEY);
296
+
297
+ if (!session) return;
298
+
299
+ session.status = reason === 'completed' ? 'completed' : 'ended';
300
+ session.ended_at = Date.now();
301
+ session.end_reason = reason;
302
+
303
+ // Save to history
304
+ this._saveToHistory(session);
305
+
306
+ // Clear current session
307
+ this.currentSession = null;
308
+ this.storage.remove(SESSION_KEY);
309
+
310
+ eventBus.emit('session:ended', { session, reason });
311
+ }
312
+
313
+ /**
314
+ * Save session to storage
315
+ * @param {Object} session
316
+ */
317
+ _saveSession(session) {
318
+ this.storage.set(SESSION_KEY, session);
319
+ }
320
+
321
+ /**
322
+ * Save completed session to history
323
+ * @param {Object} session
324
+ */
325
+ _saveToHistory(session) {
326
+ const history = this.storage.get(SESSION_HISTORY_KEY) || [];
327
+
328
+ // Keep only last 10 sessions
329
+ history.unshift({
330
+ session_id: session.session_id,
331
+ intent: session.intent,
332
+ status: session.status,
333
+ started_at: session.started_at,
334
+ ended_at: session.ended_at,
335
+ steps_completed: session.steps_completed,
336
+ total_steps: session.total_steps,
337
+ });
338
+
339
+ if (history.length > 10) {
340
+ history.pop();
341
+ }
342
+
343
+ this.storage.set(SESSION_HISTORY_KEY, history);
344
+ }
345
+
346
+ /**
347
+ * Get session history
348
+ * @returns {Array}
349
+ */
350
+ getSessionHistory() {
351
+ return this.storage.get(SESSION_HISTORY_KEY) || [];
352
+ }
353
+
354
+ /**
355
+ * Check if navigation is within the same flow
356
+ * @param {string} newRoute - New route
357
+ * @returns {boolean}
358
+ */
359
+ isInSameFlow(newRoute) {
360
+ const session = this.getActiveSession();
361
+ if (!session) return false;
362
+
363
+ const originalRoute = session.original_route;
364
+
365
+ // Extract route base (first 2 segments)
366
+ const getRouteBase = (route) => route.split('/').filter(Boolean).slice(0, 2).join('/');
367
+
368
+ const originalBase = getRouteBase(originalRoute);
369
+ const newBase = getRouteBase(newRoute);
370
+
371
+ // Same base = same flow
372
+ if (originalBase === newBase) return true;
373
+
374
+ // Check if intent keywords match new route
375
+ const intentKeywords = session.intent.split('_');
376
+ const inNewRoute = intentKeywords.some(keyword =>
377
+ newRoute.toLowerCase().includes(keyword.toLowerCase())
378
+ );
379
+
380
+ return inNewRoute;
381
+ }
382
+
383
+ /**
384
+ * Get progress percentage
385
+ * @returns {number} - 0-100
386
+ */
387
+ getProgress() {
388
+ const session = this.getActiveSession();
389
+ if (!session || session.total_steps === 0) return 0;
390
+ return Math.round((session.steps_completed / session.total_steps) * 100);
391
+ }
392
+
393
+ /**
394
+ * Clear all session data
395
+ */
396
+ clearAll() {
397
+ this.currentSession = null;
398
+ this.storage.clear();
399
+ }
400
+ }
401
+
402
+ export default SessionManager;