@oxvo/ai-live-assist 7.3.4 → 7.4.23

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 (67) hide show
  1. package/cjs/AiLiveAssist.d.ts +588 -6
  2. package/cjs/AiLiveAssist.js +14892 -1240
  3. package/cjs/client.d.ts +71 -10
  4. package/cjs/client.js +316 -58
  5. package/cjs/context.d.ts +15 -5
  6. package/cjs/context.js +568 -73
  7. package/cjs/control.d.ts +72 -7
  8. package/cjs/control.js +594 -71
  9. package/cjs/dialogFocusScope.d.ts +37 -0
  10. package/cjs/dialogFocusScope.js +227 -0
  11. package/cjs/experienceState.js +1 -1
  12. package/cjs/index.d.ts +1 -1
  13. package/cjs/index.js +2 -2
  14. package/cjs/media.d.ts +14 -3
  15. package/cjs/media.js +128 -19
  16. package/cjs/messages.js +26 -3
  17. package/cjs/messengerLoader.d.ts +3 -0
  18. package/cjs/messengerLoader.js +156 -0
  19. package/cjs/pageAssistancePreference.d.ts +78 -0
  20. package/cjs/pageAssistancePreference.js +488 -0
  21. package/cjs/preview.d.ts +33 -0
  22. package/cjs/preview.js +567 -0
  23. package/cjs/tabLock.d.ts +54 -3
  24. package/cjs/tabLock.js +752 -72
  25. package/cjs/types.d.ts +152 -4
  26. package/cjs/ui.d.ts +200 -10
  27. package/cjs/ui.js +6946 -523
  28. package/cjs/version.d.ts +1 -1
  29. package/cjs/version.js +1 -1
  30. package/cjs/voicePresenceUi.d.ts +48 -9
  31. package/cjs/voicePresenceUi.js +863 -125
  32. package/lib/AiLiveAssist.d.ts +588 -6
  33. package/lib/AiLiveAssist.js +14897 -1248
  34. package/lib/client.d.ts +71 -10
  35. package/lib/client.js +313 -57
  36. package/lib/context.d.ts +15 -5
  37. package/lib/context.js +566 -72
  38. package/lib/control.d.ts +72 -7
  39. package/lib/control.js +594 -71
  40. package/lib/dialogFocusScope.d.ts +37 -0
  41. package/lib/dialogFocusScope.js +223 -0
  42. package/lib/experienceState.js +1 -1
  43. package/lib/index.d.ts +1 -1
  44. package/lib/index.js +2 -2
  45. package/lib/media.d.ts +14 -3
  46. package/lib/media.js +128 -19
  47. package/lib/messages.js +26 -3
  48. package/lib/messengerLoader.d.ts +3 -0
  49. package/lib/messengerLoader.js +151 -0
  50. package/lib/pageAssistancePreference.d.ts +78 -0
  51. package/lib/pageAssistancePreference.js +482 -0
  52. package/lib/preview.d.ts +33 -0
  53. package/lib/preview.js +561 -0
  54. package/lib/tabLock.d.ts +54 -3
  55. package/lib/tabLock.js +752 -72
  56. package/lib/types.d.ts +152 -4
  57. package/lib/ui.d.ts +200 -10
  58. package/lib/ui.js +6947 -524
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/lib/voicePresenceUi.d.ts +48 -9
  62. package/lib/voicePresenceUi.js +863 -125
  63. package/package.json +8 -3
  64. package/cjs/recordingPolicy.d.ts +0 -2
  65. package/cjs/recordingPolicy.js +0 -12
  66. package/lib/recordingPolicy.d.ts +0 -2
  67. package/lib/recordingPolicy.js +0 -8
package/lib/context.js CHANGED
@@ -26,12 +26,63 @@ const SENSITIVE_PATTERNS = [
26
26
  ['authentication_secret', /auth.*secret|private.?key|access.?token/i],
27
27
  ['api_secret', /api.?key|client.?secret|secret.?key/i],
28
28
  ];
29
+ const EXPLICIT_SENSITIVITIES = new Set([
30
+ 'private',
31
+ 'password',
32
+ 'otp',
33
+ 'payment',
34
+ 'banking',
35
+ 'recovery',
36
+ 'authentication_secret',
37
+ 'api_secret',
38
+ ]);
29
39
  const id = (prefix) => `${prefix}_${crypto.randomUUID()}`;
30
40
  const normalize = (value, maximum) => value
31
41
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
32
42
  .replace(/\s+/g, ' ')
33
43
  .trim()
34
44
  .slice(0, maximum);
45
+ const canonicalJson = (value) => {
46
+ const sort = (item) => {
47
+ if (Array.isArray(item))
48
+ return item.map(sort);
49
+ if (item !== null && typeof item === 'object') {
50
+ return Object.fromEntries(Object.entries(item)
51
+ .sort(([left], [right]) => left.localeCompare(right))
52
+ .map(([key, nested]) => [key, sort(nested)]));
53
+ }
54
+ return item;
55
+ };
56
+ return JSON.stringify(sort(value));
57
+ };
58
+ export const actionTargetFingerprint = async (target, subtle = globalThis.crypto?.subtle) => {
59
+ if (!subtle)
60
+ return null;
61
+ try {
62
+ const canonical = canonicalJson({
63
+ targetId: target.targetId,
64
+ role: target.role,
65
+ name: target.name,
66
+ inputType: target.inputType ?? null,
67
+ autocomplete: target.autocomplete ?? null,
68
+ semanticRegion: target.semanticRegion ?? null,
69
+ destinationPath: target.destinationPath ?? null,
70
+ sensitivity: target.sensitivity,
71
+ protectedRegion: target.protectedRegion ?? null,
72
+ frameOrigin: target.frameOrigin ?? null,
73
+ consequence: target.consequence ?? null,
74
+ regionAssessment: target.regionAssessment ?? null,
75
+ options: target.options ?? null,
76
+ });
77
+ const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(canonical)));
78
+ if (digest.byteLength !== 32)
79
+ return null;
80
+ return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ };
35
86
  const isHTMLElement = (value) => value instanceof HTMLElement;
