@geometra/mcp 1.18.1 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/session.js CHANGED
@@ -1,15 +1,34 @@
1
1
  import WebSocket from 'ws';
2
+ import { spawnGeometraProxy } from './proxy-spawn.js';
2
3
  let activeSession = null;
4
+ const ACTION_UPDATE_TIMEOUT_MS = 2000;
5
+ function shutdownPreviousSession() {
6
+ const prev = activeSession;
7
+ if (!prev)
8
+ return;
9
+ activeSession = null;
10
+ try {
11
+ prev.ws.close();
12
+ }
13
+ catch {
14
+ /* ignore */
15
+ }
16
+ if (prev.proxyChild) {
17
+ try {
18
+ prev.proxyChild.kill('SIGTERM');
19
+ }
20
+ catch {
21
+ /* ignore */
22
+ }
23
+ }
24
+ }
3
25
  /**
4
26
  * Connect to a running Geometra server. Waits for the first frame so that
5
27
  * layout/tree state is available immediately after connection.
6
28
  */
7
29
  export function connect(url) {
8
30
  return new Promise((resolve, reject) => {
9
- if (activeSession) {
10
- activeSession.ws.close();
11
- activeSession = null;
12
- }
31
+ shutdownPreviousSession();
13
32
  const ws = new WebSocket(url);
14
33
  const session = { ws, layout: null, tree: null, url };
15
34
  let resolved = false;
@@ -51,8 +70,17 @@ export function connect(url) {
51
70
  }
52
71
  });
53
72
  ws.on('close', () => {
54
- if (activeSession === session)
73
+ if (activeSession === session) {
55
74
  activeSession = null;
75
+ if (session.proxyChild) {
76
+ try {
77
+ session.proxyChild.kill('SIGTERM');
78
+ }
79
+ catch {
80
+ /* ignore */
81
+ }
82
+ }
83
+ }
56
84
  if (!resolved) {
57
85
  resolved = true;
58
86
  clearTimeout(timeout);
@@ -61,14 +89,39 @@ export function connect(url) {
61
89
  });
62
90
  });
63
91
  }
92
+ /**
93
+ * Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
94
+ * process to the session so disconnect / reconnect can clean it up.
95
+ */
96
+ export async function connectThroughProxy(options) {
97
+ const { child, wsUrl } = await spawnGeometraProxy({
98
+ pageUrl: options.pageUrl,
99
+ port: options.port ?? 0,
100
+ headless: options.headless,
101
+ width: options.width,
102
+ height: options.height,
103
+ slowMo: options.slowMo,
104
+ });
105
+ try {
106
+ const session = await connect(wsUrl);
107
+ session.proxyChild = child;
108
+ return session;
109
+ }
110
+ catch (e) {
111
+ try {
112
+ child.kill('SIGTERM');
113
+ }
114
+ catch {
115
+ /* ignore */
116
+ }
117
+ throw e;
118
+ }
119
+ }
64
120
  export function getSession() {
65
121
  return activeSession;
66
122
  }
67
123
  export function disconnect() {
68
- if (activeSession) {
69
- activeSession.ws.close();
70
- activeSession = null;
71
- }
124
+ shutdownPreviousSession();
72
125
  }
