@runtypelabs/persona 1.48.0 → 2.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 (69) hide show
  1. package/README.md +140 -8
  2. package/dist/index.cjs +90 -39
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1055 -24
  5. package/dist/index.d.ts +1055 -24
  6. package/dist/index.global.js +111 -60
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +90 -39
  9. package/dist/index.js.map +1 -1
  10. package/dist/install.global.js +1 -1
  11. package/dist/install.global.js.map +1 -1
  12. package/dist/widget.css +836 -513
  13. package/package.json +1 -1
  14. package/src/artifacts-session.test.ts +80 -0
  15. package/src/client.test.ts +20 -21
  16. package/src/client.ts +153 -4
  17. package/src/components/approval-bubble.ts +45 -42
  18. package/src/components/artifact-card.ts +91 -0
  19. package/src/components/artifact-pane.ts +501 -0
  20. package/src/components/composer-builder.ts +32 -27
  21. package/src/components/event-stream-view.ts +40 -40
  22. package/src/components/feedback.ts +36 -36
  23. package/src/components/forms.ts +11 -11
  24. package/src/components/header-builder.test.ts +32 -0
  25. package/src/components/header-builder.ts +55 -36
  26. package/src/components/header-layouts.ts +58 -125
  27. package/src/components/launcher.ts +36 -21
  28. package/src/components/message-bubble.ts +92 -65
  29. package/src/components/messages.ts +2 -2
  30. package/src/components/panel.ts +42 -11
  31. package/src/components/reasoning-bubble.ts +23 -23
  32. package/src/components/registry.ts +4 -0
  33. package/src/components/suggestions.ts +1 -1
  34. package/src/components/tool-bubble.ts +32 -32
  35. package/src/defaults.ts +30 -4
  36. package/src/index.ts +80 -2
  37. package/src/install.ts +22 -0
  38. package/src/plugins/types.ts +23 -0
  39. package/src/postprocessors.ts +2 -2
  40. package/src/runtime/host-layout.ts +174 -0
  41. package/src/runtime/init.test.ts +236 -0
  42. package/src/runtime/init.ts +114 -55
  43. package/src/session.ts +135 -2
  44. package/src/styles/tailwind.css +1 -1
  45. package/src/styles/widget.css +836 -513
  46. package/src/types/theme.ts +354 -0
  47. package/src/types.ts +314 -15
  48. package/src/ui.docked.test.ts +104 -0
  49. package/src/ui.ts +940 -227
  50. package/src/utils/artifact-gate.test.ts +255 -0
  51. package/src/utils/artifact-gate.ts +142 -0
  52. package/src/utils/artifact-resize.test.ts +64 -0
  53. package/src/utils/artifact-resize.ts +67 -0
  54. package/src/utils/attachment-manager.ts +10 -10
  55. package/src/utils/code-generators.test.ts +52 -0
  56. package/src/utils/code-generators.ts +40 -36
  57. package/src/utils/dock.ts +17 -0
  58. package/src/utils/dom-context.test.ts +504 -0
  59. package/src/utils/dom-context.ts +896 -0
  60. package/src/utils/dom.ts +12 -1
  61. package/src/utils/message-fingerprint.test.ts +187 -0
  62. package/src/utils/message-fingerprint.ts +105 -0
  63. package/src/utils/migration.ts +179 -0
  64. package/src/utils/morph.ts +1 -1
  65. package/src/utils/plugins.ts +175 -0
  66. package/src/utils/positioning.ts +4 -4
  67. package/src/utils/theme.test.ts +125 -0
  68. package/src/utils/theme.ts +216 -60
  69. package/src/utils/tokens.ts +682 -0
