@geometra/mcp 1.19.12 → 1.19.14

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.
@@ -1,5 +1,5 @@
1
- import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -13,9 +13,34 @@ export function resolveProxyScriptPath() {
13
13
  }
14
14
  export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
15
15
  const errors = [];
16
+ const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
17
+ const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
18
+ const packageDir = resolveProxyPackageDir(customRequire);
19
+ if (packageDir) {
20
+ if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
21
+ return workspaceDist;
22
+ }
23
+ const packagedDist = path.join(packageDir, 'dist/index.js');
24
+ if (existsSync(packagedDist))
25
+ return packagedDist;
26
+ const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
27
+ if (builtLocalDist)
28
+ return builtLocalDist;
29
+ errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/index.js was missing`);
30
+ }
31
+ else {
32
+ errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
33
+ }
16
34
  try {
17
35
  const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
18
- return path.join(path.dirname(pkgJson), 'dist/index.js');
36
+ const exportPackageDir = path.dirname(pkgJson);
37
+ const packagedDist = path.join(exportPackageDir, 'dist/index.js');
38
+ if (existsSync(packagedDist))
39
+ return packagedDist;
40
+ const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
41
+ if (builtLocalDist)
42
+ return builtLocalDist;
43
+ errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/index.js was missing`);
19
44
  }
20
45
  catch (err) {
21
46
  errors.push(err instanceof Error ? err.message : String(err));
@@ -31,13 +56,64 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
31
56
  return packagedSiblingDist;
32
57
  }
33
58
  errors.push(`Packaged sibling fallback not found at ${packagedSiblingDist}`);
34
- const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
35
59
  if (existsSync(workspaceDist)) {
36
60
  return workspaceDist;
37
61
  }
38
62
  errors.push(`Workspace fallback not found at ${workspaceDist}`);
39
63
  throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
40
64
  }