36
87
  const isRendered = (element) => {
37
88
  if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR)) {
@@ -57,11 +108,22 @@ const isVisible = (element) => {
57
108
  rect.top <= window.innerHeight &&
58
109
  rect.left <= window.innerWidth);
59
110
  };
60
- const isCovered = (element) => {
111
+ const isCovered = (element, trustedGuideRoot = null) => {
61
112
  const rect = element.getBoundingClientRect();
62
113
  const x = Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2));
63
114
  const y = Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2));
64
- const top = document.elementFromPoint(x, y);
115
+ let top;
116
+ if (trustedGuideRoot?.isConnected &&
117
+ typeof document.elementsFromPoint === 'function') {
118
+ top =
119
+ document
120
+ .elementsFromPoint(x, y)
121
+ .find((candidate) => candidate !== trustedGuideRoot) ??
122
+ document.elementFromPoint(x, y);
123
+ }
124
+ else {
125
+ top = document.elementFromPoint(x, y);
126
+ }
65
127
  return Boolean(top && top !== element && !element.contains(top) && !top.contains(element));
66
128
  };
67
129
  const safePageUrl = () => `${window.location.origin}${window.location.pathname}`;
@@ -94,17 +156,22 @@ const sensitivityFor = (element, name) => {
94
156
  return 'otp';
95
157
  if (/^cc-/.test(autocomplete))
96
158
  return 'payment';
159
+ const explicitSensitivity = normalize(element.closest('[data-oxvo-sensitivity]')?.getAttribute('data-oxvo-sensitivity') ?? '', 64);
160
+ if (EXPLICIT_SENSITIVITIES.has(explicitSensitivity))
161
+ return explicitSensitivity;
97
162
  const descriptors = [
98
163
  name,
99
164
  element.getAttribute('name') ?? '',
100
165
  element.id,
101
166
  element.getAttribute('autocomplete') ?? '',
102
- element.getAttribute('data-oxvo-sensitivity') ?? '',
167
+ element.closest('[data-oxvo-sensitivity]')?.getAttribute('data-oxvo-sensitivity') ?? '',
103
168
  ].join(' ');
104
169
  for (const [sensitivity, pattern] of SENSITIVE_PATTERNS) {
105
170
  if (pattern.test(descriptors))
106
171
  return sensitivity;
107
172
  }
173
+ if (element.closest('[data-oxvo-sensitivity]'))
174
+ return 'private';
108
175
  if (element.closest('[data-oxvo-private]'))
109
176
  return 'private';
110
177
  return 'none';
@@ -138,11 +205,6 @@ const roleFor = (element) => {
138
205
  return tag.slice(0, 64);
139
206
  };
140
207
  const nameFor = (element) => {
141
- const labelledBy = element.getAttribute('aria-labelledby');
142
- const labelledText = labelledBy
143
- ?.split(/\s+/)
144
- .map((value) => document.getElementById(value)?.textContent ?? '')
145
- .join(' ');
146
208
  const input = element instanceof HTMLInputElement ? element : null;
147
209
  const formControl = element instanceof HTMLInputElement ||
148
210
  element instanceof HTMLSelectElement ||
@@ -151,49 +213,208 @@ const nameFor = (element) => {
151
213
  : null;
152
214
  const associatedLabel = formControl
153
215
  ? Array.from(formControl.labels ?? [])
154
- .flatMap((label) => {
155
- const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
156
- const text = [];
157
- let node = walker.nextNode();
158
- while (node) {
159
- if (!node.parentElement?.closest('input,select,textarea,option,button')) {
160
- text.push(node.textContent ?? '');
161
- }
162
- node = walker.nextNode();
163
- }
164
- return text;
165
- })
216
+ .map((label) => safeAccessibleNameText(label, 300))
217
+ .filter(Boolean)
166
218
  .join(' ')
167
219
  : '';
168
- const explicitName = element.getAttribute('aria-label') ??
169
- labelledText ??
170
- element.getAttribute('alt') ??
171
- element.getAttribute('title') ??
172
- element.getAttribute('placeholder');
220
+ const safeAttribute = (attribute, maximum) => {
221
+ const value = normalize(element.getAttribute(attribute) ?? '', maximum);
222
+ return value && sensitivityFor(element, value) === 'none' ? value : '';
223
+ };
224
+ const semanticName = safeAccessibleNameText(element, 300);
225
+ const attributeName = safeAttribute('alt', 300) ||
226
+ safeAttribute('title', 300) ||
227
+ safeAttribute('placeholder', 300);
173
228
  if (element instanceof HTMLFormElement) {
174
- return normalize(explicitName ??
175
- element.getAttribute('data-oxvo-ai-region') ??
176
- element.getAttribute('name') ??
229
+ const explicitSemanticName = element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')
230
+ ? semanticName
231
+ : '';
232
+ return normalize(explicitSemanticName ||
233
+ attributeName ||
234
+ safeAttribute('data-oxvo-ai-region', 120) ||
235
+ safeAttribute('name', 120) ||
177
236
  'Form', 120);
178
237
  }
179
- return normalize(explicitName ??
180
- (associatedLabel || null) ??
181
- (input && ['button', 'submit', 'reset'].includes(input.type) ? input.value : null) ??
182
- element.textContent ??
183
- '', 300);
238
+ const buttonValue = input && ['button', 'submit', 'reset'].includes(input.type)
239
+ ? normalize(input.value, 300)
240
+ : '';
241
+ return normalize(semanticName ||
242
+ associatedLabel ||
243
+ attributeName ||
244
+ (buttonValue && sensitivityFor(element, buttonValue) === 'none' ? buttonValue : ''), 300);
245
+ };
246
+ const safeDescendantText = (element, maximum, renderedOnly = false) => {
247
+ const parts = [];
248
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
249
+ let node = walker.nextNode();
250
+ while (node) {
251
+ const parent = node.parentElement;
252
+ const text = normalize(node.textContent ?? '', maximum);
253
+ if (parent &&
254
+ text &&
255
+ !parent.closest(PRIVATE_SELECTOR) &&
256
+ !parent.closest('script,style,noscript,template,svg,input,select,textarea,option') &&
257
+ (renderedOnly ? isRendered(parent) : isVisible(parent)) &&
258
+ sensitivityFor(parent, text) === 'none') {
259
+ parts.push(text);
260
+ }
261
+ node = walker.nextNode();
262
+ }
263
+ return normalize(parts.join(' '), maximum);
264
+ };
265
+ const safeSemanticText = (element, maximum) => {
266
+ if (!isVisible(element) || element.closest(PRIVATE_SELECTOR))
267
+ return '';
268
+ const explicit = normalize(element.getAttribute('aria-label') ?? '', maximum);
269
+ if (explicit) {
270
+ return sensitivityFor(element, explicit) === 'none' ? explicit : '';
271
+ }
272
+ const labelledBy = element.getAttribute('aria-labelledby');
273
+ const labelledParts = labelledBy
274
+ ? labelledBy
275
+ .split(/\s+/)
276
+ .map((idValue) => document.getElementById(idValue))
277
+ .filter((label) => Boolean(label))
278
+ .flatMap((label) => {
279
+ if (!isVisible(label) || label.closest(PRIVATE_SELECTOR))
280
+ return [];
281
+ const text = safeDescendantText(label, maximum);
282
+ return text && sensitivityFor(label, text) === 'none' ? [text] : [];
283
+ })
284
+ : [];
285
+ if (labelledParts.length > 0) {
286
+ const text = normalize(labelledParts.join(' '), maximum);
287
+ return sensitivityFor(element, text) === 'none' ? text : '';
288
+ }
289
+ const text = safeDescendantText(element, maximum);
290
+ return text && sensitivityFor(element, text) === 'none' ? text : '';
291
+ };
292
+ const safeAccessibleNameText = (element, maximum) => {
293
+ if (!isRendered(element) || element.closest(PRIVATE_SELECTOR))
294
+ return '';
295
+ const explicit = normalize(element.getAttribute('aria-label') ?? '', maximum);
296
+ if (explicit) {
297
+ return sensitivityFor(element, explicit) === 'none' ? explicit : '';
298
+ }
299
+ const labelledBy = element.getAttribute('aria-labelledby');
300
+ const labelledParts = labelledBy
301
+ ? labelledBy
302
+ .split(/\s+/)
303
+ .map((idValue) => document.getElementById(idValue))
304
+ .filter((label) => Boolean(label))
305
+ .flatMap((label) => {
306
+ if (!isRendered(label) || label.closest(PRIVATE_SELECTOR))
307
+ return [];
308
+ const text = safeDescendantText(label, maximum, true);
309
+ return text && sensitivityFor(label, text) === 'none' ? [text] : [];
310
+ })
311
+ : [];
312
+ if (labelledParts.length > 0) {
313
+ const text = normalize(labelledParts.join(' '), maximum);
314
+ return sensitivityFor(element, text) === 'none' ? text : '';
315
+ }
316
+ const text = safeDescendantText(element, maximum, true);
317
+ return text && sensitivityFor(element, text) === 'none' ? text : '';
318
+ };
319
+ const semanticSourceSensitivity = (source) => {
320
+ if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
321
+ return 'private';
322
+ }
323
+ const markedSources = [
324
+ ...(source.matches('[data-oxvo-sensitivity]') ? [source] : []),
325
+ ...Array.from(source.querySelectorAll('[data-oxvo-sensitivity]')),
326
+ ];
327
+ for (const marked of markedSources) {
328
+ const marker = normalize(marked.getAttribute('data-oxvo-sensitivity') ?? '', 64);
329
+ if (EXPLICIT_SENSITIVITIES.has(marker))
330
+ return marker;
331
+ const descriptor = normalize(`${marker} ${marked.textContent ?? ''}`, 500);
332
+ for (const [sensitivity, pattern] of SENSITIVE_PATTERNS) {
333
+ if (pattern.test(descriptor))
334
+ return sensitivity;
335
+ }
336
+ return 'private';
337
+ }
338
+ const text = normalize(`${source.getAttribute('label') ?? ''} ${source.textContent ?? ''}`, 500);
339
+ return sensitivityFor(source, text);
340
+ };
341
+ const accessibleNameSourceSensitivity = (element) => {
342
+ const labelledBy = element.getAttribute('aria-labelledby');
343
+ const referencedLabels = labelledBy
344
+ ? labelledBy
345
+ .split(/\s+/)
346
+ .map((idValue) => document.getElementById(idValue))
347
+ .filter((label) => Boolean(label))
348
+ : [];
349
+ const formControl = element instanceof HTMLInputElement ||
350
+ element instanceof HTMLSelectElement ||
351
+ element instanceof HTMLTextAreaElement
352
+ ? element
353
+ : null;
354
+ const sources = [
355
+ ...referencedLabels,
356
+ ...Array.from(formControl?.labels ?? []),
357
+ ];
358
+ for (const source of sources) {
359
+ const sensitivity = semanticSourceSensitivity(source);
360
+ if (sensitivity !== 'none')
361
+ return sensitivity;
362
+ }
363
+ return 'none';
364
+ };
365
+ const safeOptionLabel = (option) => {
366
+ const label = normalize(option.label || option.textContent || '', 300);
367
+ if (!label || semanticSourceSensitivity(option) !== 'none')
368
+ return '';
369
+ const group = option.closest('optgroup');
370
+ if (group && semanticSourceSensitivity(group) !== 'none')
371
+ return '';
372
+ return sensitivityFor(option, label) === 'none' ? label : '';
184
373
  };
185
374
  const isActionTarget = (element) => element.matches('a[href],button,input,select,textarea,summary,form,[contenteditable="true"],[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="combobox"],[role="textbox"],[tabindex]');
375
+ const READONLY_INPUT_TYPES = new Set([
376
+ 'date',
377
+ 'datetime-local',
378
+ 'email',
379
+ 'month',
380
+ 'number',
381
+ 'password',
382
+ 'search',
383
+ 'tel',
384
+ 'text',
385
+ 'time',
386
+ 'url',
387
+ 'week',
388
+ ]);
389
+ const isReadOnlyTextControl = (element) => (element instanceof HTMLInputElement &&
390
+ READONLY_INPUT_TYPES.has(element.type) &&
391
+ element.readOnly) ||
392
+ (element instanceof HTMLTextAreaElement && element.readOnly);
186
393
  const enabled = (element) => {
187
- if (element.getAttribute('aria-disabled') === 'true')
394
+ if (element.getAttribute('aria-disabled') === 'true' ||
395
+ element.getAttribute('aria-readonly') === 'true' ||
396
+ isReadOnlyTextControl(element)) {
188
397
  return false;
398
+ }
189
399
  if (element instanceof HTMLButtonElement ||
190
400
  element instanceof HTMLInputElement ||
191
401
  element instanceof HTMLSelectElement ||
192
402
  element instanceof HTMLTextAreaElement) {
193
- return !element.disabled;
403
+ return !element.matches(':disabled');
194
404
  }
195
405
  return true;
196
406
  };
407
+ const optionDisabled = (option) => option.disabled ||
408
+ option.closest('optgroup')?.disabled === true ||
409
+ option.matches(':disabled');
410
+ const literalBooleanAttribute = (element, attribute) => {
411
+ const value = element.getAttribute(attribute);
412
+ if (value === 'true')
413
+ return true;
414
+ if (value === 'false')
415
+ return false;
416
+ return undefined;
417
+ };
197
418
  const consequenceFor = (element, role, name) => {
198
419
  const form = element instanceof HTMLFormElement ? element : element.closest('form');
199
420
  const explicit = element
@@ -244,10 +465,12 @@ const consequenceFor = (element, role, name) => {
244
465
  return role === 'button' || role === 'checkbox' || role === 'radio' ? 'state_change' : 'none';
245
466
  };
246
467
  export class PageContextCollector {
247
- constructor(callbacks, selectorRegionRules = [], policyVersion = 1) {
468
+ constructor(callbacks, selectorRegionRules = [], policyVersion = 1, trustedGuideRoot = null, statefulButtonActionProof = false) {
248
469
  this.callbacks = callbacks;
249
470
  this.selectorRegionRules = selectorRegionRules;
250
471
  this.policyVersion = policyVersion;
472
+ this.trustedGuideRoot = trustedGuideRoot;
473
+ this.statefulButtonActionProof = statefulButtonActionProof;
251
474
  this.pageId = id('page');
252
475
  this.revision = 0;
253
476
  this.running = false;
@@ -264,10 +487,12 @@ export class PageContextCollector {
264
487
  this.pendingNavigation = null;
265
488
  this.pendingCapture = false;
266
489
  this.deferredNavigation = null;
490
+ this.navigationSettlementGeneration = 0;
491
+ this.navigationSettlement = null;
267
492
  this.recentSelection = null;
268
493
  this.latestSemanticSignature = null;
269
494
  this.scheduleCapture = () => {
270
- if (!this.running)
495
+ if (!this.running || this.navigationSettlement)
271
496
  return;
272
497
  if (this.executingActionId) {
273
498
  this.pendingCapture = true;
@@ -284,7 +509,7 @@ export class PageContextCollector {
284
509
  }, CAPTURE_SETTLE_MS);
285
510
  };
286
511
  this.scheduleMutationCapture = () => {
287
- if (!this.running)
512
+ if (!this.running || this.navigationSettlement)
288
513
  return;
289
514
  if (this.executingActionId) {
290
515
  this.pendingCapture = true;
@@ -325,6 +550,14 @@ export class PageContextCollector {
325
550
  this.handleNavigation = () => {
326
551
  if (!this.running)
327
552
  return;
553
+ const currentUrl = safePageUrl();
554
+ if (this.executingActionId &&
555
+ this.pendingNavigation?.causalActionId === this.executingActionId &&
556
+ this.pendingNavigation.url === currentUrl) {
557
+ this.pendingCapture = true;
558
+ this.reportCausalNavigationObservation();
559
+ return;
560
+ }
328
561
  this.pageId = id('page');
329
562
  this.revision += 1;
330
563
  this.targets.clear();
@@ -333,12 +566,27 @@ export class PageContextCollector {
333
566
  this.pendingNavigation = {
334
567
  pageId: this.pageId,
335
568
  revision: this.revision,
336
- url: safePageUrl(),
569
+ url: currentUrl,
570
+ causalActionId: this.executingActionId,
571
+ causalObservationReported: false,
337
572
  };
338
573
  this.pendingCapture = true;
574
+ this.reportCausalNavigationObservation();
339
575
  return;
340
576
  }
341
- void Promise.resolve(this.callbacks.onNavigation(this.pageId, this.revision, safePageUrl())).then(() => this.captureAndPublish());
577
+ const settlement = this.beginNavigationSettlement(this.pageId, this.revision, currentUrl);
578
+ void Promise.resolve()
579
+ .then(() => this.callbacks.onNavigation(settlement.pageId, settlement.revision, settlement.url))
580
+ .then((navigationAcknowledged) => {
581
+ if (navigationAcknowledged === false ||
582
+ !this.running ||
583
+ this.navigationSettlement !== settlement) {
584
+ return undefined;
585
+ }
586
+ this.navigationSettlement = null;
587
+ return this.captureAndPublish();
588
+ })
589
+ .catch(() => undefined);
342
590
  };
343
591
  }
344
592
  get currentRevision() {
@@ -385,6 +633,7 @@ export class PageContextCollector {
385
633
  'aria-label',
386
634
  'aria-disabled',
387
635
  'aria-expanded',
636
+ 'aria-pressed',
388
637
  'aria-live',
389
638
  'class',
390
639
  'disabled',
@@ -439,14 +688,19 @@ export class PageContextCollector {
439
688
  this.pendingNavigation = null;
440
689
  this.pendingCapture = false;
441
690
  this.deferredNavigation = null;
691
+ this.navigationSettlementGeneration += 1;
692
+ this.navigationSettlement = null;
442
693
  this.recentSelection = null;
443
694
  this.latestSemanticSignature = null;
444
695
  this.callbacks.onHighlight(null, 'focus');
445
696
  }
446
697
  async captureAndPublish() {
698
+ if (this.navigationSettlement)
699
+ return null;
447
700
  const snapshot = this.capture();
448
- if (this.running)
701
+ if (this.running) {
449
702
  await this.callbacks.onSnapshot(snapshot);
703
+ }
450
704
  return snapshot;
451
705
  }
452
706
  capture() {
@@ -465,8 +719,17 @@ export class PageContextCollector {
465
719
  }
466
720
  if (!isActionTarget(element) || !isRendered(element))
467
721
  continue;
722
+ const role = roleFor(element);
723
+ const hasStatefulButtonAttribute = role === 'button' &&
724
+ (element.hasAttribute('aria-expanded') || element.hasAttribute('aria-pressed'));
725
+ const hasUnsupportedMixedPressedState = role === 'button' &&
726
+ element.getAttribute('aria-pressed')?.trim().toLowerCase() === 'mixed';
727
+ if (hasUnsupportedMixedPressedState ||
728
+ (!this.statefulButtonActionProof && hasStatefulButtonAttribute)) {
729
+ continue;
730
+ }
468
731
  const name = nameFor(element);
469
- if (!name && !['form', 'textbox'].includes(roleFor(element)))
732
+ if (!name && !['form', 'textbox'].includes(role))
470
733
  continue;
471
734
  const targetId = this.targetIds.get(element) ?? id('target');
472
735
  this.targetIds.set(element, targetId);
@@ -475,15 +738,18 @@ export class PageContextCollector {
475
738
  if (element instanceof HTMLSelectElement) {
476
739
  options = Array.from(element.options)
477
740
  .slice(0, MAX_OPTIONS)
478
- .map((option) => {
741
+ .flatMap((option) => {
742
+ const label = safeOptionLabel(option);
743
+ if (!label)
744
+ return [];
479
745
  const optionId = this.optionIds.get(option) ?? id('option');
480
746
  this.optionIds.set(option, optionId);
481
747
  optionMap.set(optionId, option);
482
- return {
483
- id: optionId,
484
- label: normalize(option.label || option.textContent || '', 300),
485
- disabled: option.disabled,
486
- };
748
+ return [{
749
+ id: optionId,
750
+ label,
751
+ disabled: optionDisabled(option),
752
+ }];
487
753
  });
488
754
  }
489
755
  const semanticRegion = normalize(element.closest('[data-oxvo-ai-region]')?.getAttribute('data-oxvo-ai-region') ?? '', 128);
@@ -503,10 +769,14 @@ export class PageContextCollector {
503
769
  // Invalid tenant selectors remain unevaluated so server policy fails closed.
504
770
  }
505
771
  }
506
- const role = roleFor(element);
507
- const sensitivity = sensitivityFor(element, name);
772
+ const directSensitivity = sensitivityFor(element, name);
773
+ const sensitivity = directSensitivity !== 'none'
774
+ ? directSensitivity
775
+ : accessibleNameSourceSensitivity(element);
508
776
  const visible = isVisible(element);
509
777
  const destinationPath = safeDestinationPath(element);
778
+ const expanded = literalBooleanAttribute(element, 'aria-expanded');
779
+ const pressed = literalBooleanAttribute(element, 'aria-pressed');
510
780
  const target = {
511
781
  targetId,
512
782
  revision,
@@ -532,10 +802,12 @@ export class PageContextCollector {
532
802
  state: {
533
803
  visible,
534
804
  enabled: enabled(element),
535
- covered: visible ? isCovered(element) : false,
805
+ covered: visible ? isCovered(element, this.trustedGuideRoot) : false,
536
806
  ...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
537
807
  ? { checked: element.checked }
538
808
  : {}),
809
+ ...(this.statefulButtonActionProof && expanded !== undefined ? { expanded } : {}),
810
+ ...(this.statefulButtonActionProof && pressed !== undefined ? { pressed } : {}),
539
811
  },
540
812
  ...(options ? { options } : {}),
541
813
  };
@@ -568,11 +840,12 @@ export class PageContextCollector {
568
840
  const landmarks = Array.from(document.querySelectorAll('main,nav,header,footer,aside,[role="main"],[role="navigation"]'))
569
841
  .filter(isVisible)
570
842
  .slice(0, 32)
571
- .map((element) => ({ role: roleFor(element), label: nameFor(element) }));
843
+ .map((element) => ({ role: roleFor(element), label: safeSemanticText(element, 300) }))
844
+ .filter(({ label }) => Boolean(label));
572
845
  const alerts = Array.from(document.querySelectorAll('[role="alert"],[aria-live]'))
573
846
  .filter(isVisible)
574
847
  .slice(0, 16)
575
- .map((element) => normalize(element.textContent ?? '', 500))
848
+ .map((element) => safeSemanticText(element, 500))
576
849
  .filter(Boolean);
577
850
  const activeSelection = this.readSafeSelection();
578
851
  if (activeSelection)
@@ -609,6 +882,8 @@ export class PageContextCollector {
609
882
  return snapshot;
610
883
  }
611
884
  captureVisualContext() {
885
+ if (this.navigationSettlement)
886
+ return null;
612
887
  if (this.revision < 1 || this.targets.size === 0)
613
888
  return null;
614
889
  const viewportWidth = Math.max(1, Math.round(window.innerWidth));
@@ -647,14 +922,81 @@ export class PageContextCollector {
647
922
  regions,
648
923
  });
649
924
  }
650
- async execute(grant) {
651
- const handle = this.targets.get(grant.targetId);
925
+ async execute(grant, authorityIsCurrent = () => true) {
652
926
  const base = {
653
927
  grant: grant.grant,
654
928
  actionId: grant.actionId,
655
929
  };
656
- if (Date.parse(grant.expiresAt) <= Date.now() ||
657
- grant.domRevision !== this.revision ||
930
+ const authorityStale = () => ({
931
+ ...base,
932
+ status: 'stale',
933
+ observed: {
934
+ url: safePageUrl(),
935
+ revision: this.revision,
936
+ targetState: 'unchanged',
937
+ safeSummary: 'Page assistance permission changed before execution.',
938
+ },
939
+ });
940
+ const expiresAt = Date.parse(grant.expiresAt);
941
+ const approvalExpired = () => ({
942
+ ...base,
943
+ status: 'stale',
944
+ observed: {
945
+ url: safePageUrl(),
946
+ revision: this.revision,
947
+ targetState: 'unchanged',
948
+ safeSummary: 'The approved page action expired before execution.',
949
+ },
950
+ });
951
+ const executionStale = () => {
952
+ if (!authorityIsCurrent())
953
+ return authorityStale();
954
+ return expiresAt <= Date.now() ? approvalExpired() : null;
955
+ };
956
+ const initialStale = executionStale();
957
+ if (initialStale)
958
+ return initialStale;
959
+ this.captureSnapshot(false);
960
+ let handle = this.targets.get(grant.targetId);
961
+ const revisionMatchesOrStableNavigation = grant.domRevision === this.revision ||
962
+ (grant.domRevision < this.revision &&
963
+ grant.action === 'click' &&
964
+ handle?.element instanceof HTMLAnchorElement &&
965
+ handle.target.consequence === 'navigation' &&
966
+ typeof handle.target.destinationPath === 'string');
967
+ if (expiresAt <= Date.now() ||
968
+ grant.pageId !== this.pageId ||
969
+ grant.pageUrl !== safePageUrl() ||
970
+ grant.policyVersion !== this.policyVersion ||
971
+ !revisionMatchesOrStableNavigation ||
972
+ !handle ||
973
+ handle.revision !== this.revision ||
974
+ !handle.element.isConnected) {
975
+ return {
976
+ ...base,
977
+ status: 'stale',
978
+ observed: {
979
+ url: safePageUrl(),
980
+ revision: this.revision,
981
+ targetState: 'detached',
982
+ safeSummary: 'The requested target changed before execution.',
983
+ },
984
+ };
985
+ }
986
+ const fingerprintedHandle = handle;
987
+ const fingerprint = await actionTargetFingerprint(handle.target);
988
+ const postFingerprintStale = executionStale();
989
+ if (postFingerprintStale)
990
+ return postFingerprintStale;
991
+ this.captureSnapshot(false);
992
+ handle = this.targets.get(grant.targetId);
993
+ if (!fingerprint ||
994
+ fingerprint !== grant.targetFingerprint ||
995
+ grant.pageId !== this.pageId ||
996
+ grant.pageUrl !== safePageUrl() ||
997
+ grant.policyVersion !== this.policyVersion ||
998
+ !revisionMatchesOrStableNavigation ||
999
+ handle !== fingerprintedHandle ||
658
1000
  !handle ||
659
1001
  handle.revision !== this.revision ||
660
1002
  !handle.element.isConnected) {
@@ -680,7 +1022,9 @@ export class PageContextCollector {
680
1022
  !isRendered(element) ||
681
1023
  (grant.action !== 'scroll' && !isVisible(element) && !canRevealDesiredStateTarget) ||
682
1024
  !enabled(element) ||
683
- (grant.action !== 'scroll' && isVisible(element) && isCovered(element))) {
1025
+ (grant.action !== 'scroll' &&
1026
+ isVisible(element) &&
1027
+ isCovered(element, this.trustedGuideRoot))) {
684
1028
  return {
685
1029
  ...base,
686
1030
  status: 'blocked',
@@ -693,9 +1037,17 @@ export class PageContextCollector {
693
1037
  };
694
1038
  }
695
1039
  if (!isVisible(element) && canRevealDesiredStateTarget) {
1040
+ const preRevealStale = executionStale();
1041
+ if (preRevealStale)
1042
+ return preRevealStale;
696
1043
  element.scrollIntoView({ behavior: 'auto', block: 'center' });
697
1044
  await new Promise((resolve) => setTimeout(resolve, 80));
698
- if (!isRendered(element) || !isVisible(element) || isCovered(element)) {
1045
+ const postRevealStale = executionStale();
1046
+ if (postRevealStale)
1047
+ return postRevealStale;
1048
+ if (!isRendered(element) ||
1049
+ !isVisible(element) ||
1050
+ isCovered(element, this.trustedGuideRoot)) {
699
1051
  return {
700
1052
  ...base,
701
1053
  status: 'blocked',
@@ -708,11 +1060,18 @@ export class PageContextCollector {
708
1060
  };
709
1061
  }
710
1062
  }
1063
+ const preHighlightStale = executionStale();
1064
+ if (preHighlightStale)
1065
+ return preHighlightStale;
711
1066
  const before = this.safeState(element);
1067
+ const beforeActionUrl = safePageUrl();
1068
+ this.callbacks.onHighlight(element, 'focus');
1069
+ const preMutationStale = executionStale();
1070
+ if (preMutationStale)
1071
+ return preMutationStale;
712
1072
  this.executingActionId = grant.actionId;
713
1073
  this.pendingCapture = false;
714
1074
  this.deferredNavigation = null;
715
- this.callbacks.onHighlight(element, 'focus');
716
1075
  try {
717
1076
  switch (grant.action) {
718
1077
  case 'scroll':
@@ -729,7 +1088,7 @@ export class PageContextCollector {
729
1088
  element.focus({ preventScroll: false });
730
1089
  break;
731
1090
  case 'click':
732
- this.click(element, grant.actionId, grant.desiredChecked, handle.target.destinationPath);
1091
+ this.click(element, grant.actionId, grant.desiredChecked, grant.desiredExpanded, grant.desiredPressed, grant.destinationPath);
733
1092
  break;
734
1093
  case 'type':
735
1094
  this.type(element, grant.value ?? '');
@@ -741,13 +1100,29 @@ export class PageContextCollector {
741
1100
  this.submit(element);
742
1101
  break;
743
1102
  }
1103
+ if (['click', 'type', 'select', 'submit'].includes(grant.action)) {
1104
+ this.callbacks.onActionMutationApplied?.(grant.actionId, this.revision, safePageUrl());
1105
+ }
1106
+ if (grant.action === 'click' &&
1107
+ typeof grant.destinationPath === 'string') {
1108
+ this.observeCausalNavigationAfterMutation(grant.actionId, grant.destinationPath, beforeActionUrl);
1109
+ }
744
1110
  await new Promise((resolve) => setTimeout(resolve, 80));
1111
+ const postActionStale = executionStale();
1112
+ if (postActionStale)
1113
+ return postActionStale;
745
1114
  if (!this.actionApplied(grant, element, handle)) {
746
1115
  throw new Error('The approved value was not applied.');
747
1116
  }
748
1117
  const snapshot = this.capture();
749
1118
  const attached = element.isConnected;
750
1119
  const after = attached ? this.safeState(element) : '';
1120
+ const expanded = attached
1121
+ ? literalBooleanAttribute(element, 'aria-expanded')
1122
+ : undefined;
1123
+ const pressed = attached
1124
+ ? literalBooleanAttribute(element, 'aria-pressed')
1125
+ : undefined;
751
1126
  this.callbacks.onHighlight(attached ? element : null, 'success');
752
1127
  return {
753
1128
  ...base,
@@ -766,10 +1141,14 @@ export class PageContextCollector {
766
1141
  ? 'The requested option was applied to the approved field.'
767
1142
  : grant.desiredChecked !== undefined
768
1143
  ? 'The requested checkbox state was applied.'
769
- : `${grant.action} executed on the approved ${roleFor(element)} target.`,
1144
+ : grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined
1145
+ ? 'The requested button state was applied.'
1146
+ : `${grant.action} executed on the approved ${roleFor(element)} target.`,
770
1147
  ...(element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
771
1148
  ? { checked: element.checked }
772
1149
  : {}),
1150
+ ...(this.statefulButtonActionProof && expanded !== undefined ? { expanded } : {}),
1151
+ ...(this.statefulButtonActionProof && pressed !== undefined ? { pressed } : {}),
773
1152
  },
774
1153
  };
775
1154
  }
@@ -795,10 +1174,25 @@ export class PageContextCollector {
795
1174
  this.pendingNavigation = null;
796
1175
  const shouldCapture = this.pendingCapture || Boolean(navigation);
797
1176
  this.pendingCapture = false;
1177
+ let navigationAcknowledged = true;
1178
+ const settlement = navigation && this.running
1179
+ ? this.beginNavigationSettlement(navigation.pageId, Math.max(navigation.revision, this.revision), navigation.url)
1180
+ : null;
798
1181
  if (navigation && this.running) {
799
- await this.callbacks.onNavigation(navigation.pageId, Math.max(navigation.revision, this.revision), navigation.url);
1182
+ try {
1183
+ navigationAcknowledged =
1184
+ (await this.callbacks.onNavigation(navigation.pageId, Math.max(navigation.revision, this.revision), navigation.url, navigation.causalActionId)) !== false;
1185
+ }
1186
+ catch {
1187
+ navigationAcknowledged = false;
1188
+ }
800
1189
  }
801
- if (this.running && (shouldCapture || !navigation)) {
1190
+ if (this.running &&
1191
+ navigationAcknowledged &&
1192
+ (!settlement || this.navigationSettlement === settlement) &&
1193
+ (shouldCapture || !navigation)) {
1194
+ if (settlement)
1195
+ this.navigationSettlement = null;
802
1196
  await this.captureAndPublish();
803
1197
  }
804
1198
  const deferred = this.deferredNavigation;
@@ -807,19 +1201,39 @@ export class PageContextCollector {
807
1201
  ? deferred.toUrl
808
1202
  : null;
809
1203
  }
810
- click(element, actionId, desiredChecked, expectedDestinationPath) {
1204
+ click(element, actionId, desiredChecked, desiredExpanded, desiredPressed, expectedDestinationPath) {
811
1205
  if (!(element instanceof HTMLElement))
812
1206
  throw new Error('Target is not clickable.');
1207
+ if (desiredChecked !== undefined &&
1208
+ (desiredExpanded !== undefined || desiredPressed !== undefined)) {
1209
+ throw new Error('Conflicting desired-state contracts are not allowed.');
1210
+ }
1211
+ const desiredStates = [];
813
1212
  if (desiredChecked !== undefined) {
814
1213
  if (!(element instanceof HTMLInputElement) || !['checkbox', 'radio'].includes(element.type)) {
815
1214
  throw new Error('Desired checked state requires a checkbox or radio target.');
816
1215
  }
817
- if (element.checked === desiredChecked)
818
- return;
819
- if (element.type === 'radio' && !desiredChecked) {
1216
+ desiredStates.push(element.checked === desiredChecked);
1217
+ if (element.type === 'radio' && !desiredChecked && element.checked !== desiredChecked) {
820
1218
  throw new Error('A radio target cannot be cleared by clicking it.');
821
1219
  }
822
1220
  }
1221
+ if (desiredExpanded !== undefined) {
1222
+ const expanded = literalBooleanAttribute(element, 'aria-expanded');
1223
+ if (roleFor(element) !== 'button' || expanded === undefined) {
1224
+ throw new Error('Desired expanded state requires an expandable button target.');
1225
+ }
1226
+ desiredStates.push(expanded === desiredExpanded);
1227
+ }
1228
+ if (desiredPressed !== undefined) {
1229
+ const pressed = literalBooleanAttribute(element, 'aria-pressed');
1230
+ if (roleFor(element) !== 'button' || pressed === undefined) {
1231
+ throw new Error('Desired pressed state requires a pressed button target.');
1232
+ }
1233
+ desiredStates.push(pressed === desiredPressed);
1234
+ }
1235
+ if (desiredStates.length > 0 && desiredStates.every(Boolean))
1236
+ return;
823
1237
  if (element instanceof HTMLAnchorElement) {
824
1238
  const target = new URL(element.href, window.location.href);
825
1239
  if (expectedDestinationPath !== undefined &&
@@ -859,6 +1273,8 @@ export class PageContextCollector {
859
1273
  type(element, value) {
860
1274
  if (value.length > 2000)
861
1275
  throw new Error('Value is too long.');
1276
+ if (!enabled(element))
1277
+ throw new Error('Text target is not editable.');
862
1278
  if (element instanceof HTMLInputElement) {
863
1279
  if (!['text', 'search', 'email', 'tel', 'url', 'number'].includes(element.type)) {
864
1280
  throw new Error('Input type is not supported.');
@@ -896,14 +1312,24 @@ export class PageContextCollector {
896
1312
  element.dispatchEvent(new Event('change', { bubbles: true }));
897
1313
  }
898
1314
  select(element, handle, optionValueId) {
899
- if (!(element instanceof HTMLSelectElement) || !optionValueId) {
1315
+ if (!(element instanceof HTMLSelectElement) || !optionValueId || !enabled(element)) {
900
1316
  throw new Error('Select target is invalid.');
901
1317
  }
902
1318
  const option = handle.options.get(optionValueId);
903
- if (!option || option.disabled || !option.isConnected) {
1319
+ if (!option ||
1320
+ optionDisabled(option) ||
1321
+ !option.isConnected ||
1322
+ option.closest('select') !== element) {
904
1323
  throw new Error('Select option is unavailable.');
905
1324
  }
906
- element.value = option.value;
1325
+ const selectedIndexSetter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex')?.set;
1326
+ if (!selectedIndexSetter) {
1327
+ throw new Error('Select option cannot be updated safely.');
1328
+ }
1329
+ selectedIndexSetter.call(element, option.index);
1330
+ if (element.options[element.selectedIndex] !== option || !option.selected) {
1331
+ throw new Error('Select option could not be applied.');
1332
+ }
907
1333
  element.dispatchEvent(new Event('input', { bubbles: true }));
908
1334
  element.dispatchEvent(new Event('change', { bubbles: true }));
909
1335
  }
@@ -925,6 +1351,7 @@ export class PageContextCollector {
925
1351
  connected: element.isConnected,
926
1352
  disabled: !enabled(element),
927
1353
  expanded: element.getAttribute('aria-expanded'),
1354
+ pressed: element.getAttribute('aria-pressed'),
928
1355
  checked: element instanceof HTMLInputElement && ['checkbox', 'radio'].includes(element.type)
929
1356
  ? element.checked
930
1357
  : undefined,
@@ -938,6 +1365,17 @@ export class PageContextCollector {
938
1365
  ['checkbox', 'radio'].includes(element.type) &&
939
1366
  element.checked === grant.desiredChecked);
940
1367
  }
1368
+ if (grant.desiredExpanded !== undefined &&
1369
+ literalBooleanAttribute(element, 'aria-expanded') !== grant.desiredExpanded) {
1370
+ return false;
1371
+ }
1372
+ if (grant.desiredPressed !== undefined &&
1373
+ literalBooleanAttribute(element, 'aria-pressed') !== grant.desiredPressed) {
1374
+ return false;
1375
+ }
1376
+ if (grant.desiredExpanded !== undefined || grant.desiredPressed !== undefined) {
1377
+ return true;
1378
+ }
941
1379
  if (grant.action === 'type') {
942
1380
  if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
943
1381
  return element.value === (grant.value ?? '');
@@ -949,7 +1387,13 @@ export class PageContextCollector {
949
1387
  }
950
1388
  if (grant.action === 'select') {
951
1389
  const option = grant.optionValueId ? handle.options.get(grant.optionValueId) : undefined;
952
- return (element instanceof HTMLSelectElement && Boolean(option) && element.value === option?.value);
1390
+ if (!(element instanceof HTMLSelectElement) || !option)
1391
+ return false;
1392
+ return (option.isConnected &&
1393
+ option.closest('select') === element &&
1394
+ !optionDisabled(option) &&
1395
+ element.options[element.selectedIndex] === option &&
1396
+ option.selected);
953
1397
  }
954
1398
  return true;
955
1399
  }
@@ -970,6 +1414,8 @@ export class PageContextCollector {
970
1414
  };
971
1415
  }
972
1416
  async captureAndPublishIfChanged() {
1417
+ if (this.navigationSettlement)
1418
+ return;
973
1419
  const snapshot = this.captureSnapshot(false);
974
1420
  if (snapshot && this.running)
975
1421
  await this.callbacks.onSnapshot(snapshot);
@@ -1043,6 +1489,54 @@ export class PageContextCollector {
1043
1489
  capturedAt: new Date().toISOString(),
1044
1490
  };
1045
1491
  }
1492
+ beginNavigationSettlement(pageId, revision, url) {
1493
+ const settlement = {
1494
+ generation: ++this.navigationSettlementGeneration,
1495
+ pageId,
1496
+ revision,
1497
+ url,
1498
+ };
1499
+ this.navigationSettlement = settlement;
1500
+ if (this.captureTimer)
1501
+ clearTimeout(this.captureTimer);
1502
+ this.captureTimer = null;
1503
+ if (this.mutationCaptureTimer)
1504
+ clearTimeout(this.mutationCaptureTimer);
1505
+ this.mutationCaptureTimer = null;
1506
+ return settlement;
1507
+ }
1508
+ observeCausalNavigationAfterMutation(actionId, destinationPath, beforeActionUrl) {
1509
+ const currentUrl = safePageUrl();
1510
+ if (currentUrl === beforeActionUrl ||
1511
+ window.location.search !== '' ||
1512
+ window.location.hash !== '' ||
1513
+ window.location.pathname !== destinationPath) {
1514
+ return;
1515
+ }
1516
+ if (this.pendingNavigation?.causalActionId !== actionId ||
1517
+ this.pendingNavigation.url !== currentUrl) {
1518
+ this.pageId = id('page');
1519
+ this.revision += 1;
1520
+ this.targets.clear();
1521
+ this.recentSelection = null;
1522
+ this.pendingNavigation = {
1523
+ pageId: this.pageId,
1524
+ revision: this.revision,
1525
+ url: currentUrl,
1526
+ causalActionId: actionId,
1527
+ causalObservationReported: false,
1528
+ };
1529
+ }
1530
+ this.pendingCapture = true;
1531
+ this.reportCausalNavigationObservation();
1532
+ }
1533
+ reportCausalNavigationObservation() {
1534
+ const navigation = this.pendingNavigation;
1535
+ if (!navigation || navigation.causalObservationReported)
1536
+ return;
1537
+ navigation.causalObservationReported = true;
1538
+ this.callbacks.onCausalNavigationObserved?.(navigation.pageId, navigation.revision, navigation.url, navigation.causalActionId);
1539
+ }
1046
1540
  installNavigationHooks() {
1047
1541
  this.originalPushState = history.pushState;
1048
1542
  this.originalReplaceState = history.replaceState;