@@ -0,0 +1,896 @@
1
+ /**
2
+ * Enriched DOM context collection for providing richer page information to AI.
3
+ *
4
+ * Captures interactive elements, stable CSS selectors, ARIA roles, data attributes,
5
+ * and visibility state — giving the LLM much better context than basic className/innerText.
6
+ *
7
+ * ## Modes
8
+ *
9
+ * - **structured** (default): collects candidates, scores them with optional {@link ParseRule}
10
+ * hooks, then applies `maxElements`. Rich containers (e.g. product cards) can surface
11
+ * before unrelated static noise.
12
+ * - **simple**: legacy behavior — cap during traversal, interactive-first ordering, no rule
13
+ * scoring or {@link EnrichedPageElement.formattedSummary}.
14
+ */
15
+
16
+ export interface EnrichedPageElement {
17
+ /** Stable CSS selector the LLM can use directly */
18
+ selector: string;
19
+ /** Lowercase tag name */
20
+ tagName: string;
21
+ /** Visible text content, trimmed */
22
+ text: string;
23
+ /** ARIA role or null */
24
+ role: string | null;
25
+ /** Interactivity classification */
26
+ interactivity: "clickable" | "input" | "navigable" | "static";
27
+ /** Relevant attributes: id, data-*, href, aria-label, type, value, name */
28
+ attributes: Record<string, string>;
29
+ /**
30
+ * When set (structured mode + matching rule), {@link formatEnrichedContext} prefers this
31
+ * markdown-like line instead of raw `text`.
32
+ */
33
+ formattedSummary?: string;
34
+ }
35
+
36
+ /** How DOM context is collected and formatted. */
37
+ export type DomContextMode = "simple" | "structured";
38
+
39
+ /**
40
+ * Options that control collection limits, visibility, and mode.
41
+ * Prefer nesting these under {@link DomContextOptions.options}; top-level fields remain
42
+ * supported for backward compatibility.
43
+ */
44
+ export interface ParseOptionsConfig {
45
+ /**
46
+ * `structured` (default): score candidates with rules, then apply `maxElements`.
47
+ * `simple`: legacy traversal cap and ordering only — rules are ignored (with a warning
48
+ * if `rules` was passed on {@link DomContextOptions}).
49
+ */
50
+ mode?: DomContextMode;
51
+ /** Maximum number of elements to return. Default: 80 */
52
+ maxElements?: number;
53
+ /** CSS selector for elements to exclude (e.g. the widget host). Default: '.persona-host' */
54
+ excludeSelector?: string;
55
+ /** Maximum text length per element. Default: 200 */
56
+ maxTextLength?: number;
57
+ /** Only include visible elements. Default: true */
58
+ visibleOnly?: boolean;
59
+ /** Root element to walk. Default: document.body */
60
+ root?: HTMLElement;
61
+ /**
62
+ * Maximum candidates gathered before scoring (structured mode only).
63
+ * Default: `max(500, maxElements * 10)`.
64
+ */
65
+ maxCandidates?: number;
66
+ }
67
+
68
+ export interface RuleScoringContext {
69
+ doc: Document;
70
+ maxTextLength: number;
71
+ }
72
+
73
+ /**
74
+ * Extensible rule for structured DOM context: scoring, descendant suppression, and
75
+ * optional formatted output.
76
+ */
77
+ export interface ParseRule {
78
+ /** Stable id for debugging and tests */
79
+ id: string;
80
+ /**
81
+ * Score bonus when this rule applies to the element (0 when it does not).
82
+ * Higher scores are kept first when applying `maxElements`.
83
+ */
84
+ scoreElement(
85
+ el: HTMLElement,
86
+ enriched: EnrichedPageElement,
87
+ ctx: RuleScoringContext
88
+ ): number;
89
+ /**
90
+ * When `owner` is kept in the final set and matched this rule for formatting,
91
+ * return true to drop `descendant` (redundant price text, CTAs summarized on the card, etc.).
92
+ */
93
+ shouldSuppressDescendant?(
94
+ owner: HTMLElement,
95
+ descendant: HTMLElement,
96
+ descendantEnriched: EnrichedPageElement
97
+ ): boolean;
98
+ /**
99
+ * Markdown-like summary for the LLM. Only used when `scoreElement` &gt; 0 for this rule.
100
+ */
101
+ formatSummary?(
102
+ el: HTMLElement,
103
+ enriched: EnrichedPageElement,
104
+ ctx: RuleScoringContext
105
+ ): string | null;
106
+ }
107
+
108
+ export interface DomContextOptions {
109
+ /** Nested parse options (mode, limits, root). Merged with legacy top-level fields. */
110
+ options?: ParseOptionsConfig;
111
+ /** Custom rules for structured mode. Default: {@link defaultParseRules} */
112
+ rules?: ParseRule[];
113
+ /** @inheritdoc ParseOptionsConfig.maxElements */
114
+ maxElements?: number;
115
+ /** @inheritdoc ParseOptionsConfig.excludeSelector */
116
+ excludeSelector?: string;
117
+ /** @inheritdoc ParseOptionsConfig.maxTextLength */
118
+ maxTextLength?: number;
119
+ /** @inheritdoc ParseOptionsConfig.visibleOnly */
120
+ visibleOnly?: boolean;
121
+ /** @inheritdoc ParseOptionsConfig.root */
122
+ root?: HTMLElement;
123
+ }
124
+
125
+ export interface FormatEnrichedContextOptions {
126
+ /** When `simple`, ignore {@link EnrichedPageElement.formattedSummary}. Default: structured */
127
+ mode?: DomContextMode;
128
+ }
129
+
130
+ const SKIP_TAGS = new Set([
131
+ "script",
132
+ "style",
133
+ "noscript",
134
+ "svg",
135
+ "path",
136
+ "meta",
137
+ "link",
138
+ "br",
139
+ "hr",
140
+ ]);
141
+
142
+ const INTERACTIVE_TAGS = new Set([
143
+ "button",
144
+ "a",
145
+ "input",
146
+ "select",
147
+ "textarea",
148
+ "details",
149
+ "summary",
150
+ ]);
151
+
152
+ const INTERACTIVE_ROLES = new Set([
153
+ "button",
154
+ "link",
155
+ "menuitem",
156
+ "tab",
157
+ "option",
158
+ "switch",
159
+ "checkbox",
160
+ "radio",
161
+ "combobox",
162
+ "listbox",
163
+ "slider",
164
+ "spinbutton",
165
+ "textbox",
166
+ ]);
167
+
168
+ /** Class / id / data-* value hints for card-like containers */
169
+ const CARD_HINT_RE = /\b(product|card|item|listing|result)\b/i;
170
+
171
+ /** Currency-like text in subtree */
172
+ const CURRENCY_RE =
173
+ /\$[\d,]+(?:\.\d{2})?|€[\d,]+(?:\.\d{2})?|£[\d,]+(?:\.\d{2})?|USD\s*[\d,]+(?:\.\d{2})?/i;
174
+
175
+ const BASE_SCORE_INTERACTIVE = 3000;
176
+ const BASE_SCORE_STATIC = 100;
177
+
178
+ function hasCardHint(el: HTMLElement): boolean {
179
+ const cls = typeof el.className === "string" ? el.className : "";
180
+ if (CARD_HINT_RE.test(cls)) return true;
181
+ if (el.id && CARD_HINT_RE.test(el.id)) return true;
182
+ for (let i = 0; i < el.attributes.length; i++) {
183
+ const a = el.attributes[i];
184
+ if (a.name.startsWith("data-") && CARD_HINT_RE.test(a.value)) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ function subtreeHasCurrency(el: HTMLElement): boolean {
190
+ return CURRENCY_RE.test((el.textContent ?? "").trim());
191
+ }
192
+
193
+ function subtreeHasNonTrivialLink(el: HTMLElement): boolean {
194
+ const anchors = el.querySelectorAll("a[href]");
195
+ for (let i = 0; i < anchors.length; i++) {
196
+ const href = (anchors[i] as HTMLAnchorElement).getAttribute("href") ?? "";
197
+ if (href && href !== "#" && !href.toLowerCase().startsWith("javascript:"))
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ function subtreeHasButtonLike(el: HTMLElement): boolean {
204
+ return !!el.querySelector(
205
+ 'button, [role="button"], input[type="submit"], input[type="button"]'
206
+ );
207
+ }
208
+
209
+ function extractFirstPrice(text: string): string | null {
210
+ const m = text.match(CURRENCY_RE);
211
+ return m ? m[0] : null;
212
+ }
213
+
214
+ function extractTitleAndHref(el: HTMLElement): { title: string; href: string | null } {
215
+ const link =
216
+ el.querySelector(
217
+ ".product-title a, h1 a, h2 a, h3 a, h4 a, .title a, a[href]"
218
+ ) ?? el.querySelector("a[href]");
219
+ if (link && link.textContent?.trim()) {
220
+ const href = (link as HTMLAnchorElement).getAttribute("href");
221
+ return {
222
+ title: link.textContent.trim(),
223
+ href: href && href !== "#" ? href : null,
224
+ };
225
+ }
226
+ const heading = el.querySelector("h1, h2, h3, h4, h5, h6");
227
+ if (heading?.textContent?.trim()) {
228
+ return { title: heading.textContent.trim(), href: null };
229
+ }
230
+ return { title: "", href: null };
231
+ }
232
+
233
+ function extractCtaLabels(el: HTMLElement): string[] {
234
+ const labels: string[] = [];
235
+ const push = (s: string) => {
236
+ const t = s.trim();
237
+ if (t && !labels.includes(t)) labels.push(t);
238
+ };
239
+ el.querySelectorAll("button").forEach((b) => push(b.textContent ?? ""));
240
+ el.querySelectorAll('[role="button"]').forEach((b) => push(b.textContent ?? ""));
241
+ el.querySelectorAll('input[type="submit"], input[type="button"]').forEach((inp) => {
242
+ push((inp as HTMLInputElement).value ?? "");
243
+ });
244
+ return labels.slice(0, 6);
245
+ }
246
+
247
+ export const COMMERCE_CARD_RULE_ID = "commerce-card";
248
+ export const RESULT_CARD_RULE_ID = "result-card";
249
+
250
+ function commerceCardScore(el: HTMLElement): number {
251
+ if (!hasCardHint(el)) return 0;
252
+ if (!subtreeHasCurrency(el)) return 0;
253
+ if (!subtreeHasNonTrivialLink(el) && !subtreeHasButtonLike(el)) return 0;
254
+ return 5200;
255
+ }
256
+
257
+ function resultCardScore(el: HTMLElement): number {
258
+ if (!hasCardHint(el)) return 0;
259
+ if (subtreeHasCurrency(el)) return 0;
260
+ if (!subtreeHasNonTrivialLink(el)) return 0;
261
+ const text = (el.textContent ?? "").trim();
262
+ if (text.length < 20) return 0;
263
+ const hasTitle =
264
+ !!el.querySelector("h1, h2, h3, h4, h5, h6, .title") ||
265
+ !!el.querySelector(".snippet, .description, p");
266
+ if (!hasTitle) return 0;
267
+ return 2800;
268
+ }
269
+
270
+ /** Default structured rules: commerce-style cards and generic search/result rows. */
271
+ export const defaultParseRules: ParseRule[] = [
272
+ {
273
+ id: COMMERCE_CARD_RULE_ID,
274
+ scoreElement(el) {
275
+ return commerceCardScore(el);
276
+ },
277
+ shouldSuppressDescendant(owner, descendant, enriched) {
278
+ if (descendant === owner || !owner.contains(descendant)) return false;
279
+ if (enriched.interactivity === "static") {
280
+ const t = enriched.text.trim();
281
+ if (t.length === 0) return true;
282
+ if (CURRENCY_RE.test(t) && t.length < 32) return true;
283
+ return false;
284
+ }
285
+ return true;
286
+ },
287
+ formatSummary(el, enriched) {
288
+ if (commerceCardScore(el) === 0) return null;
289
+ const { title, href } = extractTitleAndHref(el);
290
+ const price =
291
+ extractFirstPrice((el.textContent ?? "").trim()) ??
292
+ extractFirstPrice(enriched.text) ??
293
+ "";
294
+ const ctas = extractCtaLabels(el);
295
+ const head =
296
+ href && title
297
+ ? `[${title}](${href})${price ? ` — ${price}` : ""}`
298
+ : title
299
+ ? `${title}${price ? ` — ${price}` : ""}`
300
+ : price || enriched.text.trim().slice(0, 120);
301
+ const lines = [
302
+ head,
303
+ `selector: ${enriched.selector}`,
304
+ ctas.length ? `actions: ${ctas.join(", ")}` : "",
305
+ ].filter(Boolean);
306
+ return lines.join("\n");
307
+ },
308
+ },
309
+ {
310
+ id: RESULT_CARD_RULE_ID,
311
+ scoreElement(el) {
312
+ return resultCardScore(el);
313
+ },
314
+ formatSummary(el, enriched) {
315
+ if (resultCardScore(el) === 0) return null;
316
+ const { title, href } = extractTitleAndHref(el);
317
+ const head =
318
+ href && title
319
+ ? `[${title}](${href})`
320
+ : title || enriched.text.trim().slice(0, 120);
321
+ const lines = [head, `selector: ${enriched.selector}`].filter(Boolean);
322
+ return lines.join("\n");
323
+ },
324
+ },
325
+ ];
326
+
327
+ interface ResolvedDomContextConfig {
328
+ mode: DomContextMode;
329
+ maxElements: number;
330
+ maxCandidates: number;
331
+ excludeSelector: string;
332
+ maxTextLength: number;
333
+ visibleOnly: boolean;
334
+ root: HTMLElement | undefined;
335
+ rules: ParseRule[];
336
+ }
337
+
338
+ function warnSimpleWithRules(): void {
339
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
340
+ console.warn(
341
+ "[persona] collectEnrichedPageContext: options.mode is \"simple\" but `rules` were provided; rules are ignored."
342
+ );
343
+ }
344
+ }
345
+
346
+ function resolveDomContextConfig(options: DomContextOptions): ResolvedDomContextConfig {
347
+ const nested = options.options ?? {};
348
+ const maxElements =
349
+ nested.maxElements ?? options.maxElements ?? 80;
350
+ const excludeSelector =
351
+ nested.excludeSelector ?? options.excludeSelector ?? ".persona-host";
352
+ const maxTextLength =
353
+ nested.maxTextLength ?? options.maxTextLength ?? 200;
354
+ const visibleOnly =
355
+ nested.visibleOnly ?? options.visibleOnly ?? true;
356
+ const root = nested.root ?? options.root;
357
+ const mode: DomContextMode = nested.mode ?? "structured";
358
+ const maxCandidates =
359
+ nested.maxCandidates ?? Math.max(500, maxElements * 10);
360
+
361
+ let rules = options.rules ?? defaultParseRules;
362
+ if (mode === "simple" && options.rules && options.rules.length > 0) {
363
+ warnSimpleWithRules();
364
+ rules = [];
365
+ } else if (mode === "simple") {
366
+ rules = [];
367
+ }
368
+
369
+ return {
370
+ mode,
371
+ maxElements,
372
+ maxCandidates,
373
+ excludeSelector,
374
+ maxTextLength,
375
+ visibleOnly,
376
+ root,
377
+ rules,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Escape a string for use in CSS selectors. Falls back to simple escaping
383
+ * when CSS.escape is not available (e.g. in jsdom).
384
+ */
385
+ function cssEscape(str: string): string {
386
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
387
+ return CSS.escape(str);
388
+ }
389
+ return str.replace(/([^\w-])/g, "\\$1");
390
+ }
391
+
392
+ const DATA_ATTR_PRIORITY = [
393
+ "data-testid",
394
+ "data-product",
395
+ "data-action",
396
+ "data-id",
397
+ "data-name",
398
+ "data-type",
399
+ ];
400
+
401
+ /**
402
+ * Classify an element's interactivity type.
403
+ */
404
+ function classifyInteractivity(
405
+ el: HTMLElement
406
+ ): EnrichedPageElement["interactivity"] {
407
+ const tag = el.tagName.toLowerCase();
408
+ const role = el.getAttribute("role");
409
+
410
+ if (tag === "a" && el.hasAttribute("href")) return "navigable";
411
+ if (tag === "input" || tag === "select" || tag === "textarea") return "input";
412
+ if (
413
+ role === "textbox" ||
414
+ role === "combobox" ||
415
+ role === "listbox" ||
416
+ role === "spinbutton"
417
+ )
418
+ return "input";
419
+ if (tag === "button" || role === "button") return "clickable";
420
+ if (
421
+ INTERACTIVE_TAGS.has(tag) ||
422
+ (role && INTERACTIVE_ROLES.has(role)) ||
423
+ el.hasAttribute("tabindex") ||
424
+ el.hasAttribute("onclick") ||
425
+ el.getAttribute("contenteditable") === "true"
426
+ )
427
+ return "clickable";
428
+
429
+ return "static";
430
+ }
431
+
432
+ /**
433
+ * Check if an element is visible.
434
+ * Uses a defensive approach: only marks as invisible when we have positive evidence
435
+ * of hidden state (display:none, visibility:hidden, hidden attribute).
436
+ * offsetParent is unreliable in non-layout environments (e.g. jsdom).
437
+ */
438
+ function isElementVisible(el: HTMLElement): boolean {
439
+ if (el.hidden) return false;
440
+
441
+ try {
442
+ const style = getComputedStyle(el);
443
+ if (style.display === "none") return false;
444
+ if (style.visibility === "hidden") return false;
445
+ } catch {
446
+ // getComputedStyle can fail in some environments — assume visible
447
+ }
448
+
449
+ if (el.style.display === "none") return false;
450
+ if (el.style.visibility === "hidden") return false;
451
+
452
+ return true;
453
+ }
454
+
455
+ /**
456
+ * Collect relevant attributes from an element.
457
+ */
458
+ function collectAttributes(el: HTMLElement): Record<string, string> {
459
+ const attrs: Record<string, string> = {};
460
+
461
+ const id = el.id;
462
+ if (id) attrs["id"] = id;
463
+
464
+ const href = el.getAttribute("href");
465
+ if (href) attrs["href"] = href;
466
+
467
+ const ariaLabel = el.getAttribute("aria-label");
468
+ if (ariaLabel) attrs["aria-label"] = ariaLabel;
469
+
470
+ const type = el.getAttribute("type");
471
+ if (type) attrs["type"] = type;
472
+
473
+ const value = el.getAttribute("value");
474
+ if (value) attrs["value"] = value;
475
+
476
+ const name = el.getAttribute("name");
477
+ if (name) attrs["name"] = name;
478
+
479
+ const role = el.getAttribute("role");
480
+ if (role) attrs["role"] = role;
481
+
482
+ for (let i = 0; i < el.attributes.length; i++) {
483
+ const attr = el.attributes[i];
484
+ if (attr.name.startsWith("data-")) {
485
+ attrs[attr.name] = attr.value;
486
+ }
487
+ }
488
+
489
+ return attrs;
490
+ }
491
+
492
+ /**
493
+ * Generate a stable, unique CSS selector for an element.
494
+ * Priority: #id → [data-testid]/[data-product] → tag.classes with :nth-of-type()
495
+ */
496
+ export function generateStableSelector(el: HTMLElement): string {
497
+ const tag = el.tagName.toLowerCase();
498
+
499
+ if (el.id) {
500
+ const sel = `#${cssEscape(el.id)}`;
501
+ try {
502
+ if (el.ownerDocument.querySelectorAll(sel).length === 1) return sel;
503
+ } catch {
504
+ // invalid selector, fall through
505
+ }
506
+ }
507
+
508
+ for (const attr of DATA_ATTR_PRIORITY) {
509
+ const val = el.getAttribute(attr);
510
+ if (val) {
511
+ const sel = `${tag}[${attr}="${cssEscape(val)}"]`;
512
+ try {
513
+ if (el.ownerDocument.querySelectorAll(sel).length === 1) return sel;
514
+ } catch {
515
+ // invalid selector, fall through
516
+ }
517
+ }
518
+ }
519
+
520
+ const classes = Array.from(el.classList)
521
+ .filter((c) => c && !c.startsWith("persona-"))
522
+ .slice(0, 3);
523
+
524
+ if (classes.length > 0) {
525
+ const classSel = `${tag}.${classes.map((c) => cssEscape(c)).join(".")}`;
526
+ try {
527
+ if (el.ownerDocument.querySelectorAll(classSel).length === 1) return classSel;
528
+ } catch {
529
+ // fall through
530
+ }
531
+
532
+ const parent = el.parentElement;
533
+ if (parent) {
534
+ const siblings = Array.from(parent.querySelectorAll(`:scope > ${tag}`));
535
+ const index = siblings.indexOf(el);
536
+ if (index >= 0) {
537
+ const nthSel = `${classSel}:nth-of-type(${index + 1})`;
538
+ try {
539
+ if (el.ownerDocument.querySelectorAll(nthSel).length === 1) return nthSel;
540
+ } catch {
541
+ // fall through
542
+ }
543
+ }
544
+ }
545
+ }
546
+
547
+ const parent = el.parentElement;
548
+ if (parent) {
549
+ const siblings = Array.from(parent.querySelectorAll(`:scope > ${tag}`));
550
+ const index = siblings.indexOf(el);
551
+ if (index >= 0) {
552
+ return `${tag}:nth-of-type(${index + 1})`;
553
+ }
554
+ }
555
+
556
+ return tag;
557
+ }
558
+
559
+ function baseInteractivityScore(
560
+ interactivity: EnrichedPageElement["interactivity"]
561
+ ): number {
562
+ return interactivity === "static" ? BASE_SCORE_STATIC : BASE_SCORE_INTERACTIVE;
563
+ }
564
+
565
+ interface ScoredCandidate {
566
+ el: HTMLElement;
567
+ domIndex: number;
568
+ enriched: EnrichedPageElement;
569
+ score: number;
570
+ formattingRule: ParseRule | null;
571
+ }
572
+
573
+ function buildEnriched(
574
+ el: HTMLElement,
575
+ maxTextLength: number
576
+ ): EnrichedPageElement {
577
+ const tag = el.tagName.toLowerCase();
578
+ const text = (el.textContent ?? "").trim().substring(0, maxTextLength);
579
+ return {
580
+ selector: generateStableSelector(el),
581
+ tagName: tag,
582
+ text,
583
+ role: el.getAttribute("role"),
584
+ interactivity: classifyInteractivity(el),
585
+ attributes: collectAttributes(el),
586
+ };
587
+ }
588
+
589
+ function scoreCandidate(
590
+ el: HTMLElement,
591
+ enriched: EnrichedPageElement,
592
+ rules: ParseRule[],
593
+ ctx: RuleScoringContext
594
+ ): { score: number; formattingRule: ParseRule | null } {
595
+ let score = baseInteractivityScore(enriched.interactivity);
596
+ let formattingRule: ParseRule | null = null;
597
+ for (const rule of rules) {
598
+ const bonus = rule.scoreElement(el, enriched, ctx);
599
+ if (bonus > 0) {
600
+ score += bonus;
601
+ if (rule.formatSummary && !formattingRule) formattingRule = rule;
602
+ }
603
+ }
604
+ return { score, formattingRule };
605
+ }
606
+
607
+ function shouldSuppress(
608
+ kept: ScoredCandidate[],
609
+ cand: ScoredCandidate
610
+ ): boolean {
611
+ for (const k of kept) {
612
+ if (cand.el === k.el) continue;
613
+ if (!k.formattingRule?.shouldSuppressDescendant) continue;
614
+ if (!k.el.contains(cand.el)) continue;
615
+ if (
616
+ k.formattingRule.shouldSuppressDescendant(
617
+ k.el,
618
+ cand.el,
619
+ cand.enriched
620
+ )
621
+ )
622
+ return true;
623
+ }
624
+ return false;
625
+ }
626
+
627
+ function collectStructured(
628
+ cfg: ResolvedDomContextConfig,
629
+ rootEl: HTMLElement
630
+ ): EnrichedPageElement[] {
631
+ const ctx: RuleScoringContext = {
632
+ doc: rootEl.ownerDocument,
633
+ maxTextLength: cfg.maxTextLength,
634
+ };
635
+
636
+ const seenSelectors = new Set<string>();
637
+ const raw: ScoredCandidate[] = [];
638
+ let domIndex = 0;
639
+
640
+ const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, null);
641
+ let node: Node | null = walker.currentNode;
642
+
643
+ while (node && raw.length < cfg.maxCandidates) {
644
+ if (node.nodeType === Node.ELEMENT_NODE) {
645
+ const el = node as HTMLElement;
646
+ const tag = el.tagName.toLowerCase();
647
+
648
+ if (SKIP_TAGS.has(tag)) {
649
+ node = walker.nextNode();
650
+ continue;
651
+ }
652
+
653
+ if (cfg.excludeSelector) {
654
+ try {
655
+ if (el.closest(cfg.excludeSelector)) {
656
+ node = walker.nextNode();
657
+ continue;
658
+ }
659
+ } catch {
660
+ // invalid selector
661
+ }
662
+ }
663
+
664
+ if (cfg.visibleOnly && !isElementVisible(el)) {
665
+ node = walker.nextNode();
666
+ continue;
667
+ }
668
+
669
+ const enriched = buildEnriched(el, cfg.maxTextLength);
670
+ const hasText = enriched.text.length > 0;
671
+ const hasMeaningfulAttrs =
672
+ Object.keys(enriched.attributes).length > 0 &&
673
+ !Object.keys(enriched.attributes).every((k) => k === "role");
674
+
675
+ if (!hasText && !hasMeaningfulAttrs) {
676
+ node = walker.nextNode();
677
+ continue;
678
+ }
679
+
680
+ if (seenSelectors.has(enriched.selector)) {
681
+ node = walker.nextNode();
682
+ continue;
683
+ }
684
+ seenSelectors.add(enriched.selector);
685
+
686
+ const { score, formattingRule } = scoreCandidate(
687
+ el,
688
+ enriched,
689
+ cfg.rules,
690
+ ctx
691
+ );
692
+ raw.push({ el, domIndex, enriched, score, formattingRule });
693
+ domIndex += 1;
694
+ }
695
+ node = walker.nextNode();
696
+ }
697
+
698
+ raw.sort((a, b) => {
699
+ const sa = a.enriched.interactivity === "static" ? 1 : 0;
700
+ const sb = b.enriched.interactivity === "static" ? 1 : 0;
701
+ if (sa !== sb) return sa - sb;
702
+ if (b.score !== a.score) return b.score - a.score;
703
+ return a.domIndex - b.domIndex;
704
+ });
705
+
706
+ const kept: ScoredCandidate[] = [];
707
+ for (const cand of raw) {
708
+ if (kept.length >= cfg.maxElements) break;
709
+ if (shouldSuppress(kept, cand)) continue;
710
+ kept.push(cand);
711
+ }
712
+
713
+ kept.sort((a, b) => {
714
+ const sa = a.enriched.interactivity === "static" ? 1 : 0;
715
+ const sb = b.enriched.interactivity === "static" ? 1 : 0;
716
+ if (sa !== sb) return sa - sb;
717
+ if (sa === 1 && b.score !== a.score) return b.score - a.score;
718
+ return a.domIndex - b.domIndex;
719
+ });
720
+
721
+ return kept.map((c) => {
722
+ let formattedSummary: string | undefined;
723
+ if (c.formattingRule?.formatSummary) {
724
+ const line = c.formattingRule.formatSummary(c.el, c.enriched, ctx);
725
+ if (line) formattedSummary = line;
726
+ }
727
+ const out: EnrichedPageElement = { ...c.enriched };
728
+ if (formattedSummary) out.formattedSummary = formattedSummary;
729
+ return out;
730
+ });
731
+ }
732
+
733
+ function collectSimple(
734
+ cfg: ResolvedDomContextConfig,
735
+ rootEl: HTMLElement
736
+ ): EnrichedPageElement[] {
737
+ const elements: EnrichedPageElement[] = [];
738
+ const seenSelectors = new Set<string>();
739
+
740
+ const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT, null);
741
+ let node: Node | null = walker.currentNode;
742
+
743
+ while (node && elements.length < cfg.maxElements) {
744
+ if (node.nodeType === Node.ELEMENT_NODE) {
745
+ const el = node as HTMLElement;
746
+ const tag = el.tagName.toLowerCase();
747
+
748
+ if (SKIP_TAGS.has(tag)) {
749
+ node = walker.nextNode();
750
+ continue;
751
+ }
752
+
753
+ if (cfg.excludeSelector) {
754
+ try {
755
+ if (el.closest(cfg.excludeSelector)) {
756
+ node = walker.nextNode();
757
+ continue;
758
+ }
759
+ } catch {
760
+ // invalid selector
761
+ }
762
+ }
763
+
764
+ if (cfg.visibleOnly && !isElementVisible(el)) {
765
+ node = walker.nextNode();
766
+ continue;
767
+ }
768
+
769
+ const enriched = buildEnriched(el, cfg.maxTextLength);
770
+ const hasText = enriched.text.length > 0;
771
+ const hasMeaningfulAttrs =
772
+ Object.keys(enriched.attributes).length > 0 &&
773
+ !Object.keys(enriched.attributes).every((k) => k === "role");
774
+
775
+ if (!hasText && !hasMeaningfulAttrs) {
776
+ node = walker.nextNode();
777
+ continue;
778
+ }
779
+
780
+ if (!seenSelectors.has(enriched.selector)) {
781
+ seenSelectors.add(enriched.selector);
782
+ elements.push(enriched);
783
+ }
784
+ }
785
+ node = walker.nextNode();
786
+ }
787
+
788
+ const interactive: EnrichedPageElement[] = [];
789
+ const staticEls: EnrichedPageElement[] = [];
790
+ for (const el of elements) {
791
+ if (el.interactivity !== "static") interactive.push(el);
792
+ else staticEls.push(el);
793
+ }
794
+
795
+ return [...interactive, ...staticEls].slice(0, cfg.maxElements);
796
+ }
797
+
798
+ /**
799
+ * Collect enriched page context from the DOM.
800
+ *
801
+ * - **Default (structured):** walks up to `maxCandidates` nodes, scores with
802
+ * {@link defaultParseRules} (or `rules`), suppresses redundant descendants when a
803
+ * formatting rule matches, then keeps the top `maxElements` by score (DOM order tie-break).
804
+ * - **simple:** legacy path — stops once `maxElements` nodes are collected during traversal
805
+ * and sorts interactive before static.
806
+ *
807
+ * Pass `options: { mode: "simple" }` to disable rules. If `mode` is `simple` and `rules` is
808
+ * non-empty, rules are ignored and a console warning is emitted.
809
+ */
810
+ export function collectEnrichedPageContext(
811
+ options: DomContextOptions = {}
812
+ ): EnrichedPageElement[] {
813
+ const cfg = resolveDomContextConfig(options);
814
+ const rootEl = cfg.root ?? document.body;
815
+ if (!rootEl) return [];
816
+
817
+ if (cfg.mode === "simple") {
818
+ return collectSimple(cfg, rootEl);
819
+ }
820
+ return collectStructured(cfg, rootEl);
821
+ }
822
+
823
+ const TEXT_PREVIEW_LEN = 100;
824
+
825
+ /**
826
+ * Format enriched page elements as a structured string for LLM consumption.
827
+ * When `mode` is structured (default) and elements include {@link EnrichedPageElement.formattedSummary},
828
+ * those render under **Structured summaries** before the usual interactivity groups.
829
+ */
830
+ export function formatEnrichedContext(
831
+ elements: EnrichedPageElement[],
832
+ options: FormatEnrichedContextOptions = {}
833
+ ): string {
834
+ if (elements.length === 0) {
835
+ return "No page elements found.";
836
+ }
837
+
838
+ const mode: DomContextMode = options.mode ?? "structured";
839
+ const sections: string[] = [];
840
+
841
+ if (mode === "structured") {
842
+ const summaries = elements
843
+ .map((el) => el.formattedSummary)
844
+ .filter((s): s is string => !!s && s.length > 0);
845
+ if (summaries.length > 0) {
846
+ sections.push(
847
+ `Structured summaries:\n${summaries.map((s) => `- ${s.split("\n").join("\n ")}`).join("\n")}`
848
+ );
849
+ }
850
+ }
851
+
852
+ const groups: Record<string, EnrichedPageElement[]> = {
853
+ clickable: [],
854
+ navigable: [],
855
+ input: [],
856
+ static: [],
857
+ };
858
+
859
+ for (const el of elements) {
860
+ if (mode === "structured" && el.formattedSummary) continue;
861
+ groups[el.interactivity].push(el);
862
+ }
863
+
864
+ if (groups.clickable.length > 0) {
865
+ const lines = groups.clickable.map(
866
+ (el) =>
867
+ `- ${el.selector}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (clickable)`
868
+ );
869
+ sections.push(`Interactive elements:\n${lines.join("\n")}`);
870
+ }
871
+
872
+ if (groups.navigable.length > 0) {
873
+ const lines = groups.navigable.map(
874
+ (el) =>
875
+ `- ${el.selector}${el.attributes.href ? `[href="${el.attributes.href}"]` : ""}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (navigable)`
876
+ );
877
+ sections.push(`Navigation links:\n${lines.join("\n")}`);
878
+ }
879
+
880
+ if (groups.input.length > 0) {
881
+ const lines = groups.input.map(
882
+ (el) =>
883
+ `- ${el.selector}${el.attributes.type ? `[type="${el.attributes.type}"]` : ""}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}" (input)`
884
+ );
885
+ sections.push(`Form inputs:\n${lines.join("\n")}`);
886
+ }
887
+
888
+ if (groups.static.length > 0) {
889
+ const lines = groups.static.map(
890
+ (el) => `- ${el.selector}: "${el.text.substring(0, TEXT_PREVIEW_LEN)}"`
891
+ );
892
+ sections.push(`Content:\n${lines.join("\n")}`);
893
+ }
894
+
895
+ return sections.join("\n\n");
896
+ }