65
+ function resolveProxyPackageDir(customRequire) {
66
+ const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
67
+ for (const searchRoot of searchRoots) {
68
+ const packageDir = path.join(searchRoot, '@geometra', 'proxy');
69
+ if (existsSync(path.join(packageDir, 'package.json')))
70
+ return packageDir;
71
+ }
72
+ return undefined;
73
+ }
74
+ function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
75
+ try {
76
+ return realpathSync(packageDir) === realpathSync(bundledDependencyDir);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ function buildLocalProxyDistIfPossible(packageDir, errors) {
83
+ const distEntry = path.join(packageDir, 'dist/index.js');
84
+ const sourceEntry = path.join(packageDir, 'src/index.ts');
85
+ const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
86
+ if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
87
+ return undefined;
88
+ }
89
+ try {
90
+ const realPackageDir = realpathSync(packageDir);
91
+ const realTsconfigPath = path.join(realPackageDir, 'tsconfig.build.json');
92
+ const realDistDir = path.join(realPackageDir, 'dist');
93
+ const tscBin = require.resolve('typescript/bin/tsc');
94
+ rmSync(realDistDir, { recursive: true, force: true });
95
+ const result = spawnSync(process.execPath, [tscBin, '-p', realTsconfigPath], {
96
+ cwd: realPackageDir,
97
+ encoding: 'utf8',
98
+ stdio: 'pipe',
99
+ });
100
+ if (result.status !== 0) {
101
+ const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
102
+ errors.push(`Failed to build local @geometra/proxy at ${realPackageDir}: ${detail || `exit ${result.status ?? 'unknown'}`}`);
103
+ return undefined;
104
+ }
105
+ if (existsSync(distEntry))
106
+ return distEntry;
107
+ const realDistEntry = path.join(realPackageDir, 'dist/index.js');
108
+ if (existsSync(realDistEntry))
109
+ return realDistEntry;
110
+ errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/index.js is still missing`);
111
+ }
112
+ catch (err) {
113
+ errors.push(err instanceof Error ? err.message : String(err));
114
+ }
115
+ return undefined;
116
+ }
41
117
  export function parseProxyReadySignalLine(line) {
42
118
  const trimmed = line.trim();
43
119
  if (!trimmed)
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
4
- import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
4
+ import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
5
5
  function checkedStateInput() {
6
6
  return z
7
7
  .union([z.boolean(), z.literal('mixed')])
@@ -66,6 +66,12 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
66
66
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
67
67
  }),
68
68
  ]);
69
+ const formValueSchema = z.union([
70
+ z.string(),
71
+ z.boolean(),
72
+ z.array(z.string()).min(1),
73
+ ]);
74
+ const formValuesRecordSchema = z.record(z.string(), formValueSchema);
69
75
  const batchActionSchema = z.discriminatedUnion('type', [
70
76
  z.object({
71
77
  type: z.literal('click'),
@@ -146,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
146
152
  }),
147
153
  ]);
148
154
  export function createServer() {
149
- const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
155
+ const server = new McpServer({ name: 'geometra', version: '1.19.14' }, { capabilities: { tools: {} } });
150
156
  // ── connect ──────────────────────────────────────────────────
151
157
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
152
158
 
@@ -184,6 +190,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
184
190
  .nonnegative()
185
191
  .optional()
186
192
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
193
+ detail: detailInput(),
187
194
  }, async (input) => {
188
195
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
189
196
  if (!normalized.ok)
@@ -199,13 +206,23 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
199
206
  height: input.height,
200
207
  slowMo: input.slowMo,
201
208
  });
202
- const summary = compactSessionSummary(session);
203
- const inferred = target.autoCoercedFromUrl ? ' inferred from url input' : '';
204
- return ok(`Started geometra-proxy and connected at ${session.url} (page: ${target.pageUrl}${inferred}). UI state:\n${summary}`);
209
+ return ok(JSON.stringify(connectPayload(session, {
210
+ transport: 'proxy',
211
+ requestedPageUrl: target.pageUrl,
212
+ autoCoercedFromUrl: target.autoCoercedFromUrl,
213
+ detail: input.detail,
214
+ }), null, input.detail === 'verbose' ? 2 : undefined));
205
215
  }
206
- const session = await connect(target.wsUrl);
207
- const summary = compactSessionSummary(session);
208
- return ok(`Connected to ${target.wsUrl}. UI state:\n${summary}`);
216
+ const session = await connect(target.wsUrl, {
217
+ width: input.width,
218
+ height: input.height,
219
+ });
220
+ return ok(JSON.stringify(connectPayload(session, {
221
+ transport: 'ws',
222
+ requestedWsUrl: target.wsUrl,
223
+ autoCoercedFromUrl: false,
224
+ detail: input.detail,
225
+ }), null, input.detail === 'verbose' ? 2 : undefined));
209
226
  }
210
227
  catch (e) {
211
228
  return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
@@ -360,6 +377,138 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
360
377
  }
361
378
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
362
379
  });
380
+ 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
+
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.`, {
383
+ formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
384
+ valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
385
+ valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
386
+ stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
387
+ failOnInvalid: z
388
+ .boolean()
389
+ .optional()
390
+ .default(false)
391
+ .describe('Return an error if invalid fields remain after filling'),
392
+ includeSteps: z
393
+ .boolean()
394
+ .optional()
395
+ .default(false)
396
+ .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
397
+ 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.');
402
+ const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
403
+ if (entryCount === 0) {
404
+ return err('Provide at least one value in valuesById or valuesByLabel');
405
+ }
406
+ const afterConnect = sessionA11y(session);
407
+ if (!afterConnect)
408
+ return err('No UI tree available for form filling');
409
+ const schemas = buildFormSchemas(afterConnect);
410
+ if (schemas.length === 0)
411
+ return err('No forms found in the current UI');
412
+ const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
413
+ if (!resolution.ok)
414
+ return err(resolution.error);
415
+ const schema = resolution.schema;
416
+ const planned = planFormFill(schema, { valuesById, valuesByLabel });
417
+ if (!planned.ok)
418
+ return err(planned.error);
419
+ if (!includeSteps) {
420
+ let usedBatch = false;
421
+ try {
422
+ const startRevision = session.updateRevision;
423
+ const wait = await sendFillFields(session, planned.fields);
424
+ const ackResult = parseProxyFillAckResult(wait.result);
425
+ if (ackResult && ackResult.invalidCount === 0) {
426
+ usedBatch = true;
427
+ const payload = {
428
+ completed: true,
429
+ execution: 'batched',
430
+ finalSource: 'proxy',
431
+ formId: schema.formId,
432
+ requestedValueCount: entryCount,
433
+ fieldCount: planned.fields.length,
434
+ successCount: planned.fields.length,
435
+ errorCount: 0,
436
+ final: ackResult,
437
+ };
438
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
439
+ }
440
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
441
+ await waitForBatchFieldReadback(session, planned.fields);
442
+ usedBatch = true;
443
+ }
444
+ catch (e) {
445
+ if (!canFallbackToSequentialFill(e)) {
446
+ const message = e instanceof Error ? e.message : String(e);
447
+ return err(message);
448
+ }
449
+ }
450
+ if (usedBatch) {
451
+ const after = sessionA11y(session);
452
+ const signals = after ? collectSessionSignals(after) : undefined;
453
+ const invalidRemaining = signals?.invalidFields.length ?? 0;
454
+ const payload = {
455
+ completed: true,
456
+ execution: 'batched',
457
+ finalSource: 'session',
458
+ formId: schema.formId,
459
+ requestedValueCount: entryCount,
460
+ fieldCount: planned.fields.length,
461
+ successCount: planned.fields.length,
462
+ errorCount: 0,
463
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
464
+ };
465
+ if (failOnInvalid && invalidRemaining > 0) {
466
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
467
+ }
468
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
469
+ }
470
+ }
471
+ const steps = [];
472
+ let stoppedAt;
473
+ for (let index = 0; index < planned.fields.length; index++) {
474
+ const field = planned.fields[index];
475
+ try {
476
+ const result = await executeFillField(session, field, detail);
477
+ steps.push(detail === 'verbose'
478
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
479
+ : { index, kind: field.kind, ok: true, ...result.compact });
480
+ }
481
+ catch (e) {
482
+ const message = e instanceof Error ? e.message : String(e);
483
+ steps.push({ index, kind: field.kind, ok: false, error: message });
484
+ if (stopOnError) {
485
+ stoppedAt = index;
486
+ break;
487
+ }
488
+ }
489
+ }
490
+ const after = sessionA11y(session);
491
+ const signals = after ? collectSessionSignals(after) : undefined;
492
+ const invalidRemaining = signals?.invalidFields.length ?? 0;
493
+ const successCount = steps.filter(step => step.ok === true).length;
494
+ const errorCount = steps.length - successCount;
495
+ const payload = {
496
+ completed: stoppedAt === undefined && steps.length === planned.fields.length,
497
+ execution: 'sequential',
498
+ formId: schema.formId,
499
+ requestedValueCount: entryCount,
500
+ fieldCount: planned.fields.length,
501
+ successCount,
502
+ errorCount,
503
+ ...(includeSteps ? { steps } : {}),
504
+ ...(stoppedAt !== undefined ? { stoppedAt } : {}),
505
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
506
+ };
507
+ if (failOnInvalid && invalidRemaining > 0) {
508
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
509
+ }
510
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
511
+ });
363
512
  server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
364
513
 
365
514
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
@@ -436,6 +585,29 @@ Use this first on normal HTML pages when you want to understand the page shape w
436
585
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
437
586
  return ok(JSON.stringify(model));
438
587
  });
