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.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- 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;
|