73
126
  /**
74
127
  * Send a click event at (x, y) and wait for the next frame/patch response.
@@ -220,6 +273,101 @@ const DIALOG_ROLES = new Set([
220
273
  'dialog',
221
274
  'alertdialog',
222
275
  ]);
276
+ const FIELD_LABEL_ROLES = new Set(['textbox', 'combobox', 'checkbox', 'radio']);
277
+ const CONTENT_NAME_ROLES = new Set(['heading', 'text']);
278
+ function encodePath(path) {
279
+ return path.length === 0 ? 'root' : path.map(part => part.toString(36)).join('.');
280
+ }
281
+ function decodePath(encoded) {
282
+ if (encoded === 'root')
283
+ return [];
284
+ const parts = encoded.split('.');
285
+ const out = [];
286
+ for (const part of parts) {
287
+ const value = Number.parseInt(part, 36);
288
+ if (!Number.isFinite(value) || value < 0)
289
+ return null;
290
+ out.push(value);
291
+ }
292
+ return out;
293
+ }
294
+ export function nodeIdForPath(path) {
295
+ return `n:${encodePath(path)}`;
296
+ }
297
+ function sectionPrefix(kind) {
298
+ if (kind === 'landmark')
299
+ return 'lm';
300
+ if (kind === 'form')
301
+ return 'fm';
302
+ if (kind === 'dialog')
303
+ return 'dg';
304
+ return 'ls';
305
+ }
306
+ function sectionIdForPath(kind, path) {
307
+ return `${sectionPrefix(kind)}:${encodePath(path)}`;
308
+ }
309
+ function parseSectionId(id) {
310
+ const [prefix, encoded] = id.split(':', 2);
311
+ if (!prefix || !encoded)
312
+ return null;
313
+ const path = decodePath(encoded);
314
+ if (!path)
315
+ return null;
316
+ if (prefix === 'lm')
317
+ return { kind: 'landmark', path };
318
+ if (prefix === 'fm')
319
+ return { kind: 'form', path };
320
+ if (prefix === 'dg')
321
+ return { kind: 'dialog', path };
322
+ if (prefix === 'ls')
323
+ return { kind: 'list', path };
324
+ return null;
325
+ }
326
+ function normalizeUiText(value) {
327
+ return value.replace(/\s+/g, ' ').replace(/\s*\u00a0\s*/g, ' ').trim();
328
+ }
329
+ function trimPunctuation(value) {
330
+ return value.replace(/[:*]+$/g, '').trim();
331
+ }
332
+ function sanitizeInlineName(value, max = 120) {
333
+ if (!value)
334
+ return undefined;
335
+ const normalized = normalizeUiText(value);
336
+ if (!normalized)
337
+ return undefined;
338
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}\u2026` : normalized;
339
+ }
340
+ function sanitizeFieldName(value, max = 80) {
341
+ const normalized = sanitizeInlineName(value, max + 8);
342
+ if (!normalized)
343
+ return undefined;
344
+ const trimmed = trimPunctuation(normalized);
345
+ if (!trimmed)
346
+ return undefined;
347
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1)}\u2026` : trimmed;
348
+ }
349
+ function looksNoisyContainerName(value) {
350
+ const starCount = (value.match(/\*/g) ?? []).length;
351
+ const labelMatches = value.match(/\b(first name|last name|email|phone|country|location|resume|linkedin|portfolio|website|city)\b/gi);
352
+ const tokenCount = value.split(/\s+/).filter(Boolean).length;
353
+ if (value.length > 90)
354
+ return true;
355
+ if (starCount >= 2)
356
+ return true;
357
+ if ((labelMatches?.length ?? 0) >= 3)
358
+ return true;
359
+ if (tokenCount >= 12)
360
+ return true;
361
+ return false;
362
+ }
363
+ function sanitizeContainerName(value, max = 80) {
364
+ const normalized = sanitizeInlineName(value, max + 24);
365
+ if (!normalized)
366
+ return undefined;
367
+ if (looksNoisyContainerName(normalized))
368
+ return undefined;
369
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}\u2026` : normalized;
370
+ }
223
371
  function intersectsViewport(b, vw, vh) {
224
372
  return (b.width > 0 &&
225
373
  b.height > 0 &&
@@ -248,8 +396,9 @@ export function buildCompactUiIndex(root, options) {
248
396
  const acc = [];
249
397
  function walk(n) {
250
398
  if (includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh)) {
251
- const name = n.name && n.name.length > 240 ? `${n.name.slice(0, 239)}\u2026` : n.name;
399
+ const name = sanitizeInlineName(n.name, 240);
252
400
  acc.push({
401
+ id: nodeIdForPath(n.path),
253
402
  role: n.role,
254
403
  ...(name ? { name } : {}),
255
404
  ...(n.state && Object.keys(n.state).length > 0 ? { state: n.state } : {}),
@@ -281,7 +430,7 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
281
430
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
282
431
  const foc = n.focusable ? ' *' : '';
283
432
  const b = n.bounds;
284
- lines.push(`${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height}) path=${JSON.stringify(n.path)}${st}${foc}`);
433
+ lines.push(`${n.id} ${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
285
434
  }
