@geometra/mcp 1.19.14 → 1.19.16

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/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { z } from 'zod';
3
4
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
@@ -15,6 +16,20 @@ function detailInput() {
15
16
  .default('minimal')
16
17
  .describe('`minimal` (default) returns terse action summaries. Use `verbose` for a fuller current-UI fallback.');
17
18
  }
19
+ function formSchemaFormatInput() {
20
+ return z
21
+ .enum(['compact', 'packed'])
22
+ .optional()
23
+ .default('compact')
24
+ .describe('`compact` (default) returns readable JSON fields. Use `packed` for the smallest schema payload with short keys.');
25
+ }
26
+ function formSchemaContextInput() {
27
+ return z
28
+ .enum(['auto', 'always', 'none'])
29
+ .optional()
30
+ .default('auto')
31
+ .describe('How much disambiguation context to include in form schema rows. `auto` keeps context only when it helps.');
32
+ }
18
33
  function nodeFilterShape() {
19
34
  return {
20
35
  id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
@@ -37,6 +52,7 @@ const timeoutMsInput = z.number().int().min(50).max(60_000).optional();
37
52
  const fillFieldSchema = z.discriminatedUnion('kind', [
38
53
  z.object({
39
54
  kind: z.literal('text'),
55
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
40
56
  fieldLabel: z.string().describe('Visible field label / accessible name'),
41
57
  value: z.string().describe('Text value to set'),
42
58
  exact: z.boolean().optional().describe('Exact label match'),
@@ -44,14 +60,20 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
44
60
  }),
45
61
  z.object({
46
62
  kind: z.literal('choice'),
63
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
47
64
  fieldLabel: z.string().describe('Visible field label / accessible name'),
48
65
  value: z.string().describe('Desired option value / answer label'),
49
66
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
67
+ choiceType: z
68
+ .enum(['select', 'group', 'listbox'])
69
+ .optional()
70
+ .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
50
71
  exact: z.boolean().optional().describe('Exact label match'),
51
72
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
52
73
  }),
53
74
  z.object({
54
75
  kind: z.literal('toggle'),
76
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
55
77
  label: z.string().describe('Visible checkbox/radio label to set'),
56
78
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
57
79
  exact: z.boolean().optional().describe('Exact label match'),
@@ -60,6 +82,7 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
60
82
  }),
61
83
  z.object({
62
84
  kind: z.literal('file'),
85
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
63
86
  fieldLabel: z.string().describe('Visible file-field label / accessible name'),
64
87
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
65
88
  exact: z.boolean().optional().describe('Exact label match'),
@@ -190,12 +213,35 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
190
213
  .nonnegative()
191
214
  .optional()
192
215
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
216
+ returnForms: z
217
+ .boolean()
218
+ .optional()
219
+ .default(false)
220
+ .describe('Include compact form schema discovery in the connect response so form flows can start in one turn.'),
221
+ formId: z.string().optional().describe('Optional form id filter when returnForms=true'),
222
+ maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form when returnForms=true'),
223
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
224
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
225
+ includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
226
+ includeContext: formSchemaContextInput(),
227
+ sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
228
+ schemaFormat: formSchemaFormatInput(),
193
229
  detail: detailInput(),
194
230
  }, async (input) => {
195
231
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
196
232
  if (!normalized.ok)
197
233
  return err(normalized.error);
198
234
  const target = normalized.value;
235
+ const formSchema = {
236
+ formId: input.formId,
237
+ maxFields: input.maxFields,
238
+ onlyRequiredFields: input.onlyRequiredFields,
239
+ onlyInvalidFields: input.onlyInvalidFields,
240
+ includeOptions: input.includeOptions,
241
+ includeContext: input.includeContext,
242
+ sinceSchemaId: input.sinceSchemaId,
243
+ format: input.schemaFormat,
244
+ };
199
245
  try {
200
246
  if (target.kind === 'proxy') {
201
247
  const session = await connectThroughProxy({
@@ -206,22 +252,32 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
206
252
  height: input.height,
207
253
  slowMo: input.slowMo,
208
254
  });
209
- return ok(JSON.stringify(connectPayload(session, {
255
+ if (input.returnForms) {
256
+ await stabilizeInlineFormSchemas(session, formSchema);
257
+ }
258
+ return ok(JSON.stringify(connectResponsePayload(session, {
210
259
  transport: 'proxy',
211
260
  requestedPageUrl: target.pageUrl,
212
261
  autoCoercedFromUrl: target.autoCoercedFromUrl,
213
262
  detail: input.detail,
263
+ returnForms: input.returnForms,
264
+ formSchema,
214
265
  }), null, input.detail === 'verbose' ? 2 : undefined));
215
266
  }
216
267
  const session = await connect(target.wsUrl, {
217
268
  width: input.width,
218
269
  height: input.height,
219
270
  });
220
- return ok(JSON.stringify(connectPayload(session, {
271
+ if (input.returnForms) {
272
+ await stabilizeInlineFormSchemas(session, formSchema);
273
+ }
274
+ return ok(JSON.stringify(connectResponsePayload(session, {
221
275
  transport: 'ws',
222
276
  requestedWsUrl: target.wsUrl,
223
277
  autoCoercedFromUrl: false,
224
278
  detail: input.detail,
279
+ returnForms: input.returnForms,
280
+ formSchema,
225
281
  }), null, input.detail === 'verbose' ? 2 : undefined));
226
282
  }
227
283
  catch (e) {
@@ -235,7 +291,9 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
235
291
  const session = getSession();
236
292
  if (!session?.tree || !session?.layout)
237
293
  return err('Not connected. Call geometra_connect first.');
238
- const a11y = buildA11yTree(session.tree, session.layout);
294
+ const a11y = sessionA11y(session);
295
+ if (!a11y)
296
+ return err('No UI tree available');
239
297
  const filter = {
240
298
  id,
241
299
  role,
@@ -299,7 +357,9 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
299
357
  const matchesCondition = () => {
300
358
  if (!session.tree || !session.layout)
301
359
  return false;
302
- const a11y = buildA11yTree(session.tree, session.layout);
360
+ const a11y = sessionA11y(session);
361
+ if (!a11y)
362
+ return false;
303
363
  const matches = findNodes(a11y, filter);
304
364
  return present ? matches.length > 0 : matches.length === 0;
305
365
  };
@@ -379,7 +439,14 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
379
439
  });
380
440
  server.tool('geometra_fill_form', `Fill a form from a compact values object instead of expanding sections first. This is the lowest-token happy path for standard application flows.
381
441
 
382
- Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result.`, {
442
+ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result. If you pass \`pageUrl\` or \`url\`, MCP will connect first so known-form fills can run in a single tool call.`, {
443
+ url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before filling.'),
444
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before filling. Prefer this over url for browser pages.'),
445
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
446
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
447
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
448
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
449
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
383
450
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
384
451
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
385
452
  valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
@@ -395,18 +462,80 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
395
462
  .default(false)
396
463
  .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
397
464
  detail: detailInput(),
398
- }, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
399
- const session = getSession();
400
- if (!session)
401
- return err('Not connected. Call geometra_connect first.');
465
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
466
+ const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
467
+ ? directLabelBatchFields(valuesByLabel)
468
+ : null;
469
+ const resolved = await ensureToolSession({
470
+ url,
471
+ pageUrl,
472
+ port,
473
+ headless,
474
+ width,
475
+ height,
476
+ slowMo,
477
+ awaitInitialFrame: directFields ? false : undefined,
478
+ }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
479
+ if (!resolved.ok)
480
+ return err(resolved.error);
481
+ const session = resolved.session;
482
+ const connection = autoConnectionPayload(resolved);
402
483
  const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
403
484
  if (entryCount === 0) {
404
485
  return err('Provide at least one value in valuesById or valuesByLabel');
405
486
  }
487
+ if (directFields) {
488
+ try {
489
+ const startRevision = session.updateRevision;
490
+ const wait = await sendFillFields(session, directFields);
491
+ const ackResult = parseProxyFillAckResult(wait.result);
492
+ if (ackResult && ackResult.invalidCount === 0) {
493
+ return ok(JSON.stringify({
494
+ ...connection,
495
+ completed: true,
496
+ execution: 'batched-direct',
497
+ finalSource: 'proxy',
498
+ requestedValueCount: entryCount,
499
+ fieldCount: directFields.length,
500
+ successCount: directFields.length,
501
+ errorCount: 0,
502
+ final: ackResult,
503
+ }, null, detail === 'verbose' ? 2 : undefined));
504
+ }
505
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
506
+ const afterDirect = sessionA11y(session);
507
+ const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
508
+ if (directSignals && directSignals.invalidFields.length === 0) {
509
+ return ok(JSON.stringify({
510
+ ...connection,
511
+ completed: true,
512
+ execution: 'batched-direct',
513
+ finalSource: 'session',
514
+ requestedValueCount: entryCount,
515
+ fieldCount: directFields.length,
516
+ successCount: directFields.length,
517
+ errorCount: 0,
518
+ final: sessionSignalsPayload(directSignals, detail),
519
+ }, null, detail === 'verbose' ? 2 : undefined));
520
+ }
521
+ }
522
+ catch (e) {
523
+ if (!canFallbackToSequentialFill(e)) {
524
+ const message = e instanceof Error ? e.message : String(e);
525
+ return err(message);
526
+ }
527
+ }
528
+ }
529
+ if (!session.tree || !session.layout) {
530
+ await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
531
+ }
406
532
  const afterConnect = sessionA11y(session);
407
533
  if (!afterConnect)
408
534
  return err('No UI tree available for form filling');
409
- const schemas = buildFormSchemas(afterConnect);
535
+ const schemas = getSessionFormSchemas(session, {
536
+ includeOptions: true,
537
+ includeContext: 'auto',
538
+ });
410
539
  if (schemas.length === 0)
411
540
  return err('No forms found in the current UI');
412
541
  const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
@@ -418,13 +547,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
418
547
  return err(planned.error);
419
548
  if (!includeSteps) {
420
549
  let usedBatch = false;
550
+ let batchAckResult;
421
551
  try {
422
552
  const startRevision = session.updateRevision;
423
553
  const wait = await sendFillFields(session, planned.fields);
424
554
  const ackResult = parseProxyFillAckResult(wait.result);
555
+ batchAckResult = ackResult;
425
556
  if (ackResult && ackResult.invalidCount === 0) {
426
557
  usedBatch = true;
427
558
  const payload = {
559
+ ...connection,
428
560
  completed: true,
429
561
  execution: 'batched',
430
562
  finalSource: 'proxy',
@@ -447,11 +579,20 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
447
579
  return err(message);
448
580
  }
449
581
  }
582
+ if (usedBatch) {
583
+ const after = sessionA11y(session);
584
+ const signals = after ? collectSessionSignals(after) : undefined;
585
+ const invalidRemaining = signals?.invalidFields.length ?? 0;
586
+ if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
587
+ usedBatch = false;
588
+ }
589
+ }
450
590
  if (usedBatch) {
451
591
  const after = sessionA11y(session);
452
592
  const signals = after ? collectSessionSignals(after) : undefined;
453
593
  const invalidRemaining = signals?.invalidFields.length ?? 0;
454
594
  const payload = {
595
+ ...connection,
455
596
  completed: true,
456
597
  execution: 'batched',
457
598
  finalSource: 'session',
@@ -493,6 +634,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
493
634
  const successCount = steps.filter(step => step.ok === true).length;
494
635
  const errorCount = steps.length - successCount;
495
636
  const payload = {
637
+ ...connection,
496
638
  completed: stoppedAt === undefined && steps.length === planned.fields.length,
497
639
  execution: 'sequential',
498
640
  formId: schema.formId,
@@ -581,32 +723,52 @@ Use this first on normal HTML pages when you want to understand the page shape w
581
723
  const session = getSession();
582
724
  if (!session?.tree || !session?.layout)
583
725
  return err('Not connected. Call geometra_connect first.');
584
- const a11y = buildA11yTree(session.tree, session.layout);
726
+ const a11y = sessionA11y(session);
727
+ if (!a11y)
728
+ return err('No UI tree available');
585
729
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
586
730
  return ok(JSON.stringify(model));
587
731
  });
588
732
  server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
589
733
 
590
- Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
734
+ Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default. If you pass \`pageUrl\` or \`url\`, MCP will connect first so discovery can happen in one tool call.`, {
735
+ url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before discovery.'),
736
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before discovery. Prefer this over url for browser pages.'),
737
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
738
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
739
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
740
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
741
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
591
742
  formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
592
743
  maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
593
744
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
594
745
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
595
- }, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
596
- const session = getSession();
597
- if (!session?.tree || !session?.layout)
598
- return err('Not connected. Call geometra_connect first.');
599
- const a11y = buildA11yTree(session.tree, session.layout);
600
- const forms = buildFormSchemas(a11y, {
746
+ includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
747
+ includeContext: formSchemaContextInput(),
748
+ sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
749
+ format: formSchemaFormatInput(),
750
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format }) => {
751
+ const resolved = await ensureToolSession({ url, pageUrl, port, headless, width, height, slowMo }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
752
+ if (!resolved.ok)
753
+ return err(resolved.error);
754
+ const session = resolved.session;
755
+ const payload = formSchemaResponsePayload(session, {
601
756
  formId,
602
757
  maxFields,
603
758
  onlyRequiredFields,
604
759
  onlyInvalidFields,
760
+ includeOptions,
761
+ includeContext,
762
+ sinceSchemaId,
763
+ format,
605
764
  });
606
- if (forms.length === 0) {
765
+ if (payload.formCount === 0) {
607
766
  return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
608
767
  }
609
- return ok(JSON.stringify({ forms }));
768
+ return ok(JSON.stringify({
769
+ ...autoConnectionPayload(resolved),
770
+ ...payload,
771
+ }));
610
772
  });
611
773
  server.tool('geometra_expand_section', `Expand one section from geometra_page_model by stable id. Returns richer on-demand details such as headings, fields, actions, nested lists, list items, and text preview.
612
774
 
@@ -629,7 +791,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
629
791
  const session = getSession();
630
792
  if (!session?.tree || !session?.layout)
631
793
  return err('Not connected. Call geometra_connect first.');
632
- const a11y = buildA11yTree(session.tree, session.layout);
794
+ const a11y = sessionA11y(session);
795
+ if (!a11y)
796
+ return err('No UI tree available');
633
797
  const detail = expandPageSection(a11y, id, {
634
798
  maxHeadings,
635
799
  maxFields,
@@ -998,7 +1162,9 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
998
1162
  const session = getSession();
999
1163
  if (!session?.tree || !session?.layout)
1000
1164
  return err('Not connected. Call geometra_connect first.');
1001
- const a11y = buildA11yTree(session.tree, session.layout);
1165
+ const a11y = sessionA11y(session);
1166
+ if (!a11y)
1167
+ return err('No UI tree available');
1002
1168
  if (view === 'full') {
1003
1169
  return ok(JSON.stringify(a11y, null, 2));
1004
1170
  }
@@ -1022,9 +1188,11 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
1022
1188
  return ok(JSON.stringify(session.layout, null, 2));
1023
1189
  });
1024
1190
  // ── disconnect ───────────────────────────────────────────────
1025
- server.tool('geometra_disconnect', `Disconnect from the Geometra server and clean up the WebSocket connection.`, {}, async () => {
1026
- disconnect();
1027
- return ok('Disconnected.');
1191
+ server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep the browser alive by default so the next geometra_connect can reuse it quickly; pass closeBrowser=true to fully tear down the proxy/browser.`, {
1192
+ closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
1193
+ }, async ({ closeBrowser }) => {
1194
+ disconnect({ closeProxy: closeBrowser });
1195
+ return ok(closeBrowser ? 'Disconnected and closed browser.' : 'Disconnected.');
1028
1196
  });
1029
1197
  return server;
1030
1198
  }
@@ -1050,7 +1218,191 @@ function connectPayload(session, opts) {
1050
1218
  function sessionA11y(session) {
1051
1219
  if (!session.tree || !session.layout)
1052
1220
  return null;
1053
- return buildA11yTree(session.tree, session.layout);
1221
+ if (session.cachedA11yRevision === session.updateRevision) {
1222
+ return session.cachedA11y ?? null;
1223
+ }
1224
+ const a11y = buildA11yTree(session.tree, session.layout);
1225
+ session.cachedA11y = a11y;
1226
+ session.cachedA11yRevision = session.updateRevision;
1227
+ return a11y;
1228
+ }
1229
+ function shortHash(value) {
1230
+ return createHash('sha1').update(value).digest('hex').slice(0, 12);
1231
+ }
1232
+ function formSchemaCacheKey(options) {
1233
+ return JSON.stringify({
1234
+ formId: options.formId ?? null,
1235
+ maxFields: options.maxFields ?? null,
1236
+ onlyRequiredFields: options.onlyRequiredFields ?? false,
1237
+ onlyInvalidFields: options.onlyInvalidFields ?? false,
1238
+ includeOptions: options.includeOptions ?? false,
1239
+ includeContext: options.includeContext ?? 'auto',
1240
+ });
1241
+ }
1242
+ function getSessionFormSchemas(session, options) {
1243
+ const key = formSchemaCacheKey(options);
1244
+ const cached = session.cachedFormSchemas?.get(key);
1245
+ if (cached && cached.revision === session.updateRevision)
1246
+ return cached.forms;
1247
+ const a11y = sessionA11y(session);
1248
+ if (!a11y)
1249
+ return [];
1250
+ const forms = buildFormSchemas(a11y, options);
1251
+ if (!session.cachedFormSchemas)
1252
+ session.cachedFormSchemas = new Map();
1253
+ session.cachedFormSchemas.set(key, {
1254
+ revision: session.updateRevision,
1255
+ forms,
1256
+ });
1257
+ return forms;
1258
+ }
1259
+ function packedFormSchemas(forms) {
1260
+ return forms.map(form => ({
1261
+ i: form.formId,
1262
+ ...(form.name ? { n: form.name } : {}),
1263
+ fc: form.fieldCount,
1264
+ rc: form.requiredCount,
1265
+ ic: form.invalidCount,
1266
+ f: form.fields.map(field => ({
1267
+ i: field.id,
1268
+ k: field.kind,
1269
+ l: field.label,
1270
+ ...(field.required ? { r: 1 } : {}),
1271
+ ...(field.invalid ? { iv: 1 } : {}),
1272
+ ...(field.choiceType ? { ch: field.choiceType } : {}),
1273
+ ...(field.booleanChoice ? { b: 1 } : {}),
1274
+ ...(field.controlType ? { t: field.controlType } : {}),
1275
+ ...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
1276
+ ...(field.value ? { v: field.value } : {}),
1277
+ ...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
1278
+ ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
1279
+ ...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
1280
+ ...(field.context ? { x: field.context } : {}),
1281
+ })),
1282
+ }));
1283
+ }
1284
+ function formSchemaResponsePayload(session, opts) {
1285
+ const forms = getSessionFormSchemas(session, opts);
1286
+ const schemaJson = JSON.stringify(forms);
1287
+ const schemaId = `fs:${shortHash(schemaJson)}`;
1288
+ if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
1289
+ return {
1290
+ schemaId,
1291
+ changed: false,
1292
+ formCount: forms.length,
1293
+ format: opts.format ?? 'compact',
1294
+ };
1295
+ }
1296
+ return {
1297
+ schemaId,
1298
+ changed: true,
1299
+ formCount: forms.length,
1300
+ format: opts.format ?? 'compact',
1301
+ forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
1302
+ };
1303
+ }
1304
+ function totalReturnedSchemaFields(forms) {
1305
+ return forms.reduce((sum, form) => sum + form.fields.length, 0);
1306
+ }
1307
+ function expectedReturnedSchemaFields(forms, maxFields) {
1308
+ return forms.reduce((sum, form) => sum + Math.min(form.fieldCount, maxFields ?? form.fieldCount), 0);
1309
+ }
1310
+ function schemaShapeSignature(forms) {
1311
+ return JSON.stringify(forms.map(form => ({
1312
+ formId: form.formId,
1313
+ fieldCount: form.fieldCount,
1314
+ fields: form.fields.map(field => field.id),
1315
+ })));
1316
+ }
1317
+ async function stabilizeInlineFormSchemas(session, options, opts) {
1318
+ const timeoutMs = opts?.timeoutMs ?? 2_000;
1319
+ const pollMs = opts?.pollMs ?? 60;
1320
+ const stableMs = opts?.stableMs ?? 120;
1321
+ const deadline = Date.now() + timeoutMs;
1322
+ let forms = getSessionFormSchemas(session, options);
1323
+ let lastSignature = schemaShapeSignature(forms);
1324
+ let stableSince = Date.now();
1325
+ while (Date.now() < deadline) {
1326
+ const expectedFields = expectedReturnedSchemaFields(forms, options.maxFields);
1327
+ if (forms.length > 0 && totalReturnedSchemaFields(forms) >= expectedFields && Date.now() - stableSince >= stableMs) {
1328
+ return;
1329
+ }
1330
+ await new Promise(resolve => setTimeout(resolve, pollMs));
1331
+ forms = getSessionFormSchemas(session, options);
1332
+ const signature = schemaShapeSignature(forms);
1333
+ if (signature !== lastSignature) {
1334
+ lastSignature = signature;
1335
+ stableSince = Date.now();
1336
+ }
1337
+ }
1338
+ }
1339
+ function connectResponsePayload(session, opts) {
1340
+ const payload = connectPayload(session, opts);
1341
+ if (!opts.returnForms)
1342
+ return payload;
1343
+ return {
1344
+ ...payload,
1345
+ formSchema: formSchemaResponsePayload(session, opts.formSchema ?? {}),
1346
+ };
1347
+ }
1348
+ async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
1349
+ if (!target.url && !target.pageUrl) {
1350
+ const session = getSession();
1351
+ if (!session)
1352
+ return { ok: false, error: missingConnectionMessage };
1353
+ return { ok: true, session, autoConnected: false };
1354
+ }
1355
+ const normalized = normalizeConnectTarget({ url: target.url, pageUrl: target.pageUrl });
1356
+ if (!normalized.ok)
1357
+ return { ok: false, error: normalized.error };
1358
+ const resolvedTarget = normalized.value;
1359
+ try {
1360
+ if (resolvedTarget.kind === 'proxy') {
1361
+ const session = await connectThroughProxy({
1362
+ pageUrl: resolvedTarget.pageUrl,
1363
+ port: target.port,
1364
+ headless: target.headless,
1365
+ width: target.width,
1366
+ height: target.height,
1367
+ slowMo: target.slowMo,
1368
+ awaitInitialFrame: target.awaitInitialFrame,
1369
+ });
1370
+ return {
1371
+ ok: true,
1372
+ session,
1373
+ autoConnected: true,
1374
+ transport: 'proxy',
1375
+ requestedPageUrl: resolvedTarget.pageUrl,
1376
+ autoCoercedFromUrl: resolvedTarget.autoCoercedFromUrl,
1377
+ };
1378
+ }
1379
+ const session = await connect(resolvedTarget.wsUrl, {
1380
+ width: target.width,
1381
+ height: target.height,
1382
+ awaitInitialFrame: target.awaitInitialFrame,
1383
+ });
1384
+ return {
1385
+ ok: true,
1386
+ session,
1387
+ autoConnected: true,
1388
+ transport: 'ws',
1389
+ requestedWsUrl: resolvedTarget.wsUrl,
1390
+ };
1391
+ }
1392
+ catch (e) {
1393
+ return { ok: false, error: `Failed to connect: ${formatConnectFailureMessage(e, resolvedTarget)}` };
1394
+ }
1395
+ }
1396
+ function autoConnectionPayload(target) {
1397
+ if (!target?.autoConnected)
1398
+ return {};
1399
+ return {
1400
+ autoConnected: true,
1401
+ ...(target.transport ? { transport: target.transport } : {}),
1402
+ ...(target.requestedPageUrl ? { pageUrl: target.requestedPageUrl } : {}),
1403
+ ...(target.requestedWsUrl ? { requestedWsUrl: target.requestedWsUrl } : {}),
1404
+ ...(target.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
1405
+ };
1054
1406
  }
1055
1407
  function sessionOverviewFromA11y(a11y) {
1056
1408
  const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
@@ -1293,6 +1645,8 @@ function coerceChoiceValue(field, value) {
1293
1645
  return value;
1294
1646
  if (typeof value !== 'boolean')
1295
1647
  return null;
1648
+ if (field.booleanChoice)
1649
+ return value ? 'Yes' : 'No';
1296
1650
  const desired = value ? 'yes' : 'no';
1297
1651
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
1298
1652
  return option ?? (value ? 'Yes' : 'No');
@@ -1301,18 +1655,24 @@ function plannedFillInputsForField(field, value) {
1301
1655
  if (field.kind === 'text') {
1302
1656
  if (typeof value !== 'string')
1303
1657
  return { error: `Field "${field.label}" expects a string value` };
1304
- return [{ kind: 'text', fieldLabel: field.label, value }];
1658
+ return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value }];
1305
1659
  }
1306
1660
  if (field.kind === 'choice') {
1307
1661
  const coerced = coerceChoiceValue(field, value);
1308
1662
  if (!coerced)
1309
1663
  return { error: `Field "${field.label}" expects a string value` };
1310
- return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
1664
+ return [{
1665
+ kind: 'choice',
1666
+ fieldId: field.id,
1667
+ fieldLabel: field.label,
1668
+ value: coerced,
1669
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
1670
+ }];
1311
1671
  }
1312
1672
  if (field.kind === 'toggle') {
1313
1673
  if (typeof value !== 'boolean')
1314
1674
  return { error: `Field "${field.label}" expects a boolean value` };
1315
- return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
1675
+ return [{ kind: 'toggle', fieldId: field.id, label: field.label, checked: value, controlType: field.controlType }];
1316
1676
  }
1317
1677
  const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
1318
1678
  if (!selected || selected.length === 0)
@@ -1323,6 +1683,7 @@ function plannedFillInputsForField(field, value) {
1323
1683
  const selectedKeys = new Set(selected.map(normalizeLookupKey));
1324
1684
  return field.options.map(option => ({
1325
1685
  kind: 'toggle',
1686
+ fieldId: field.id,
1326
1687
  label: option,
1327
1688
  checked: selectedKeys.has(normalizeLookupKey(option)),
1328
1689
  controlType: 'checkbox',
@@ -1373,7 +1734,14 @@ function planFormFill(schema, opts) {
1373
1734
  function canFallbackToSequentialFill(error) {
1374
1735
  const message = error instanceof Error ? error.message : String(error);
1375
1736
  return (message.includes('Unsupported client message type "fillFields"') ||
1376
- message.includes('Client message type "fillFields" is not supported'));
1737
+ message.includes('Client message type "fillFields" is not supported') ||
1738
+ message.startsWith('setFieldText:') ||
1739
+ message.startsWith('setFieldChoice:') ||
1740
+ message.startsWith('setChecked:') ||
1741
+ message.startsWith('attachFiles:') ||
1742
+ message.startsWith('pickListboxOption:') ||
1743
+ message.startsWith('Could not find a') ||
1744
+ message.startsWith('No visible'));
1377
1745
  }
1378
1746
  function parseProxyFillAckResult(value) {
1379
1747
  if (!value || typeof value !== 'object')
@@ -1393,6 +1761,18 @@ function parseProxyFillAckResult(value) {
1393
1761
  busyCount: candidate.busyCount,
1394
1762
  };
1395
1763
  }
1764
+ function directLabelBatchFields(valuesByLabel) {
1765
+ const entries = Object.entries(valuesByLabel ?? {});
1766
+ if (entries.length === 0)
1767
+ return null;
1768
+ const fields = [];
1769
+ for (const [fieldLabel, value] of entries) {
1770
+ if (typeof value !== 'string' && typeof value !== 'boolean')
1771
+ return null;
1772
+ fields.push({ kind: 'auto', fieldLabel, value });
1773
+ }
1774
+ return fields;
1775
+ }
1396
1776
  async function waitForDeferredBatchUpdate(session, startRevision, wait) {
1397
1777
  if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
1398
1778
  return;
@@ -1589,9 +1969,9 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1589
1969
  const timeoutMs = action.timeoutMs ?? 10_000;
1590
1970
  const startedAt = Date.now();
1591
1971
  const matched = await waitForUiCondition(session, () => {
1592
- if (!session.tree || !session.layout)
1972
+ const a11y = sessionA11y(session);
1973
+ if (!a11y)
1593
1974
  return false;
1594
- const a11y = buildA11yTree(session.tree, session.layout);
1595
1975
  const matches = findNodes(a11y, filter);
1596
1976
  return present ? matches.length > 0 : matches.length === 0;
1597
1977
  }, timeoutMs);
@@ -1665,7 +2045,7 @@ async function executeFillField(session, field, detail) {
1665
2045
  switch (field.kind) {
1666
2046
  case 'text': {
1667
2047
  const before = sessionA11y(session);
1668
- const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
2048
+ const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact, fieldId: field.fieldId }, field.timeoutMs);
1669
2049
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1670
2050
  return {
1671
2051
  summary: [
@@ -1674,6 +2054,7 @@ async function executeFillField(session, field, detail) {
1674
2054
  postActionSummary(session, before, wait, detail),
1675
2055
  ].filter(Boolean).join('\n'),
1676
2056
  compact: {
2057
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
1677
2058
  fieldLabel: field.fieldLabel,
1678
2059
  ...compactTextValue(field.value),
1679
2060
  ...waitStatusPayload(wait),
@@ -1683,7 +2064,7 @@ async function executeFillField(session, field, detail) {
1683
2064
  }
1684
2065
  case 'choice': {
1685
2066
  const before = sessionA11y(session);
1686
- const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
2067
+ const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query, choiceType: field.choiceType, fieldId: field.fieldId }, field.timeoutMs);
1687
2068
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1688
2069
  return {
1689
2070
  summary: [
@@ -1692,8 +2073,10 @@ async function executeFillField(session, field, detail) {
1692
2073
  postActionSummary(session, before, wait, detail),
1693
2074
  ].filter(Boolean).join('\n'),
1694
2075
  compact: {
2076
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
1695
2077
  fieldLabel: field.fieldLabel,
1696
2078
  value: field.value,
2079
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
1697
2080
  ...waitStatusPayload(wait),
1698
2081
  readback: fieldStatePayload(session, field.fieldLabel),
1699
2082
  },