588
+ 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
+
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.`, {
591
+ formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
592
+ maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
593
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
594
+ 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, {
601
+ formId,
602
+ maxFields,
603
+ onlyRequiredFields,
604
+ onlyInvalidFields,
605
+ });
606
+ if (forms.length === 0) {
607
+ return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
608
+ }
609
+ return ok(JSON.stringify({ forms }));
610
+ });
439
611
  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.
440
612
 
441
613
  Use this after geometra_page_model when you know which form/dialog/list/landmark you want to inspect more closely. Per-item bounds are omitted by default to save tokens; set includeBounds=true if you need them immediately.`, {
@@ -863,6 +1035,18 @@ function compactSessionSummary(session) {
863
1035
  return 'No UI update received';
864
1036
  return sessionOverviewFromA11y(a11y);
865
1037
  }
1038
+ function connectPayload(session, opts) {
1039
+ const a11y = sessionA11y(session);
1040
+ return {
1041
+ connected: true,
1042
+ transport: opts.transport,
1043
+ wsUrl: session.url,
1044
+ ...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
1045
+ ...(opts.requestedWsUrl ? { requestedWsUrl: opts.requestedWsUrl } : {}),
1046
+ ...(opts.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
1047
+ ...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
1048
+ };
1049
+ }
866
1050
  function sessionA11y(session) {
867
1051
  if (!session.tree || !session.layout)
868
1052
  return null;
@@ -1072,6 +1256,183 @@ function waitStatusPayload(wait) {
1072
1256
  function compactFilterPayload(filter) {
1073
1257
  return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
1074
1258
  }
1259
+ function normalizeLookupKey(value) {
1260
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
1261
+ }
1262
+ function resolveTargetFormSchema(schemas, opts) {
1263
+ if (opts.formId) {
1264
+ const matched = schemas.find(schema => schema.formId === opts.formId);
1265
+ return matched
1266
+ ? { ok: true, schema: matched }
1267
+ : { ok: false, error: `No form schema found for id ${opts.formId}` };
1268
+ }
1269
+ if (schemas.length === 1)
1270
+ return { ok: true, schema: schemas[0] };
1271
+ const idKeys = Object.keys(opts.valuesById ?? {});
1272
+ const labelKeys = Object.keys(opts.valuesByLabel ?? {}).map(normalizeLookupKey);
1273
+ const matches = schemas.filter(schema => {
1274
+ const ids = new Set(schema.fields.map(field => field.id));
1275
+ const labels = new Set(schema.fields.map(field => normalizeLookupKey(field.label)));
1276
+ return idKeys.every(id => ids.has(id)) && labelKeys.every(label => labels.has(label));
1277
+ });
1278
+ if (matches.length === 1)
1279
+ return { ok: true, schema: matches[0] };
1280
+ if (matches.length === 0) {
1281
+ return {
1282
+ ok: false,
1283
+ error: 'Could not infer which form to fill from the provided field ids/labels. Pass formId from geometra_form_schema.',
1284
+ };
1285
+ }
1286
+ return {
1287
+ ok: false,
1288
+ error: 'Multiple forms match the provided field ids/labels. Pass formId from geometra_form_schema.',
1289
+ };
1290
+ }
1291
+ function coerceChoiceValue(field, value) {
1292
+ if (typeof value === 'string')
1293
+ return value;
1294
+ if (typeof value !== 'boolean')
1295
+ return null;
1296
+ const desired = value ? 'yes' : 'no';
1297
+ const option = field.options?.find(option => normalizeLookupKey(option) === desired);
1298
+ return option ?? (value ? 'Yes' : 'No');
1299
+ }
1300
+ function plannedFillInputsForField(field, value) {
1301
+ if (field.kind === 'text') {
1302
+ if (typeof value !== 'string')
1303
+ return { error: `Field "${field.label}" expects a string value` };
1304
+ return [{ kind: 'text', fieldLabel: field.label, value }];
1305
+ }
1306
+ if (field.kind === 'choice') {
1307
+ const coerced = coerceChoiceValue(field, value);
1308
+ if (!coerced)
1309
+ return { error: `Field "${field.label}" expects a string value` };
1310
+ return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
1311
+ }
1312
+ if (field.kind === 'toggle') {
1313
+ if (typeof value !== 'boolean')
1314
+ return { error: `Field "${field.label}" expects a boolean value` };
1315
+ return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
1316
+ }
1317
+ const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
1318
+ if (!selected || selected.length === 0)
1319
+ return { error: `Field "${field.label}" expects a string array value` };
1320
+ if (!field.options || field.options.length === 0) {
1321
+ return { error: `Field "${field.label}" does not expose checkbox options; use geometra_fill_fields for this field` };
1322
+ }
1323
+ const selectedKeys = new Set(selected.map(normalizeLookupKey));
1324
+ return field.options.map(option => ({
1325
+ kind: 'toggle',
1326
+ label: option,
1327
+ checked: selectedKeys.has(normalizeLookupKey(option)),
1328
+ controlType: 'checkbox',
1329
+ }));
1330
+ }
1331
+ function planFormFill(schema, opts) {
1332
+ const fieldById = new Map(schema.fields.map(field => [field.id, field]));
1333
+ const fieldsByLabel = new Map();
1334
+ for (const field of schema.fields) {
1335
+ const key = normalizeLookupKey(field.label);
1336
+ const existing = fieldsByLabel.get(key);
1337
+ if (existing)
1338
+ existing.push(field);
1339
+ else
1340
+ fieldsByLabel.set(key, [field]);
1341
+ }
1342
+ const planned = [];
1343
+ const seenFieldIds = new Set();
1344
+ for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
1345
+ const field = fieldById.get(fieldId);
1346
+ if (!field)
1347
+ return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
1348
+ const next = plannedFillInputsForField(field, value);
1349
+ if ('error' in next)
1350
+ return { ok: false, error: next.error };
1351
+ planned.push(...next);
1352
+ seenFieldIds.add(field.id);
1353
+ }
1354
+ for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
1355
+ const matches = fieldsByLabel.get(normalizeLookupKey(label)) ?? [];
1356
+ if (matches.length === 0)
1357
+ return { ok: false, error: `Unknown form field label "${label}". Refresh geometra_form_schema and try again.` };
1358
+ if (matches.length > 1) {
1359
+ return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
1360
+ }
1361
+ const field = matches[0];
1362
+ if (seenFieldIds.has(field.id)) {
1363
+ return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
1364
+ }
1365
+ const next = plannedFillInputsForField(field, value);
1366
+ if ('error' in next)
1367
+ return { ok: false, error: next.error };
1368
+ planned.push(...next);
1369
+ seenFieldIds.add(field.id);
1370
+ }
1371
+ return { ok: true, fields: planned };
1372
+ }
1373
+ function canFallbackToSequentialFill(error) {
1374
+ const message = error instanceof Error ? error.message : String(error);
1375
+ return (message.includes('Unsupported client message type "fillFields"') ||
1376
+ message.includes('Client message type "fillFields" is not supported'));
1377
+ }
1378
+ function parseProxyFillAckResult(value) {
1379
+ if (!value || typeof value !== 'object')
1380
+ return undefined;
1381
+ const candidate = value;
1382
+ if (typeof candidate.invalidCount !== 'number' ||
1383
+ typeof candidate.alertCount !== 'number' ||
1384
+ typeof candidate.dialogCount !== 'number' ||
1385
+ typeof candidate.busyCount !== 'number') {
1386
+ return undefined;
1387
+ }
1388
+ return {
1389
+ ...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
1390
+ invalidCount: candidate.invalidCount,
1391
+ alertCount: candidate.alertCount,
1392
+ dialogCount: candidate.dialogCount,
1393
+ busyCount: candidate.busyCount,
1394
+ };
1395
+ }
1396
+ async function waitForDeferredBatchUpdate(session, startRevision, wait) {
1397
+ if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
1398
+ return;
1399
+ await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
1400
+ }
1401
+ async function waitForBatchFieldReadback(session, fields) {
1402
+ await waitForUiCondition(session, () => {
1403
+ const a11y = sessionA11y(session);
1404
+ if (!a11y)
1405
+ return false;
1406
+ return fields.every(field => batchFieldReadbackMatches(a11y, field));
1407
+ }, 1500);
1408
+ }
1409
+ function batchFieldReadbackMatches(a11y, field) {
1410
+ switch (field.kind) {
1411
+ case 'text': {
1412
+ const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
1413
+ return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
1414
+ }
1415
+ case 'choice': {
1416
+ const directMatches = [
1417
+ ...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
1418
+ ...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
1419
+ ...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
1420
+ ];
1421
+ if (directMatches.length === 0)
1422
+ return true;
1423
+ return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
1424
+ }
1425
+ case 'toggle':
1426
+ return true;
1427
+ case 'file': {
1428
+ const matches = [
1429
+ ...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
1430
+ ...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
1431
+ ];
1432
+ return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
1433
+ }
1434
+ }
1435
+ }
1075
1436
  async function executeBatchAction(session, action, detail, includeSteps) {
1076
1437
  switch (action.type) {
1077
1438
  case 'click': {
package/dist/session.d.ts CHANGED
@@ -241,6 +241,30 @@ export interface PageSectionDetail {
241
241
  items: PageListItemModel[];
242
242
  textPreview: string[];
243
243
  }
244
+ export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
245
+ export interface FormSchemaField {
246
+ id: string;
247
+ kind: FormSchemaFieldKind;
248
+ label: string;
249
+ required?: boolean;
250
+ invalid?: boolean;
251
+ controlType?: 'checkbox' | 'radio';
252
+ value?: string;
253
+ valueLength?: number;
254
+ checked?: boolean;
255
+ values?: string[];
256
+ optionCount?: number;
257
+ options?: string[];
258
+ context?: NodeContextModel;
259
+ }
260
+ export interface FormSchemaModel {
261
+ formId: string;
262
+ name?: string;
263
+ fieldCount: number;
264
+ requiredCount: number;
265
+ invalidCount: number;
266
+ fields: FormSchemaField[];
267
+ }
244
268
  export interface UiNodeUpdate {
245
269
  before: CompactUiNode;
246
270
  after: CompactUiNode;
@@ -292,12 +316,40 @@ export interface Session {
292
316
  export interface UpdateWaitResult {
293
317
  status: 'updated' | 'acknowledged' | 'timed_out';
294
318
  timeoutMs: number;
319
+ result?: unknown;
295
320
  }
321
+ export type ProxyFillField = {
322
+ kind: 'text';
323
+ fieldLabel: string;
324
+ value: string;
325
+ exact?: boolean;
326
+ } | {
327
+ kind: 'choice';
328
+ fieldLabel: string;
329
+ value: string;
330
+ query?: string;
331
+ exact?: boolean;
332
+ } | {
333
+ kind: 'toggle';
334
+ label: string;
335
+ checked?: boolean;
336
+ exact?: boolean;
337
+ controlType?: 'checkbox' | 'radio';
338
+ } | {
339
+ kind: 'file';
340
+ fieldLabel: string;
341
+ paths: string[];
342
+ exact?: boolean;
343
+ };
296
344
  /**
297
345
  * Connect to a running Geometra server. Waits for the first frame so that
298
346
  * layout/tree state is available immediately after connection.
299
347
  */
300
- export declare function connect(url: string): Promise<Session>;
348
+ export declare function connect(url: string, opts?: {
349
+ width?: number;
350
+ height?: number;
351
+ skipInitialResize?: boolean;
352
+ }): Promise<Session>;
301
353
  /**
302
354
  * Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
303
355
  * process to the session so disconnect / reconnect can clean it up.
@@ -356,6 +408,8 @@ export declare function sendFieldChoice(session: Session, fieldLabel: string, va
356
408
  exact?: boolean;
357
409
  query?: string;
358
410
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
411
+ /** Fill several semantic form fields in one proxy-side batch. */
412
+ export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
359
413
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
360
414
  export declare function sendListboxPick(session: Session, label: string, opts?: {
361
415
  exact?: boolean;
@@ -413,6 +467,12 @@ export declare function buildPageModel(root: A11yNode, options?: {
413
467
  maxPrimaryActions?: number;
414
468
  maxSectionsPerKind?: number;
415
469
  }): PageModel;
470
+ export declare function buildFormSchemas(root: A11yNode, options?: {
471
+ formId?: string;
472
+ maxFields?: number;
473
+ onlyRequiredFields?: boolean;
474
+ onlyInvalidFields?: boolean;
475
+ }): FormSchemaModel[];
416
476
  /**
417
477
  * Expand a page-model section by stable ID into richer, on-demand details.
418
478
  */