286
435
  if (nodes.length > maxLines) {
287
436
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -336,51 +485,138 @@ function firstNamedDescendant(node, allowedRoles) {
336
485
  }
337
486
  return undefined;
338
487
  }
339
- function containerName(node) {
340
- return node.name ?? firstNamedDescendant(node, new Set(['heading', 'text']));
488
+ function findNodeByPath(root, path) {
489
+ let current = root;
490
+ for (const index of path) {
491
+ if (!current.children[index])
492
+ return null;
493
+ current = current.children[index];
494
+ }
495
+ return current;
496
+ }
497
+ function countFocusableNodes(root) {
498
+ let count = 0;
499
+ function walk(node) {
500
+ if (node.focusable)
501
+ count++;
502
+ for (const child of node.children)
503
+ walk(child);
504
+ }
505
+ walk(root);
506
+ return count;
507
+ }
508
+ function dedupeStrings(values, max) {
509
+ const seen = new Set();
510
+ const out = [];
511
+ for (const value of values) {
512
+ if (!value || seen.has(value))
513
+ continue;
514
+ seen.add(value);
515
+ out.push(value);
516
+ if (out.length >= max)
517
+ break;
518
+ }
519
+ return out;
520
+ }
521
+ function fieldLabel(node) {
522
+ return sanitizeFieldName(node.name, 80);
523
+ }
524
+ function contentPreviewName(node) {
525
+ if (node.role === 'heading')
526
+ return sanitizeInlineName(node.name, 80);
527
+ if (node.role === 'text')
528
+ return sanitizeInlineName(node.name, 80);
529
+ if (node.role === 'link' || node.role === 'button')
530
+ return sanitizeInlineName(node.name, 80);
531
+ return undefined;
532
+ }
533
+ function sectionDisplayName(node, kind) {
534
+ const headingName = sanitizeInlineName(firstNamedDescendant(node, new Set(['heading'])), 80);
535
+ if (headingName)
536
+ return headingName;
537
+ if (kind === 'list') {
538
+ return sanitizeContainerName(node.name, 80)
539
+ ?? sanitizeInlineName(firstNamedDescendant(node, new Set(['text', 'link', 'button'])), 80);
540
+ }
541
+ if (kind === 'landmark') {
542
+ return sanitizeContainerName(node.name, 80)
543
+ ?? sanitizeInlineName(firstNamedDescendant(node, CONTENT_NAME_ROLES), 80);
544
+ }
545
+ return sanitizeContainerName(node.name, 80);
341
546
  }
342
547
  function listItemName(node) {
343
- return node.name ?? firstNamedDescendant(node, new Set(['heading', 'text', 'link', 'button']));
548
+ return sanitizeInlineName(node.name ?? firstNamedDescendant(node, new Set(['heading', 'text', 'link', 'button'])), 80);
344
549
  }
345
- function truncateForModel(value, max = 120) {
346
- if (!value)
347
- return undefined;
348
- return value.length > max ? `${value.slice(0, max - 1)}\u2026` : value;
550
+ function textPreview(node, maxItems) {
551
+ const texts = collectDescendants(node, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
552
+ !!sanitizeInlineName(candidate.name, 90));
553
+ return dedupeStrings(texts.map(candidate => contentPreviewName(candidate)), maxItems);
349
554
  }
350
- function toFieldModel(node) {
555
+ function primaryAction(node) {
351
556
  return {
557
+ id: nodeIdForPath(node.path),
352
558
  role: node.role,
353
- ...(truncateForModel(node.name, 160) ? { name: truncateForModel(node.name, 160) } : {}),
559
+ ...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
354
560
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
355
561
  bounds: cloneBounds(node.bounds),
356
- path: clonePath(node.path),
357
562
  };
358
563
  }
359
- function toActionModel(node) {
564
+ function toFieldModel(node, includeBounds = true) {
360
565
  return {
566
+ id: nodeIdForPath(node.path),
361
567
  role: node.role,
362
- ...(truncateForModel(node.name, 160) ? { name: truncateForModel(node.name, 160) } : {}),
568
+ ...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
363
569
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
364
- bounds: cloneBounds(node.bounds),
365
- path: clonePath(node.path),
570
+ ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
571
+ };
572
+ }
573
+ function toActionModel(node, includeBounds = true) {
574
+ return {
575
+ id: nodeIdForPath(node.path),
576
+ role: node.role,
577
+ ...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
578
+ ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
579
+ ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
366
580
  };
367
581
  }
368
582
  function toLandmarkModel(node) {
583
+ const name = sectionDisplayName(node, 'landmark');
369
584
  return {
585
+ id: sectionIdForPath('landmark', node.path),
370
586
  role: node.role,
371
- ...(truncateForModel(containerName(node), 120) ? { name: truncateForModel(containerName(node), 120) } : {}),
587
+ ...(name ? { name } : {}),
372
588
  bounds: cloneBounds(node.bounds),
373
- path: clonePath(node.path),
374
589
  };
375
590
  }
591
+ function inferPageArchetypes(model) {
592
+ const out = new Set();
593
+ const landmarkRoles = new Set(model.landmarks.map(landmark => landmark.role));
594
+ if (landmarkRoles.has('navigation') && landmarkRoles.has('main'))
595
+ out.add('shell');
596
+ if (model.summary.formCount > 0)
597
+ out.add('form');
598
+ if (model.summary.dialogCount > 0)
599
+ out.add('dialog');
600
+ if (model.summary.listCount > 0)
601
+ out.add('results');
602
+ if (model.summary.focusableCount >= 14 && model.summary.listCount >= 2 && model.summary.formCount === 0) {
603
+ out.add('dashboard');
604
+ }
605
+ if (model.summary.formCount === 0 &&
606
+ model.summary.dialogCount === 0 &&
607
+ model.summary.listCount <= 1 &&
608
+ model.summary.focusableCount <= 8) {
609
+ out.add('content');
610
+ }
611
+ return [...out];
612
+ }
376
613
  /**
377
- * Build a compact, webpage-shaped model from the accessibility tree:
378
- * landmarks, dialogs, forms, and lists with short previews.
614
+ * Build a summary-first, stable-ID webpage model from the accessibility tree.
615
+ * Use {@link expandPageSection} to fetch details for a specific section on demand.
379
616
  */
380
617
  export function buildPageModel(root, options) {
381
- const maxFieldsPerForm = options?.maxFieldsPerForm ?? 12;
382
- const maxActionsPerContainer = options?.maxActionsPerContainer ?? 8;
383
- const maxItemsPerList = options?.maxItemsPerList ?? 5;
618
+ const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
619
+ const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
384
620
  const landmarks = [];
385
621
  const forms = [];
386
622
  const dialogs = [];
@@ -390,86 +626,192 @@ export function buildPageModel(root, options) {
390
626
  landmarks.push(toLandmarkModel(node));
391
627
  }
392
628
  if (node.role === 'form') {
393
- const fields = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
394
- const actions = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
395
- const name = truncateForModel(containerName(node), 120);
629
+ const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
630
+ const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
631
+ const name = sectionDisplayName(node, 'form');
396
632
  forms.push({
633
+ id: sectionIdForPath('form', node.path),
634
+ role: node.role,
397
635
  ...(name ? { name } : {}),
398
636
  bounds: cloneBounds(node.bounds),
399
- path: clonePath(node.path),
400
637
  fieldCount: fields.length,
401
638
  actionCount: actions.length,
402
- fields: fields.slice(0, maxFieldsPerForm).map(toFieldModel),
403
- actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
404
639
  });
405
640
  }
406
641
  if (DIALOG_ROLES.has(node.role)) {
407
- const actions = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
408
- const name = truncateForModel(containerName(node), 120);
642
+ const fields = collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role));
643
+ const actions = collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable);
644
+ const name = sectionDisplayName(node, 'dialog');
409
645
  dialogs.push({
646
+ id: sectionIdForPath('dialog', node.path),
647
+ role: node.role,
410
648
  ...(name ? { name } : {}),
411
649
  bounds: cloneBounds(node.bounds),
412
- path: clonePath(node.path),
650
+ fieldCount: fields.length,
413
651
  actionCount: actions.length,
414
- actions: actions.slice(0, maxActionsPerContainer).map(toActionModel),
415
652
  });
416
653
  }
417
654
  if (node.role === 'list') {
418
- const items = sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'));
419
- const preview = items
420
- .map(item => truncateForModel(listItemName(item), 80))
421
- .filter((value) => !!value)
422
- .slice(0, maxItemsPerList);
423
- const name = truncateForModel(containerName(node), 120);
655
+ const items = collectDescendants(node, candidate => candidate.role === 'listitem');
656
+ const name = sectionDisplayName(node, 'list');
424
657
  lists.push({
658
+ id: sectionIdForPath('list', node.path),
659
+ role: node.role,
425
660
  ...(name ? { name } : {}),
426
661
  bounds: cloneBounds(node.bounds),
427
- path: clonePath(node.path),
428
662
  itemCount: items.length,
429
- itemsPreview: preview,
430
663
  });
431
664
  }
432
665
  for (const child of node.children)
433
666
  walk(child);
434
667
  }
435
668
  walk(root);
436
- return {
669
+ const compact = buildCompactUiIndex(root, { maxNodes: 200 });
670
+ const primaryActions = compact.nodes
671
+ .filter(node => node.focusable && ACTION_ROLES.has(node.role))
672
+ .slice(0, maxPrimaryActions)
673
+ .map(node => primaryAction({
674
+ role: node.role,
675
+ name: node.name,
676
+ state: node.state,
677
+ bounds: node.bounds,
678
+ path: node.path,
679
+ children: [],
680
+ focusable: node.focusable,
681
+ }));
682
+ const baseModel = {
437
683
  viewport: {
438
684
  width: root.bounds.width,
439
685
  height: root.bounds.height,
440
686
  },
441
- landmarks: sortByBounds(landmarks),
442
- forms: sortByBounds(forms),
443
- dialogs: sortByBounds(dialogs),
444
- lists: sortByBounds(lists),
687
+ summary: {
688
+ landmarkCount: landmarks.length,
689
+ formCount: forms.length,
690
+ dialogCount: dialogs.length,
691
+ listCount: lists.length,
692
+ focusableCount: countFocusableNodes(root),
693
+ },
694
+ primaryActions,
695
+ landmarks: sortByBounds(landmarks).slice(0, maxSectionsPerKind),
696
+ forms: sortByBounds(forms).slice(0, maxSectionsPerKind),
697
+ dialogs: sortByBounds(dialogs).slice(0, maxSectionsPerKind),
698
+ lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
699
+ };
700
+ return {
701
+ ...baseModel,
702
+ archetypes: inferPageArchetypes(baseModel),
703
+ };
704
+ }
705
+ function headingModels(node, maxHeadings, includeBounds) {
706
+ const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
707
+ return headings.slice(0, maxHeadings).map(heading => ({
708
+ id: nodeIdForPath(heading.path),
709
+ name: sanitizeInlineName(heading.name, 80),
710
+ ...(includeBounds ? { bounds: cloneBounds(heading.bounds) } : {}),
711
+ }));
712
+ }
713
+ function nestedListSummaries(node, maxLists, selfPath) {
714
+ const nestedLists = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(selfPath)));
715
+ return nestedLists.slice(0, maxLists).map(list => ({
716
+ id: sectionIdForPath('list', list.path),
717
+ role: list.role,
718
+ ...(sectionDisplayName(list, 'list') ? { name: sectionDisplayName(list, 'list') } : {}),
719
+ bounds: cloneBounds(list.bounds),
720
+ itemCount: collectDescendants(list, candidate => candidate.role === 'listitem').length,
721
+ }));
722
+ }
723
+ function sectionKindForNode(node) {
724
+ if (node.role === 'form')
725
+ return 'form';
726
+ if (DIALOG_ROLES.has(node.role))
727
+ return 'dialog';
728
+ if (node.role === 'list')
729
+ return 'list';
730
+ if (LANDMARK_ROLES.has(node.role))
731
+ return 'landmark';
732
+ return null;
733
+ }
734
+ /**
735
+ * Expand a page-model section by stable ID into richer, on-demand details.
736
+ */
737
+ export function expandPageSection(root, id, options) {
738
+ const parsed = parseSectionId(id);
739
+ if (!parsed)
740
+ return null;
741
+ const node = findNodeByPath(root, parsed.path);
742
+ if (!node)
743
+ return null;
744
+ const actualKind = sectionKindForNode(node);
745
+ if (actualKind !== parsed.kind)
746
+ return null;
747
+ const maxHeadings = options?.maxHeadings ?? 6;
748
+ const maxFields = options?.maxFields ?? 18;
749
+ const maxActions = options?.maxActions ?? 12;
750
+ const maxLists = options?.maxLists ?? 8;
751
+ const maxItems = options?.maxItems ?? 20;
752
+ const maxTextPreview = options?.maxTextPreview ?? 6;
753
+ const includeBounds = options?.includeBounds ?? false;
754
+ const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
755
+ const fieldsAll = sortByBounds(collectDescendants(node, candidate => FORM_FIELD_ROLES.has(candidate.role)));
756
+ const actionsAll = sortByBounds(collectDescendants(node, candidate => ACTION_ROLES.has(candidate.role) && candidate.focusable));
757
+ const nestedListsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'list' && pathKey(candidate.path) !== pathKey(node.path)));
758
+ const itemsAll = actualKind === 'list'
759
+ ? sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'))
760
+ : [];
761
+ const name = sectionDisplayName(node, actualKind);
762
+ return {
763
+ id: sectionIdForPath(actualKind, node.path),
764
+ kind: actualKind,
765
+ role: node.role,
766
+ ...(name ? { name } : {}),
767
+ bounds: cloneBounds(node.bounds),
768
+ summary: {
769
+ headingCount: headingsAll.length,
770
+ fieldCount: fieldsAll.length,
771
+ actionCount: actionsAll.length,
772
+ listCount: nestedListsAll.length,
773
+ itemCount: itemsAll.length,
774
+ },
775
+ headings: headingModels(node, maxHeadings, includeBounds),
776
+ fields: fieldsAll.slice(0, maxFields).map(field => toFieldModel(field, includeBounds)),
777
+ actions: actionsAll.slice(0, maxActions).map(action => toActionModel(action, includeBounds)),
778
+ lists: nestedListSummaries(node, maxLists, node.path),
779
+ items: itemsAll.slice(0, maxItems).map(item => ({
780
+ id: nodeIdForPath(item.path),
781
+ ...(listItemName(item) ? { name: listItemName(item) } : {}),
782
+ ...(includeBounds ? { bounds: cloneBounds(item.bounds) } : {}),
783
+ })),
784
+ textPreview: actualKind === 'form' ? [] : textPreview(node, maxTextPreview),
445
785
  };
