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,429 @@
1
+ /**
2
+ * Hand Hold SDK - AgentMode (Task H5)
3
+ *
4
+ * Thin bridge between Handhold (Config/EventBus/HelpWidget/UICoach) and the
5
+ * vendored trusted-page-agent engine. AgentMode owns the engine lifecycle for
6
+ * "Do it for me" runs; the widget renders what the engine reports and answers
7
+ * its confirm prompts. Feature-flagged: HandHold only constructs this when
8
+ * `config.agentMode` is true (SDK default false) — "Show me"/"Explain" paths
9
+ * are untouched.
10
+ *
11
+ * Security posture (inherits the engine's guarantees — never weaken here):
12
+ * - Model calls ride the Handhold backend proxy (`/api/v1/agent/llm`) using
13
+ * the tenant apiKey the SDK already has. No LLM provider key in the browser.
14
+ * - `allowedOrigins` is pinned to the current origin.
15
+ * - onConfirm stays fail-closed: the returned promise only resolves when the
16
+ * user answers a confirm card; anything except an explicit `true` denies.
17
+ * - The widget's own DOM (#handhold-container and every descendant) is
18
+ * blacklisted from engine snapshots, so the agent can never "see" or click
19
+ * Handhold's own UI (no self-driving loops, no Allow-button self-clicks).
20
+ *
21
+ * Events (per plan H5):
22
+ * in: 'agent:start' (agent_plan from the widget button), 'agent:stop'
23
+ * out: 'agent:activity' (engine activity, executing events enriched with a
24
+ * best-effort targetSelector for UICoach), 'agent:confirm'
25
+ * ({pending, respond}), 'agent:ask' ({question, respond}),
26
+ * 'agent:done' ({success, text, steps})
27
+ */
28
+ import { generateSelector } from '../utils/domUtils.js';
29
+
30
+ const WIDGET_CONTAINER_ID = 'handhold-container';
31
+ const LLM_PROXY_PATH = '/api/v1/agent/llm';
32
+
33
+ class AgentMode {
34
+ /**
35
+ * @param {Object} deps
36
+ * @param {Object} deps.config Config instance (or stub with get())
37
+ * @param {Object} deps.eventBus Handhold event bus
38
+ * @param {Object} [deps.sessionManager] SessionManager — carries the agent
39
+ * checkpoint across full page loads (H7). Optional: without it runs
40
+ * work but do not survive reloads.
41
+ * @param {(url:string)=>void} [deps.navigateImpl] performs the actual
42
+ * navigation after checkpointing (default location.assign; test seam)
43
+ * @param {(cfg:Object)=>Object} [deps.engineFactory]
44
+ * Test seam: receives the exact TrustedPageAgent config and returns an
45
+ * engine ({run, stop, getAuditLog}). Default lazily imports the
46
+ * vendored engine — lazy so environments that never start a run
47
+ * (and jest, which fakes the engine) never load it.
48
+ */
49
+ constructor({ config, eventBus, sessionManager, navigateImpl, engineFactory } = {}) {
50
+ if (!config) throw new Error('[HandHold AgentMode] config is required');
51
+ this.config = config;
52
+ this.eventBus = eventBus || null;
53
+ this.sessionManager = sessionManager || null;
54
+ this._navigate = navigateImpl || ((url) => globalThis.window?.location?.assign(url));
55
+ this._customFactory = typeof engineFactory === 'function';
56
+ this.engineFactory = this._customFactory
57
+ ? engineFactory
58
+ : async (cfg) => {
59
+ const { default: TrustedPageAgent } = await import(
60
+ /* webpackMode: "eager" */ '../agent/index.js'
61
+ );
62
+ return new TrustedPageAgent(cfg);
63
+ };
64
+ this.engine = null;
65
+ this.running = false;
66
+ this._currentTask = null;
67
+ this._abort = null; // per-run AbortController (H9: responsive Stop)
68
+ this._engineMod = null; // vendored module, preloaded for spotlight resolution
69
+ this._manifestPromise = null; // one fetch per AgentMode instance (H6)
70
+
71
+ if (this.eventBus) {
72
+ this.eventBus.on('agent:start', (plan) => {
73
+ this.start(plan);
74
+ });
75
+ this.eventBus.on('agent:stop', () => this.stop());
76
+ // SPA settle only (H7): in-app route changes keep this JS context alive,
77
+ // so a running engine simply continues — resume is BOOT-ONLY (a full
78
+ // reload destroyed the context). Never resume from here.
79
+ this.eventBus.on('navigation:changed', () => {});
80
+ }
81
+ }
82
+
83
+ _get(key) {
84
+ return typeof this.config.get === 'function' ? this.config.get(key) : this.config[key];
85
+ }
86
+
87
+ _emit(name, payload) {
88
+ if (this.eventBus) this.eventBus.emit(name, payload);
89
+ }
90
+
91
+ /**
92
+ * The Handhold-UI blacklist: a function re-evaluated on every engine snapshot,
93
+ * returning every Handhold widget element + ALL descendants so the agent can
94
+ * never see or click its own UI (the engine matches exact elements — see
95
+ * resolveElementList in the vendored serializer).
96
+ *
97
+ * Covers BOTH the SDK's own container (`#handhold-container`) AND any host-
98
+ * rendered Handhold UI marked `data-handhold-ui`. The demo replaces the
99
+ * legacy widget with its own React panels; without the marker those panels
100
+ * were visible to the agent, which then mis-clicked its own controls.
101
+ */
102
+ _widgetBlacklist() {
103
+ return [
104
+ () => {
105
+ const roots = [
106
+ document.getElementById(WIDGET_CONTAINER_ID),
107
+ ...document.querySelectorAll('[data-handhold-ui]'),
108
+ ].filter(Boolean);
109
+ const out = [];
110
+ for (const root of roots) {
111
+ out.push(root, ...root.querySelectorAll('*'));
112
+ }
113
+ return out;
114
+ },
115
+ ];
116
+ }
117
+
118
+ /**
119
+ * Fetch the tenant's site manifest once and cache it (H6). The server only
120
+ * serves reviewed manifests (404 otherwise); reviewed===true is re-checked
121
+ * here anyway, and the engine's loadSiteManifest enforces it a third time at
122
+ * construction — an unreviewed map must never steer the agent. Any failure
123
+ * (404, network, bad JSON) degrades to null: single-page mode, run proceeds.
124
+ */
125
+ _getSiteManifest() {
126
+ if (!this._manifestPromise) {
127
+ this._manifestPromise = (async () => {
128
+ try {
129
+ const res = await globalThis.fetch(`${this._get('baseUrl')}/api/v1/manifest`, {
130
+ headers: { Authorization: `Bearer ${this._get('apiKey')}` },
131
+ });
132
+ if (!res || !res.ok) return null;
133
+ const json = await res.json();
134
+ return json && json.reviewed === true ? json : null;
135
+ } catch (e) {
136
+ return null;
137
+ }
138
+ })();
139
+ }
140
+ return this._manifestPromise;
141
+ }
142
+
143
+ /** The exact TrustedPageAgent config (also what tests assert against). */
144
+ _engineConfig() {
145
+ return {
146
+ llm: {
147
+ provider: 'openai-compatible',
148
+ baseURL: `${this._get('baseUrl')}${LLM_PROXY_PATH}`,
149
+ apiKey: this._get('apiKey'),
150
+ model: 'tenant-default', // hint only — the backend proxy picks the real model
151
+ },
152
+ allowedOrigins: [window.location.origin],
153
+ onConfirm: (pending) =>
154
+ new Promise((resolve) => {
155
+ let settled = false;
156
+ const respond = (allowed) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ resolve(allowed === true); // fail-closed: only explicit true allows
160
+ };
161
+ this._emit('agent:confirm', { pending, respond });
162
+ }),
163
+ onAskUser: (question) =>
164
+ new Promise((resolve, reject) => {
165
+ let settled = false;
166
+ this._emit('agent:ask', {
167
+ question,
168
+ respond: (answer) => {
169
+ if (settled) return;
170
+ settled = true;
171
+ resolve(String(answer ?? ''));
172
+ },
173
+ cancel: () => {
174
+ if (settled) return;
175
+ settled = true;
176
+ reject(new Error('dismissed'));
177
+ },
178
+ });
179
+ }),
180
+ onActivity: (ev) => this._onEngineActivity(ev),
181
+ domConfig: { interactiveBlacklist: this._widgetBlacklist() },
182
+ // H7: every engine navigation checkpoints first, then really navigates
183
+ navigateImpl: (url) => this._checkpointAndNavigate(url),
184
+ maxSteps: 25,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Write the run checkpoint, then navigate (H7). Called by the engine's
190
+ * controller AFTER the guard allowed the navigation. The checkpoint is
191
+ * best-effort — a failure to save must never block the navigation itself.
192
+ * The in-flight navigate step is not in engine history yet (the loop pushes
193
+ * after the action), so a synthetic marker entry records where we headed.
194
+ */
195
+ async _checkpointAndNavigate(url) {
196
+ try {
197
+ if (this.sessionManager && this.engine && typeof this.engine.getRunState === 'function') {
198
+ const st = this.engine.getRunState() || {};
199
+ const history = (st.history ?? []).slice(-50).map(String);
200
+ history.push(`<step_${st.stepsUsed ?? '?'}> navigate → heading to ${url} (checkpoint saved)`);
201
+ const checkpoint = {
202
+ v: 1,
203
+ task: this._currentTask ?? '',
204
+ stepsUsed: Number.isInteger(st.stepsUsed) ? st.stepsUsed : 0,
205
+ history: history.slice(-50),
206
+ audit: (st.audit ?? []).slice(-50),
207
+ savedAt: Date.now(),
208
+ };
209
+ // size caps write-side too: drop audit first, then trim history harder
210
+ if (JSON.stringify(checkpoint).length > 40000) checkpoint.audit = [];
211
+ if (JSON.stringify(checkpoint).length > 40000) checkpoint.history = checkpoint.history.slice(-20);
212
+ this.sessionManager.saveAgentCheckpoint(checkpoint);
213
+ }
214
+ } catch (e) {
215
+ /* checkpoint is best-effort; navigation must proceed */
216
+ }
217
+ return this._navigate(url);
218
+ }
219
+
220
+ /**
221
+ * Restore-side checkpoint validation (spec §7). Returns a human-readable
222
+ * problem string, or null when the checkpoint is safe to resume.
223
+ */
224
+ _checkpointProblem(cp) {
225
+ if (!cp || typeof cp !== 'object') return 'malformed (not an object)';
226
+ if (cp.v !== 1) return `malformed (unsupported checkpoint version ${cp.v})`;
227
+ if (typeof cp.task !== 'string' || !cp.task.trim()) return 'malformed (missing task)';
228
+ if (!Array.isArray(cp.history) || cp.history.some((h) => typeof h !== 'string')) {
229
+ return 'malformed (history must be an array of strings)';
230
+ }
231
+ if (!Number.isInteger(cp.stepsUsed) || cp.stepsUsed < 0) return 'malformed (bad stepsUsed)';
232
+ if (typeof cp.savedAt !== 'number') return 'malformed (bad savedAt)';
233
+ if (Date.now() - cp.savedAt > 10 * 60 * 1000) return 'stale (older than 10 minutes)';
234
+ if (cp.history.length > 50) return 'oversized (history exceeds 50 entries)';
235
+ try {
236
+ if (JSON.stringify(cp).length > 40000) return 'oversized (exceeds 40 kB)';
237
+ } catch {
238
+ return 'malformed (not serializable)';
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * Boot-time resume (H7): if the active session carries a checkpoint, either
245
+ * resume it (restored history + remaining step budget) or discard it with an
246
+ * audited reason. SPA route changes never reach this — resume is boot-only.
247
+ * @returns {Promise<{resumed:boolean, reason?:string, result?:Object}>}
248
+ */
249
+ async resumeIfCheckpointed() {
250
+ const sm = this.sessionManager;
251
+ if (!sm || typeof sm.getAgentCheckpoint !== 'function') return { resumed: false };
252
+ const cp = sm.getAgentCheckpoint();
253
+ if (!cp) return { resumed: false };
254
+
255
+ const reason = this._checkpointProblem(cp);
256
+ if (reason) {
257
+ try {
258
+ sm.clearAgentCheckpoint();
259
+ } catch (e) {
260
+ /* discard is best-effort */
261
+ }
262
+ this._emit('agent:activity', { type: 'checkpoint_discarded', reason });
263
+ return { resumed: false, reason };
264
+ }
265
+
266
+ // Start the resumed run in the BACKGROUND so boot completes promptly (the
267
+ // run may be long and may navigate again). The run clears the checkpoint
268
+ // when it finishes. runPromise is exposed for callers/tests that want to
269
+ // await completion.
270
+ const runPromise = this._run(
271
+ cp.task,
272
+ { restoredHistory: cp.history, stepsUsed: cp.stepsUsed },
273
+ { type: 'resuming', task: cp.task }
274
+ );
275
+ return { resumed: true, runPromise };
276
+ }
277
+
278
+ _onEngineActivity(ev) {
279
+ const enriched = { ...ev };
280
+ if (ev && ev.type === 'executing' && ev.input && typeof ev.input.index === 'number') {
281
+ enriched.targetSelector = this._resolveTargetSelector(ev.input.index);
282
+ }
283
+ this._emit('agent:activity', enriched);
284
+ }
285
+
286
+ /**
287
+ * Best-effort: map the engine-reported element index back to a selector so
288
+ * UICoach can spotlight what is about to be acted on. A fresh snapshot with
289
+ * the same domConfig yields the same indices in practice; any failure (e.g.
290
+ * no layout engine) degrades to null — never blocks the run.
291
+ */
292
+ _resolveTargetSelector(index) {
293
+ try {
294
+ if (!this._engineMod) return null;
295
+ const snapshot = this._engineMod.domSnapshotProvider({
296
+ interactiveBlacklist: this._widgetBlacklist(),
297
+ })();
298
+ const el = snapshot.selectorMap.get(index)?.ref;
299
+ return el ? generateSelector(el) : null;
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Frame the run task from the guidance agent_plan. The plan is trusted
307
+ * backend output (a restatement of the reviewed KB), unlike page content.
308
+ */
309
+ buildTask(plan = {}) {
310
+ const goal = String(plan.goal || '').trim() || 'Complete the task the user requested.';
311
+ const lines = [goal];
312
+ if (Array.isArray(plan.hints) && plan.hints.length) {
313
+ lines.push('', 'Guidance from the help article (trusted):');
314
+ for (const hint of plan.hints.slice(0, 7)) lines.push(`- ${hint}`);
315
+ }
316
+ if (plan.target_page_path) {
317
+ lines.push(
318
+ '',
319
+ `This task is normally performed on the page at ${plan.target_page_path}. ` +
320
+ 'If the current page is not that page, work with what is visible or ' +
321
+ 'report what is missing — never guess URLs.'
322
+ );
323
+ }
324
+ // Behavioral rules surfaced by live testing (see feedback memory):
325
+ // (1) stop when done — the model was completing tasks but not calling done,
326
+ // looping to the step cap; (2) ask, never invent, missing input values.
327
+ lines.push(
328
+ '',
329
+ 'Rules for this task:',
330
+ '- The moment the objective above is achieved, call done(success=true) with a ' +
331
+ 'one-line summary. Do NOT keep acting once the goal is met, and do not ' +
332
+ 're-verify by repeating actions.',
333
+ '- If a field needs a value you were not given (a title, name, amount, date, ' +
334
+ 'email, or similar), call ask_user FIRST and WAIT for the answer, THEN type ' +
335
+ 'EXACTLY what the person said. Do NOT type a value before asking, and never ' +
336
+ 'invent, guess, use a placeholder, or copy an unrelated value from the page ' +
337
+ '(e.g. an existing row) into an input field.',
338
+ '- Ask for a given value only ONCE. After the person answers, immediately use ' +
339
+ 'their exact answer in your next action — never ask the same thing again.',
340
+ '- If a form needs SEVERAL values you do not have (e.g. first name, last name, ' +
341
+ 'email), ask for them ALL in ONE ask_user question (list them) rather than ' +
342
+ 'one at a time, then fill each field with the matching answer.',
343
+ '- After each action, check the Current page and the visible elements to ' +
344
+ 'confirm it had the effect you intended BEFORE moving on. If the page did ' +
345
+ 'not change when you expected navigation, the element was wrong — pick a ' +
346
+ 'different element or use the navigate tool; do not assume it worked.',
347
+ '- If you are blocked or an action is denied, call done(success=false) and ' +
348
+ 'explain — do not retry a blocked action.'
349
+ );
350
+ return lines.join('\n');
351
+ }
352
+
353
+ /**
354
+ * Start a run from an agent_plan. One run at a time.
355
+ * @returns {Promise<{success:boolean, text:string, steps:number}|null>}
356
+ */
357
+ async start(plan) {
358
+ const task = this.buildTask(plan);
359
+ return this._run(task, {}, { type: 'run_start', goal: plan?.goal ?? task });
360
+ }
361
+
362
+ /**
363
+ * Shared run path for fresh starts and checkpoint resumes.
364
+ * @param {string} task
365
+ * @param {Object} engineOpts {restoredHistory?, stepsUsed?} on resume
366
+ * @param {Object} startActivity emitted once the engine is ready
367
+ */
368
+ async _run(task, engineOpts, startActivity) {
369
+ if (this.running) {
370
+ this._emit('agent:activity', { type: 'info', message: 'Agent is already running.' });
371
+ return null;
372
+ }
373
+ this.running = true;
374
+ this._currentTask = task;
375
+ let result;
376
+ try {
377
+ if (!this._customFactory && !this._engineMod) {
378
+ // preload once for spotlight index->selector resolution
379
+ this._engineMod = await import(/* webpackMode: "eager" */ '../agent/index.js');
380
+ }
381
+ // Whole-site (H6): reviewed manifest -> engine siteManifest (null = single-page)
382
+ const siteManifest = await this._getSiteManifest();
383
+ this.engine = await this.engineFactory({ ...this._engineConfig(), siteManifest });
384
+ this._emit('agent:activity', startActivity);
385
+ // H9: pass an abort signal so Stop cancels the in-flight LLM call
386
+ // immediately instead of waiting for the next step boundary.
387
+ this._abort = new AbortController();
388
+ result = await this.engine.run(task, { ...engineOpts, signal: this._abort.signal });
389
+ } catch (e) {
390
+ result = { success: false, text: `Agent error: ${e.message}`, steps: 0 };
391
+ } finally {
392
+ this.running = false;
393
+ }
394
+ // The run finished IN THIS CONTEXT (done / error / SPA-continued): any
395
+ // checkpoint is now obsolete. A real full-page unload never reaches this
396
+ // line — the checkpoint survives for the boot-time resume (H7).
397
+ try {
398
+ if (this.sessionManager) this.sessionManager.clearAgentCheckpoint();
399
+ } catch (e) {
400
+ /* best-effort */
401
+ }
402
+ this._emit('agent:done', result);
403
+ return result;
404
+ }
405
+
406
+ /**
407
+ * Stop the current run. Aborts the in-flight LLM call (so it halts within
408
+ * the current step, not after it) AND sets the engine's cooperative stop
409
+ * flag. Emits immediate feedback so the UI acknowledges the click.
410
+ */
411
+ stop() {
412
+ if (!this.running) return;
413
+ this._emit('agent:activity', { type: 'info', message: 'Stopping…' });
414
+ try {
415
+ if (this._abort) this._abort.abort();
416
+ } catch (e) {
417
+ /* noop */
418
+ }
419
+ if (this.engine && typeof this.engine.stop === 'function') this.engine.stop();
420
+ }
421
+
422
+ getAuditLog() {
423
+ return this.engine && typeof this.engine.getAuditLog === 'function'
424
+ ? this.engine.getAuditLog()
425
+ : [];
426
+ }
427
+ }
428
+
429
+ export default AgentMode;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Hand Hold SDK - API Communication Layer
3
+ * Handles all HTTP communication with the Hand Hold backend
4
+ */
5
+
6
+ class APIClient {
7
+ constructor(config) {
8
+ this.config = config;
9
+ this.baseUrl = config.baseUrl;
10
+ this.apiKey = config.apiKey;
11
+ this.timeout = config.apiTimeout || 30000;
12
+ this.retryAttempts = config.retryAttempts || 3;
13
+ this.retryDelay = config.retryDelay || 1000;
14
+ }
15
+
16
+ /**
17
+ * Make HTTP request with retry logic
18
+ */
19
+ async _request(endpoint, options = {}) {
20
+ const url = `${this.baseUrl}${endpoint}`;
21
+
22
+ const defaultHeaders = {
23
+ 'Content-Type': 'application/json',
24
+ 'Authorization': `Bearer ${this.apiKey}`,
25
+ };
26
+
27
+ const config = {
28
+ ...options,
29
+ headers: {
30
+ ...defaultHeaders,
31
+ ...options.headers,
32
+ },
33
+ };
34
+
35
+ let lastError;
36
+
37
+ for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
38
+ try {
39
+ const controller = new AbortController();
40
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
41
+
42
+ const response = await fetch(url, {
43
+ ...config,
44
+ signal: controller.signal,
45
+ });
46
+
47
+ clearTimeout(timeoutId);
48
+
49
+ if (!response.ok) {
50
+ const errorData = await response.json().catch(() => ({}));
51
+ const error = new Error(errorData.detail || `HTTP ${response.status}`);
52
+ error.status = response.status;
53
+ error.data = errorData;
54
+
55
+ // Don't retry on client errors (4xx) except 429
56
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
57
+ throw error;
58
+ }
59
+
60
+ // Handle rate limiting
61
+ if (response.status === 429) {
62
+ const retryAfter = response.headers.get('Retry-After') || 5;
63
+ await this._delay(parseInt(retryAfter, 10) * 1000);
64
+ continue;
65
+ }
66
+
67
+ throw error;
68
+ }
69
+
70
+ return await response.json();
71
+ } catch (error) {
72
+ lastError = error;
73
+
74
+ if (error.name === 'AbortError') {
75
+ lastError = new Error('Request timeout');
76
+ }
77
+
78
+ // Don't retry on client errors
79
+ if (error.status >= 400 && error.status < 500) {
80
+ throw error;
81
+ }
82
+
83
+ if (attempt < this.retryAttempts) {
84
+ await this._delay(this.retryDelay * attempt);
85
+ }
86
+ }
87
+ }
88
+
89
+ throw lastError;
90
+ }
91
+
92
+ /**
93
+ * Delay utility
94
+ */
95
+ _delay(ms) {
96
+ return new Promise(resolve => setTimeout(resolve, ms));
97
+ }
98
+
99
+ /**
100
+ * POST request helper
101
+ */
102
+ async _post(endpoint, data) {
103
+ return this._request(endpoint, {
104
+ method: 'POST',
105
+ body: JSON.stringify(data),
106
+ });
107
+ }
108
+
109
+ /**
110
+ * GET request helper
111
+ */
112
+ async _get(endpoint, params = {}) {
113
+ const queryString = new URLSearchParams(params).toString();
114
+ const url = queryString ? `${endpoint}?${queryString}` : endpoint;
115
+ return this._request(url, { method: 'GET' });
116
+ }
117
+
118
+ // ============================================
119
+ // Intent APIs
120
+ // ============================================
121
+
122
+ /**
123
+ * Infer intents from UI elements (server validation/caching)
124
+ * @param {Object} data - { route, page_title, ui_elements }
125
+ * @returns {Promise<Object>} - { intents, cached, cache_key }
126
+ */
127
+ async inferIntents(data) {
128
+ return this._post('/api/v1/intents/infer', data);
129
+ }
130
+
131
+ /**
132
+ * Extract intent from free-text query (LLM-based)
133
+ * @param {Object} data - { user_query, route, available_intents }
134
+ * @returns {Promise<Object>} - { intent, confidence, clarifying_question }
135
+ */
136
+ async extractIntent(data) {
137
+ return this._post('/api/v1/intents/extract', data);
138
+ }
139
+
140
+ // ============================================
141
+ // Guidance APIs
142
+ // ============================================
143
+
144
+ /**
145
+ * Get step-by-step guidance for an intent
146
+ * @param {Object} data - { intent, route, page_context, ui_elements, session_id }
147
+ * @returns {Promise<Object>} - Full guidance response with steps
148
+ */
149
+ async getGuidance(data) {
150
+ return this._post('/api/v1/guidance', data);
151
+ }
152
+
153
+ /**
154
+ * Re-match element on a new page (multi-page flow tracking)
155
+ * @param {Object} data - { step_description, original_selector, route, ui_elements, session_id }
156
+ * @returns {Promise<Object>} - { selector, confidence, match_type, found, fallback_message }
157
+ */
158
+ async matchElement(data) {
159
+ return this._post('/api/v1/guidance/match-element', data);
160
+ }
161
+
162
+ /**
163
+ * Check page status for active session
164
+ * @param {Object} data - { session_id, route, ui_elements }
165
+ * @returns {Promise<Object>} - { status, action, step_info }
166
+ */
167
+ async checkPageStatus(data) {
168
+ return this._post('/api/v1/guidance/check-page', data);
169
+ }
170
+
171
+ /**
172
+ * Update guidance session status
173
+ * @param {Object} data - { session_id, status, current_step, total_steps }
174
+ * @returns {Promise<Object>} - { session_id, status, updated_at }
175
+ */
176
+ async updateSession(data) {
177
+ try {
178
+ return await this._post('/api/v1/guidance/sessions/update', data);
179
+ } catch (error) {
180
+ if (error.status === 404) {
181
+ // Session already gone on backend, treat as success/cleanup
182
+ return { status: 'session_not_found', session_id: data.session_id };
183
+ }
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get session details
190
+ * @param {string} sessionId - Session ID
191
+ * @returns {Promise<Object>} - Session details
192
+ */
193
+ async getSession(sessionId) {
194
+ return this._get(`/api/v1/guidance/sessions/${sessionId}`);
195
+ }
196
+
197
+ /**
198
+ * Explain a selected term
199
+ * @param {Object} data - { text, route, page_title, context }
200
+ * @returns {Promise<Object>} - { term, explanation, related_articles, confidence }
201
+ */
202
+ async explainTerm(data) {
203
+ return this._post('/api/v1/explain/term', data);
204
+ }
205
+
206
+ // ============================================
207
+ // Knowledge Base APIs
208
+ // ============================================
209
+
210
+ /**
211
+ * Search knowledge base
212
+ * @param {string} query - Search query
213
+ * @param {Object} options - { limit, intent, route }
214
+ * @returns {Promise<Object>} - { query, results, total }
215
+ */
216
+ async searchKB(query, options = {}) {
217
+ return this._get('/api/v1/kb/search', {
218
+ q: query,
219
+ ...options,
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Get KB article by ID
225
+ * @param {string} articleId - Article ID
226
+ * @returns {Promise<Object>} - Article details
227
+ */
228
+ async getArticle(articleId) {
229
+ return this._get(`/api/v1/kb/${articleId}`);
230
+ }
231
+
232
+ // ============================================
233
+ // Health Check
234
+ // ============================================
235
+
236
+ /**
237
+ * Check backend health
238
+ * @returns {Promise<Object>} - Health status
239
+ */
240
+ async healthCheck() {
241
+ return this._get('/health');
242
+ }
243
+
244
+ /**
245
+ * Check if backend is available
246
+ * @returns {Promise<boolean>}
247
+ */
248
+ async isAvailable() {
249
+ try {
250
+ await this.healthCheck();
251
+ return true;
252
+ } catch (error) {
253
+ return false;
254
+ }
255
+ }
256
+ }
257
+
258
+ export default APIClient;