446
786
  }
447
787
  export function summarizePageModel(model, maxLines = 10) {
448
788
  const lines = [];
449
- if (model.landmarks.length > 0) {
450
- const landmarks = model.landmarks
451
- .slice(0, 5)
452
- .map(landmark => landmark.name ? `${landmark.role} "${truncateUiText(landmark.name, 36)}"` : landmark.role)
453
- .join(', ');
454
- lines.push(`landmarks: ${landmarks}`);
789
+ if (model.archetypes.length > 0) {
790
+ lines.push(`archetypes: ${model.archetypes.join(', ')}`);
791
+ }
792
+ lines.push(`summary: ${model.summary.landmarkCount} landmarks, ${model.summary.formCount} forms, ${model.summary.dialogCount} dialogs, ${model.summary.listCount} lists, ${model.summary.focusableCount} focusable`);
793
+ for (const landmark of model.landmarks.slice(0, 3)) {
794
+ const name = landmark.name ? ` "${truncateUiText(landmark.name, 32)}"` : '';
795
+ lines.push(`${landmark.id} ${landmark.role}${name}`);
455
796
  }
456
797
  for (const form of model.forms.slice(0, 3)) {
457
798
  const name = form.name ? ` "${truncateUiText(form.name, 40)}"` : '';
458
- lines.push(`form${name}: ${form.fieldCount} fields, ${form.actionCount} actions`);
799
+ lines.push(`${form.id} form${name}: ${form.fieldCount} fields, ${form.actionCount} actions`);
459
800
  }
460
801
  for (const dialog of model.dialogs.slice(0, 2)) {
461
802
  const name = dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : '';
462
- lines.push(`dialog${name}: ${dialog.actionCount} actions`);
803
+ lines.push(`${dialog.id} dialog${name}: ${dialog.fieldCount} fields, ${dialog.actionCount} actions`);
463
804
  }
464
805
  for (const list of model.lists.slice(0, 3)) {
465
806
  const name = list.name ? ` "${truncateUiText(list.name, 40)}"` : '';
466
- const preview = list.itemsPreview.length > 0
467
- ? ` [${list.itemsPreview.map(item => `"${truncateUiText(item, 24)}"`).join(', ')}]`
468
- : '';
469
- lines.push(`list${name}: ${list.itemCount} items${preview}`);
807
+ lines.push(`${list.id} list${name}: ${list.itemCount} items`);
470
808
  }
471
- if (lines.length === 0) {
472
- return `viewport ${model.viewport.width}x${model.viewport.height}; no common page structures detected`;
809
+ if (model.primaryActions.length > 0) {
810
+ const actions = model.primaryActions
811
+ .slice(0, 4)
812
+ .map(action => action.name ? `${action.id} "${truncateUiText(action.name, 24)}"` : action.id)
813
+ .join(', ');
814
+ lines.push(`primary actions: ${actions}`);
473
815
  }
474
816
  return lines.slice(0, maxLines).join('\n');
475
817
  }
@@ -478,8 +820,8 @@ function pathKey(path) {
478
820
  }
479
821
  function compactNodeLabel(node) {
480
822
  if (node.name)
481
- return `${node.role} "${truncateUiText(node.name, 40)}"`;
482
- return `${node.role} @ ${JSON.stringify(node.path)}`;
823
+ return `${node.id} ${node.role} "${truncateUiText(node.name, 40)}"`;
824
+ return `${node.id} ${node.role}`;
483
825
  }
484
826
  function formatStateValue(value) {
485
827
  return value === undefined ? 'unset' : String(value);
@@ -516,8 +858,8 @@ export function buildUiDelta(before, after, options) {
516
858
  const maxNodes = options?.maxNodes ?? 250;
517
859
  const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
518
860
  const afterCompact = buildCompactUiIndex(after, { maxNodes }).nodes;
519
- const beforeMap = new Map(beforeCompact.map(node => [pathKey(node.path), node]));
520
- const afterMap = new Map(afterCompact.map(node => [pathKey(node.path), node]));
861
+ const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
862
+ const afterMap = new Map(afterCompact.map(node => [node.id, node]));
521
863
  const added = [];
522
864
  const removed = [];
523
865
  const updated = [];
@@ -537,31 +879,31 @@ export function buildUiDelta(before, after, options) {
537
879
  }
538
880
  const beforePage = buildPageModel(before);
539
881
  const afterPage = buildPageModel(after);
540
- const beforeDialogs = new Map(beforePage.dialogs.map(dialog => [pageContainerKey(dialog), dialog]));
541
- const afterDialogs = new Map(afterPage.dialogs.map(dialog => [pageContainerKey(dialog), dialog]));
882
+ const beforeDialogs = new Map(beforePage.dialogs.map(dialog => [dialog.id, dialog]));
883
+ const afterDialogs = new Map(afterPage.dialogs.map(dialog => [dialog.id, dialog]));
542
884
  const dialogsOpened = [...afterDialogs.entries()]
543
885
  .filter(([key]) => !beforeDialogs.has(key))
544
886
  .map(([, value]) => value);
545
887
  const dialogsClosed = [...beforeDialogs.entries()]
546
888
  .filter(([key]) => !afterDialogs.has(key))
547
889
  .map(([, value]) => value);
548
- const beforeForms = new Map(beforePage.forms.map(form => [pageContainerKey(form), form]));
549
- const afterForms = new Map(afterPage.forms.map(form => [pageContainerKey(form), form]));
890
+ const beforeForms = new Map(beforePage.forms.map(form => [form.id, form]));
891
+ const afterForms = new Map(afterPage.forms.map(form => [form.id, form]));
550
892
  const formsAppeared = [...afterForms.entries()]
551
893
  .filter(([key]) => !beforeForms.has(key))
552
894
  .map(([, value]) => value);
553
895
  const formsRemoved = [...beforeForms.entries()]
554
896
  .filter(([key]) => !afterForms.has(key))
555
897
  .map(([, value]) => value);
556
- const beforeLists = new Map(beforePage.lists.map(list => [pathKey(list.path), list]));
557
- const afterLists = new Map(afterPage.lists.map(list => [pathKey(list.path), list]));
898
+ const beforeLists = new Map(beforePage.lists.map(list => [list.id, list]));
899
+ const afterLists = new Map(afterPage.lists.map(list => [list.id, list]));
558
900
  const listCountsChanged = [];
559
901
  for (const [key, afterList] of afterLists) {
560
902
  const beforeList = beforeLists.get(key);
561
903
  if (beforeList && beforeList.itemCount !== afterList.itemCount) {
562
904
  listCountsChanged.push({
905
+ id: afterList.id,
563
906
  ...(afterList.name ? { name: afterList.name } : {}),
564
- path: clonePath(afterList.path),
565
907
  beforeCount: beforeList.itemCount,
566
908
  afterCount: afterList.itemCount,
567
909
  });
@@ -591,19 +933,19 @@ export function hasUiDelta(delta) {
591
933
  export function summarizeUiDelta(delta, maxLines = 14) {
592
934
  const lines = [];
593
935
  for (const dialog of delta.dialogsOpened.slice(0, 2)) {
594
- lines.push(`+ dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
936
+ lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
595
937
  }
596
938
  for (const dialog of delta.dialogsClosed.slice(0, 2)) {
597
- lines.push(`- dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} closed`);
939
+ lines.push(`- ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} closed`);
598
940
  }
599
941
  for (const form of delta.formsAppeared.slice(0, 2)) {
600
- lines.push(`+ form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} appeared (${form.fieldCount} fields)`);
942
+ lines.push(`+ ${form.id} form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} appeared (${form.fieldCount} fields)`);
601
943
  }
602
944
  for (const form of delta.formsRemoved.slice(0, 2)) {
603
- lines.push(`- form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} removed`);
945
+ lines.push(`- ${form.id} form${form.name ? ` "${truncateUiText(form.name, 40)}"` : ''} removed`);
604
946
  }
605
947
  for (const list of delta.listCountsChanged.slice(0, 3)) {
606
- lines.push(`~ list${list.name ? ` "${truncateUiText(list.name, 40)}"` : ''} items ${list.beforeCount} -> ${list.afterCount}`);
948
+ lines.push(`~ ${list.id} list${list.name ? ` "${truncateUiText(list.name, 40)}"` : ''} items ${list.beforeCount} -> ${list.afterCount}`);
607
949
  }
608
950
  for (const update of delta.updated.slice(0, 5)) {
609
951
  lines.push(`~ ${compactNodeLabel(update.after)}: ${update.changes.join('; ')}`);
@@ -756,18 +1098,21 @@ function waitForNextUpdate(session) {
756
1098
  session.layout = msg.layout;
757
1099
  session.tree = msg.tree;
758
1100
  cleanup();
759
- resolve();
1101
+ resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
760
1102
  }
761
1103
  else if (msg.type === 'patch' && session.layout) {
762
1104
  applyPatches(session.layout, msg.patches);
763
1105
  cleanup();
764
- resolve();
1106
+ resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
765
1107
  }
766
1108
  }
767
1109
  catch { /* ignore */ }
768
1110
  };
769
- // Resolve after timeout even if no update comes (action may not change layout)
770
- const timeout = setTimeout(() => { cleanup(); resolve(); }, 2000);
1111
+ // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1112
+ const timeout = setTimeout(() => {
1113
+ cleanup();
1114
+ resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1115
+ }, ACTION_UPDATE_TIMEOUT_MS);
771
1116
  function cleanup() {
772
1117
  clearTimeout(timeout);
773
1118
  session.ws.off('message', onMessage);