halo-agent 1.3.6 → 2.0.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/orchestrator.js CHANGED
@@ -11,7 +11,43 @@
11
11
  const os = require('os');
12
12
  const path = require('path');
13
13
  const fs = require('fs');
14
- const { fillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
14
+ const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
15
+ const { smartFillPage } = require('./smartFill');
16
+
17
+ // Switchable filler — smart by default, can be killed via config.useSmartFill=false.
18
+ // smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
19
+ // is unavailable, so a planner outage doesn't break us — but this flag is the
20
+ // hard kill switch if smart mode is misbehaving on a specific user / form.
21
+ async function fillFields(page, aep, opts) {
22
+ const config = opts?.config;
23
+ const smartEnabled = config?.useSmartFill !== false; // default true
24
+ if (!smartEnabled) {
25
+ return await legacyFillFields(page, aep, opts);
26
+ }
27
+ const result = await smartFillPage(page, aep, {
28
+ config,
29
+ jobId: opts.jobId,
30
+ resumePath: aep.__resumeLocalPath || null,
31
+ coverLetterPath: aep.__coverLetterLocalPath || null,
32
+ ctx: opts.ctx,
33
+ ats: opts.ats,
34
+ }).catch(async (e) => {
35
+ console.warn(`[orchestrator] smartFillPage threw: ${e.message} — falling back to legacy`);
36
+ return await legacyFillFields(page, aep, opts);
37
+ });
38
+ // Normalize the result shape (smart returns askUserReasons, planned, fallback;
39
+ // legacy returns needsAI). The orchestrator only reads filled/skipped/failed/
40
+ // needsAI downstream, so we preserve needsAI as empty when smart succeeded.
41
+ return {
42
+ filled: result.filled || 0,
43
+ skipped: result.skipped || 0,
44
+ failed: result.failed || 0,
45
+ needsAI: result.needsAI || [],
46
+ askUserReasons: result.askUserReasons || [],
47
+ plannedActions: result.planned || 0,
48
+ fellBackToLegacy: !!result.fallback,
49
+ };
50
+ }
15
51
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
16
52
  const { visionFill, visionNavigateAndSubmit, visionFillSkipped } = require('./vision');
17
53
 
@@ -67,6 +103,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
67
103
  if (aep.recommended_resume?.pdf_presigned_url) {
68
104
  tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
69
105
  }
106
+ // Expose resume path on aep so smartFill's upload_file action can route it.
107
+ aep.__resumeLocalPath = tempResumeFile;
70
108
 
71
109
  // Download cover-letter PDF too — separate file because Greenhouse/Ashby
72
110
  // have separate file inputs for each. Only present when the user
@@ -76,8 +114,8 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
76
114
  tempCoverLetterFile = await downloadResume(aep.cover_letter_pdf.pdf_presigned_url);
77
115
  if (tempCoverLetterFile) console.log('[orchestrator] Cover letter PDF downloaded');
78
116
  }
79
- // Expose to fillFields via aep so the file:cover_letter category resolver
80
- // can hand it to the uploader.
117
+ // Expose to fillFields via aep so smartFill's upload_file action can
118
+ // route it (legacy filler also reads this for file:cover_letter).
81
119
  aep.__coverLetterLocalPath = tempCoverLetterFile;
82
120
 
83
121
  // Check for an existing checkpoint — if a previous run got past page 1
@@ -331,15 +369,46 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
331
369
  });
332
370
 
333
371
  if (visionResult.submitted) {
334
- // Vision already submitted we're done
372
+ // Vision THINKS it submitted, but we shouldn't trust that without
373
+ // verifying — vision can confuse "review page rendered" with
374
+ // "application accepted." Route through the same verify-submit
375
+ // gate everything else uses. Worst case → REVIEWING, user clicks
376
+ // Submit. Better than a false-positive DONE.
335
377
  const confirmShot = await page.screenshot({ type: 'jpeg', quality: 70 }).catch(() => null);
336
378
  const confirmKey = confirmShot ? await uploadScreenshot(config, confirmShot, `confirm_${queueId}.jpg`) : null;
337
- await reportStatus('DONE', {
338
- confirmation_screenshot_r2_key: confirmKey || null,
379
+ const verdictUrl = page.url();
380
+ let vVerdict = { submitted: null, error_message: null, source: 'unavailable' };
381
+ try {
382
+ const vRes = await fetch(`${config.apiUrl}/agent/verify-submit`, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
385
+ body: JSON.stringify({ queue_id: queueId, page_url: verdictUrl }),
386
+ });
387
+ if (vRes.ok) vVerdict = await vRes.json();
388
+ } catch {}
389
+ if (vVerdict.submitted === true) {
390
+ await reportStatus('DONE', { confirmation_screenshot_r2_key: confirmKey || null, fields_filled: cumulativeFilled });
391
+ await clearCheckpoint(config, queueId);
392
+ console.log(`[orchestrator] Done via vision (verified): ${queueItem.company} - ${queueItem.title}`);
393
+ return;
394
+ }
395
+ if (vVerdict.submitted === false) {
396
+ await reportStatus('NEEDS_ATTENTION', {
397
+ review_screenshot_r2_key: confirmKey || null,
398
+ needs_attention_reason: `Vision submitted but ATS rejected: ${vVerdict.error_message || 'unknown'}`,
399
+ intervention_type: 'submit_failed',
400
+ step: 'VERIFY',
401
+ step_detail: (vVerdict.error_message || '').slice(0, 200),
402
+ fields_filled: cumulativeFilled,
403
+ });
404
+ throw new Error(`Vision-submit failed verification: ${vVerdict.error_message || 'unknown'}`);
405
+ }
406
+ await reportStatus('REVIEWING', {
407
+ review_screenshot_r2_key: confirmKey || null,
408
+ step: 'REVIEWING',
409
+ step_detail: 'Vision attempted submit — verifier unavailable, please eyeball',
339
410
  fields_filled: cumulativeFilled,
340
411
  });
341
- await clearCheckpoint(config, queueId);
342
- console.log(`[orchestrator] Done via vision: ${queueItem.company} - ${queueItem.title}`);
343
412
  return;
344
413
  }
345
414
 
@@ -436,31 +505,22 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
436
505
  }
437
506
 
438
507
  if (verdict.submitted === null) {
439
- // Auto-submit mode means "don't ask me, just submit." If we can't
440
- // verify but the user opted into hands-off, trust the click and
441
- // mark DONE (the screenshot is the receipt; user can audit later).
442
- // Without this, autoSubmit was silently being ignored every time
443
- // Firecrawl was slow/down — exactly the case user hit.
444
- const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
445
- if (autoSubmit) {
446
- console.log(`[orchestrator] Could not verify (source: ${verdict.source}) auto-submit ON, trusting click.`);
447
- await reportStatus('DONE', {
448
- confirmation_screenshot_r2_key: confirmKey || null,
449
- fields_filled: cumulativeFilled,
450
- });
451
- await clearCheckpoint(config, queueId);
452
- console.log(`[orchestrator] Done (auto-submit, unverified): ${queueItem.company} - ${queueItem.title}`);
453
- return;
454
- }
455
- console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
508
+ // EARLIER VERSION: when auto-submit was ON, we trusted the click and
509
+ // marked DONE. That was wrong it produced false-positive submissions
510
+ // (applied=true in DB, no actual application sent). Auto-submit means
511
+ // "don't make me click Submit on the dashboard" — it does NOT mean
512
+ // "lie about delivery."
513
+ //
514
+ // Honest behavior: unverified == REVIEWING regardless of auto-submit.
515
+ // The screenshot is right there in the dashboard, one click confirms.
516
+ // Better to over-ask than to ghost-apply.
517
+ console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
456
518
  await reportStatus('REVIEWING', {
457
519
  review_screenshot_r2_key: confirmKey || null,
458
520
  step: 'REVIEWING',
459
- step_detail: 'Could not auto-verify — please confirm the submit',
521
+ step_detail: `Submit clicked at ${verdictUrl.slice(0, 100)} verifier unavailable, please confirm`,
460
522
  fields_filled: cumulativeFilled,
461
523
  });
462
- // Stop here; user clicks Submit on dashboard → /apply-queue/submit/:id
463
- // will flip to DONE. Don't return — let the function return naturally.
464
524
  return;
465
525
  }
466
526
 
@@ -1020,6 +1080,8 @@ async function runExtensionFill({
1020
1080
  if (aep.recommended_resume?.pdf_presigned_url) {
1021
1081
  tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
1022
1082
  }
1083
+ // Expose for smartFill upload_file action (and legacy gate uploader)
1084
+ aep.__resumeLocalPath = tempResumeFile;
1023
1085
 
1024
1086
  const ctx = createFormContext();
1025
1087
  const useVisionForThis = useVision && VISION_ATS.has((ats_type || '').toLowerCase());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "1.3.6",
3
+ "version": "2.0.1",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -22,6 +22,8 @@
22
22
  "localServer.js",
23
23
  "filler.js",
24
24
  "scanPage.js",
25
+ "scanAccessibility.js",
26
+ "smartFill.js",
25
27
  "captcha.js",
26
28
  "vision.js",
27
29
  "manusAutomate.js",
@@ -0,0 +1,447 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Accessibility-tree-based form scanner.
5
+ *
6
+ * Replaces the per-ATS regex scanners (scanGreenhouse, scanLever, scanAshby,
7
+ * scanWorkday, scanICIMS, scanGeneric) with a single uniform pass that works
8
+ * on any form because it reads what the browser tells screen readers.
9
+ *
10
+ * How:
11
+ * 1. INJECT mmid="N" attribute on every interactive DOM element (input,
12
+ * textarea, select, [contenteditable], [role=combobox|listbox|radio|
13
+ * checkbox|button|option], <a>). The mmid is a stable handle the LLM
14
+ * planner refers to when it returns an action plan.
15
+ * 2. FETCH the accessibility tree via Chrome DevTools Protocol's
16
+ * Accessibility.getFullAXTree. This returns the same tree
17
+ * screen readers see — already deduped, already labeled, already
18
+ * grouped by semantic role. Way cleaner than walking the DOM.
19
+ * 3. RECONCILE: walk the AX tree, find nodes carrying our injected mmid
20
+ * (via aria-keyshortcuts — we steal that attribute because it surfaces
21
+ * verbatim in the AX tree.name|description and is otherwise unused).
22
+ * For each AX node, read its name/role/description/options/required
23
+ * flags, then enrich with DOM-only signals (typeahead heuristics, the
24
+ * raw input type, the parent label text for grouped fields).
25
+ * 4. PRUNE: drop hidden, decorative, or already-filled fields. Return
26
+ * a flat list the LLM planner can reason over in one prompt.
27
+ *
28
+ * Why this beats per-ATS scanners:
29
+ * - Works on any ATS (Greenhouse, Lever, Ashby, Workday, iCIMS) AND any
30
+ * unknown ATS without code changes — the AX tree is the same shape.
31
+ * - Picks up labels the DOM scanner missed (aria-labelledby chains,
32
+ * fieldset/legend grouping, parent-walk heuristics) because the
33
+ * browser already did all that work for screen readers.
34
+ * - Deduplicates radio/checkbox groups into a single fieldset entry
35
+ * (the AX tree has 'radiogroup' role).
36
+ *
37
+ * Output shape (one entry per fillable field):
38
+ * {
39
+ * mmid: "12", // stable handle for the planner
40
+ * role: "textbox" | "combobox" | "checkbox" | "radio" | "button" |
41
+ * "listbox" | "menu" | "file_upload" | "textarea",
42
+ * label: "First Name", // primary name from AX tree
43
+ * description: "Required", // helper / placeholder / hint
44
+ * required: true,
45
+ * options: ["Male","Female",...] | null,
46
+ * selectorHint: "#first_name", // best CSS selector we could build
47
+ * inputType: "text", // HTML type= for inputs
48
+ * value: "" | "Male", // current filled value
49
+ * isTypeahead: false, // pure DOM heuristic
50
+ * groupLabel: null | "Pronouns", // fieldset/legend grouping
51
+ * filledAlready: false,
52
+ * }
53
+ */
54
+
55
+ const MMID_ATTR = 'mmid';
56
+ const HANDLE_ATTR = 'aria-keyshortcuts'; // we hijack this — surfaces in AX tree
57
+
58
+ // ── Step 1: Inject mmid into every interactive element ───────────────────────
59
+
60
+ async function injectMmid(page) {
61
+ // Returns the count so the caller can sanity-check that injection happened.
62
+ return await page.evaluate(({ mmidAttr, handleAttr }) => {
63
+ const sel = [
64
+ 'input',
65
+ 'textarea',
66
+ 'select',
67
+ '[contenteditable="true"]',
68
+ '[role="textbox"]',
69
+ '[role="combobox"]',
70
+ '[role="listbox"]',
71
+ '[role="radiogroup"]',
72
+ '[role="radio"]',
73
+ '[role="checkbox"]',
74
+ '[role="switch"]',
75
+ '[role="button"]',
76
+ '[role="option"]',
77
+ '[role="menuitem"]',
78
+ 'button[type="submit"]',
79
+ 'a[href]',
80
+ ].join(',');
81
+ const all = document.querySelectorAll(sel);
82
+ let n = 0;
83
+ all.forEach((el) => {
84
+ n += 1;
85
+ const id = String(n);
86
+ el.setAttribute(mmidAttr, id);
87
+ // Stash the original aria-keyshortcuts so we don't destroy real a11y data
88
+ const prev = el.getAttribute(handleAttr);
89
+ if (prev && !el.hasAttribute('data-orig-aria-keyshortcuts')) {
90
+ el.setAttribute('data-orig-aria-keyshortcuts', prev);
91
+ }
92
+ el.setAttribute(handleAttr, id);
93
+ });
94
+ return n;
95
+ }, { mmidAttr: MMID_ATTR, handleAttr: HANDLE_ATTR });
96
+ }
97
+
98
+ // ── Step 2: Fetch the full AX tree via CDP ───────────────────────────────────
99
+
100
+ async function fetchAxTree(page) {
101
+ // Open a CDP session — Playwright exposes this via context.newCDPSession.
102
+ // We need the page's context, which lives on the browser context.
103
+ const session = await page.context().newCDPSession(page);
104
+ try {
105
+ await session.send('Accessibility.enable');
106
+ const { nodes } = await session.send('Accessibility.getFullAXTree');
107
+ return nodes;
108
+ } finally {
109
+ try { await session.detach(); } catch {}
110
+ }
111
+ }
112
+
113
+ // ── Step 3: Reconcile AX tree → flat list keyed by mmid ──────────────────────
114
+
115
+ /**
116
+ * Pull a flat-string value from an AX property bag. AX values come as
117
+ * { type, value } objects; we just want the value (string|bool).
118
+ */
119
+ function axProp(node, key) {
120
+ // Top-level fields like 'name' / 'role' have { type, value } shape.
121
+ if (node?.[key]?.value !== undefined) return node[key].value;
122
+ // 'properties' is an array of { name, value: {type, value} }
123
+ if (Array.isArray(node?.properties)) {
124
+ const p = node.properties.find((x) => x.name === key);
125
+ if (p?.value?.value !== undefined) return p.value.value;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ /**
131
+ * Some labels arrive as the literal mmid we injected (when an element's
132
+ * name resolves to its own aria-keyshortcuts via labelledby chains).
133
+ * Strip those — they're our injection, not real labels.
134
+ */
135
+ function isMmidLiteral(s) {
136
+ return typeof s === 'string' && /^\d+$/.test(s.trim());
137
+ }
138
+
139
+ function reconcile(axNodes) {
140
+ // Build a map mmid → AX node and mmid → parent group label.
141
+ // The AX tree is a flat list of nodes with parentId/childIds, so we
142
+ // first build a parent index then walk to find ancestor radiogroup
143
+ // / fieldset names for grouped fields.
144
+ const byId = new Map();
145
+ for (const n of axNodes) byId.set(n.nodeId, n);
146
+
147
+ // For each node, find the mmid (lives in keyshortcuts because we hijacked it)
148
+ // and the closest ancestor whose role is 'radiogroup' / 'group' / 'form'.
149
+ const out = [];
150
+ for (const n of axNodes) {
151
+ const mmid = axProp(n, 'keyshortcuts');
152
+ if (!mmid || !/^\d+$/.test(String(mmid))) continue;
153
+
154
+ const role = (axProp(n, 'role') || '').toString();
155
+ // Skip nodes that are containers/decorative — we want fillable leaves.
156
+ // Buttons / links we keep so the planner can decide to click them
157
+ // (Submit, Next, etc.).
158
+ if (['generic', 'none', 'presentation'].includes(role)) continue;
159
+
160
+ // Walk up to find a grouping label
161
+ let groupLabel = null;
162
+ let cursor = n;
163
+ let hops = 0;
164
+ while (cursor && hops < 10) {
165
+ const parentId = cursor.parentId;
166
+ if (!parentId) break;
167
+ const parent = byId.get(parentId);
168
+ if (!parent) break;
169
+ const parentRole = (axProp(parent, 'role') || '').toString();
170
+ if (['radiogroup', 'group', 'form'].includes(parentRole)) {
171
+ const pname = axProp(parent, 'name');
172
+ if (pname && !isMmidLiteral(pname)) {
173
+ groupLabel = String(pname).trim();
174
+ break;
175
+ }
176
+ }
177
+ cursor = parent;
178
+ hops += 1;
179
+ }
180
+
181
+ out.push({
182
+ mmid: String(mmid),
183
+ role,
184
+ // Strip mmid-literal names — that happens when the element has no real
185
+ // accessible name and the AX tree falls back to our hijacked attr.
186
+ rawName: (() => {
187
+ const v = axProp(n, 'name');
188
+ return v && !isMmidLiteral(v) ? String(v).trim() : '';
189
+ })(),
190
+ description: (() => {
191
+ const v = axProp(n, 'description');
192
+ return v && !isMmidLiteral(v) ? String(v).trim() : '';
193
+ })(),
194
+ required: !!axProp(n, 'required'),
195
+ disabled: !!axProp(n, 'disabled'),
196
+ focused: !!axProp(n, 'focused'),
197
+ checked: axProp(n, 'checked'),
198
+ selected: !!axProp(n, 'selected'),
199
+ // valuetext is what the user "sees" in a filled combobox / spinner.
200
+ // value is the underlying value when it differs (rare for inputs).
201
+ axValue: axProp(n, 'value'),
202
+ groupLabel,
203
+ });
204
+ }
205
+ return out;
206
+ }
207
+
208
+ // ── Step 4: Enrich each reconciled field with DOM-only signals ───────────────
209
+
210
+ /**
211
+ * The AX tree tells us what the field IS; DOM tells us what we need to
212
+ * INTERACT with it. We pull:
213
+ * - tag, inputType, name, id (for selector building + classification)
214
+ * - currentValue (so we can skip already-filled fields)
215
+ * - options[] (for native <select> AND custom comboboxes)
216
+ * - typeahead heuristics (aria-autocomplete, role=combobox, "Locate me" sibling)
217
+ * - iframeSelector when the field lives inside an iframe (iCIMS)
218
+ *
219
+ * One round trip per page — pulls all mmids at once for efficiency.
220
+ */
221
+ async function enrichFromDom(page, axFields) {
222
+ const mmids = axFields.map((f) => f.mmid);
223
+ if (mmids.length === 0) return [];
224
+
225
+ const enriched = await page.evaluate(({ mmids, mmidAttr }) => {
226
+ function safeText(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
227
+ function cssEscape(s) {
228
+ // CSS.escape isn't available in some older contexts; polyfill.
229
+ try { return CSS.escape(s); } catch { return s.replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g, '\\$1'); }
230
+ }
231
+
232
+ return mmids.map((mmid) => {
233
+ const el = document.querySelector(`[${mmidAttr}="${mmid}"]`);
234
+ if (!el) return { mmid, missing: true };
235
+
236
+ const tag = el.tagName.toLowerCase();
237
+ const type = (el.type || '').toLowerCase();
238
+ const role = (el.getAttribute('role') || '').toLowerCase();
239
+ const isContentEditable = el.isContentEditable || false;
240
+
241
+ // Skip non-visible elements UNLESS they're radio/checkbox/file (often
242
+ // styled-hidden but functional) or option lists in a dropdown.
243
+ let isVisible = true;
244
+ if (!['radio', 'checkbox', 'file'].includes(type) && role !== 'option') {
245
+ const rect = el.getBoundingClientRect();
246
+ if (rect.width === 0 && rect.height === 0) isVisible = false;
247
+ if (el.offsetParent === null && !el.closest('[role="dialog"]')) isVisible = false;
248
+ }
249
+
250
+ // Best selector — used by the executor to re-locate the element.
251
+ // mmid attribute is the most reliable since we control it.
252
+ let selectorHint = `[${mmidAttr}="${mmid}"]`;
253
+ if (el.id) selectorHint = `#${cssEscape(el.id)}`;
254
+ else if (el.name) selectorHint = `${tag}[name="${el.name.replace(/"/g, '\\"')}"]`;
255
+
256
+ // Current value (skip already-filled)
257
+ const currentValue = (el.value || (isContentEditable ? safeText(el.innerText) : '') || '').trim();
258
+
259
+ // Native <select> options
260
+ let options = null;
261
+ if (tag === 'select' && el.options) {
262
+ options = Array.from(el.options).map((o) => ({ value: o.value, label: safeText(o.text) })).filter((o) => o.label);
263
+ }
264
+
265
+ // Typeahead heuristic (Greenhouse Location, Lever City, Workday locations).
266
+ // Same logic as the old filler — kept here so the planner can reason
267
+ // about HOW to fill, not just WHAT.
268
+ const isTypeahead = !!(
269
+ el.getAttribute('aria-autocomplete') === 'list' ||
270
+ el.getAttribute('aria-autocomplete') === 'both' ||
271
+ el.getAttribute('aria-haspopup') === 'listbox' ||
272
+ el.getAttribute('aria-haspopup') === 'true' ||
273
+ (el.getAttribute('autocomplete') === 'off' && el.getAttribute('aria-expanded') !== null) ||
274
+ el.closest('[role="combobox"]') ||
275
+ Array.from((el.closest('div') || el.parentElement || document).querySelectorAll('button, a')).some((b) => /locate\s*me/i.test(b.textContent || ''))
276
+ );
277
+
278
+ // Iframe context — if the element is inside an iframe (iCIMS), the
279
+ // executor needs a frame-aware selector. We can't directly serialize
280
+ // a frame here, so we report the iframe src for matching later.
281
+ // (When this scanner runs on a frame's page, the frame itself is the
282
+ // page context — this only matters for the main-frame run.)
283
+ const ownerFrame = window.frameElement;
284
+ const iframeSrc = ownerFrame ? ownerFrame.src || ownerFrame.name || '' : '';
285
+
286
+ return {
287
+ mmid,
288
+ tag,
289
+ inputType: type,
290
+ role,
291
+ name: el.name || '',
292
+ id: el.id || '',
293
+ ariaLabel: el.getAttribute('aria-label') || '',
294
+ placeholder: el.placeholder || '',
295
+ selectorHint,
296
+ currentValue,
297
+ options,
298
+ isTypeahead,
299
+ iframeSrc,
300
+ isVisible,
301
+ isContentEditable,
302
+ // The element's own text content — for <button> / <a> the AX name
303
+ // sometimes misses the visible label (icons-only buttons surface
304
+ // weirdly). Keep as a fallback.
305
+ textContent: safeText(el.innerText || el.textContent || '').slice(0, 120),
306
+ };
307
+ });
308
+ }, { mmids, mmidAttr: MMID_ATTR });
309
+
310
+ // Merge AX side and DOM side by mmid
311
+ const domByMmid = new Map(enriched.map((e) => [e.mmid, e]));
312
+ return axFields.map((ax) => {
313
+ const dom = domByMmid.get(ax.mmid) || {};
314
+ return { ...ax, ...dom };
315
+ }).filter((f) => !f.missing && f.isVisible !== false);
316
+ }
317
+
318
+ // ── Step 5: Normalize into the agent-facing schema ───────────────────────────
319
+
320
+ /**
321
+ * Collapse AX role + HTML tag/type into one of our canonical interaction
322
+ * categories. The LLM planner reasons over THIS field, not the raw role,
323
+ * because the AX tree has 20+ roles but the agent only knows 7 ways to act.
324
+ */
325
+ function normalizeRole(f) {
326
+ if (f.inputType === 'file') return 'file_upload';
327
+ if (f.inputType === 'checkbox' || f.role === 'checkbox' || f.role === 'switch') return 'checkbox';
328
+ if (f.inputType === 'radio' || f.role === 'radio') return 'radio';
329
+ if (f.tag === 'select' || f.role === 'combobox' || f.role === 'listbox') return 'combobox';
330
+ if (f.tag === 'textarea' || f.isContentEditable) return 'textarea';
331
+ if (f.tag === 'button' || f.role === 'button') return 'button';
332
+ if (f.role === 'option' || f.role === 'menuitem') return 'option';
333
+ if (f.tag === 'a') return 'link';
334
+ // Default: free-text input. Typeahead is still a textbox; planner sees
335
+ // isTypeahead and chooses type-then-pick-suggestion.
336
+ return 'textbox';
337
+ }
338
+
339
+ function pickLabel(f) {
340
+ // Priority: AX name → aria-label → placeholder → text content → field id.
341
+ // (AX name already merges <label for>, aria-labelledby, fieldset/legend
342
+ // walks — we get them all for free.)
343
+ return (f.rawName || f.ariaLabel || f.placeholder || f.textContent || f.id || f.name || '').trim();
344
+ }
345
+
346
+ function pickDescription(f) {
347
+ // AX description first (helper text, hints), placeholder second.
348
+ return (f.description || (f.rawName && f.placeholder ? f.placeholder : '')).trim();
349
+ }
350
+
351
+ // ── Public entry point ───────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Main scanner. Returns a flat list of fields the LLM planner can plan over.
355
+ * Iterates same-origin frames so iCIMS/Workday iframes don't get missed.
356
+ */
357
+ async function scanAccessibility(page) {
358
+ // Run injection + AX fetch on the main frame AND every accessible same-
359
+ // origin frame. Cross-origin frames are blocked; we silently skip them.
360
+ const frames = [page, ...page.frames().filter((f) => f !== page.mainFrame())];
361
+ const allFields = [];
362
+
363
+ for (const ctx of frames) {
364
+ try {
365
+ // Each frame's "page" is the frame itself — playwright Page and Frame
366
+ // both expose evaluate(). For non-main frames we still need a CDP
367
+ // session against the underlying page, but the AX tree we fetch
368
+ // pertains to the entire frame tree from the root. We only fetch it
369
+ // once (on the main page) and reconcile against all frames' DOMs.
370
+ const isMain = ctx === page || ctx === page.mainFrame?.();
371
+
372
+ const injected = await (isMain
373
+ ? injectMmid(page)
374
+ : ctx.evaluate(({ mmidAttr, handleAttr }) => {
375
+ const sel = 'input,textarea,select,[contenteditable="true"],[role="textbox"],[role="combobox"],[role="listbox"],[role="radiogroup"],[role="radio"],[role="checkbox"],[role="switch"],[role="button"],[role="option"],button[type="submit"],a[href]';
376
+ // Continue numbering from a high offset so frames don't collide
377
+ // with main-frame ids. 100000 * frame index is plenty.
378
+ let n = Math.floor(Math.random() * 90000) + 100000;
379
+ const all = document.querySelectorAll(sel);
380
+ all.forEach((el) => {
381
+ n += 1; const id = String(n);
382
+ el.setAttribute(mmidAttr, id);
383
+ const prev = el.getAttribute(handleAttr);
384
+ if (prev && !el.hasAttribute('data-orig-aria-keyshortcuts')) {
385
+ el.setAttribute('data-orig-aria-keyshortcuts', prev);
386
+ }
387
+ el.setAttribute(handleAttr, id);
388
+ });
389
+ return all.length;
390
+ }, { mmidAttr: MMID_ATTR, handleAttr: HANDLE_ATTR }));
391
+
392
+ if (!injected || injected === 0) continue;
393
+
394
+ // Only the main page can drive CDP; for frames, AX is reachable from
395
+ // the same root tree fetched on the main page (it includes all frame
396
+ // subtrees). Skip the per-frame fetch.
397
+ if (!isMain) {
398
+ // For frames, just enrich DOM — we'll re-run reconcile after the
399
+ // main-page AX fetch. Stash the frame's DOM data only.
400
+ // (Simpler: skip frames entirely in v1 — the main-frame AX tree
401
+ // doesn't include cross-realm frame nodes anyway.)
402
+ continue;
403
+ }
404
+
405
+ const axNodes = await fetchAxTree(page);
406
+ const axFields = reconcile(axNodes);
407
+ const enriched = await enrichFromDom(page, axFields);
408
+ allFields.push(...enriched);
409
+ } catch (e) {
410
+ console.warn(`[scanAx] Frame scan failed: ${e.message}`);
411
+ }
412
+ }
413
+
414
+ // Normalize, dedupe by mmid (frames could collide, though we offset above)
415
+ const seen = new Set();
416
+ const out = [];
417
+ for (const f of allFields) {
418
+ if (seen.has(f.mmid)) continue;
419
+ seen.add(f.mmid);
420
+ const label = pickLabel(f);
421
+ // Filter noise: no label AND no visible role → skip
422
+ if (!label && !['button', 'link'].includes(normalizeRole(f))) continue;
423
+ out.push({
424
+ mmid: f.mmid,
425
+ role: normalizeRole(f),
426
+ label,
427
+ description: pickDescription(f),
428
+ required: f.required,
429
+ disabled: f.disabled,
430
+ options: f.options ? f.options.map((o) => o.label) : null,
431
+ selectorHint: f.selectorHint,
432
+ inputType: f.inputType,
433
+ currentValue: f.currentValue,
434
+ isTypeahead: f.isTypeahead,
435
+ groupLabel: f.groupLabel,
436
+ // Forward the raw text content for buttons/links the planner might click
437
+ textContent: f.textContent,
438
+ // Already-filled hint so the planner can skip
439
+ filledAlready: !!(f.currentValue && f.currentValue.length > 0),
440
+ });
441
+ }
442
+
443
+ console.log(`[scanAx] ${out.length} fields detected (${out.filter((f) => f.role === 'textbox').length} text, ${out.filter((f) => f.role === 'combobox').length} dropdowns, ${out.filter((f) => f.role === 'file_upload').length} files, ${out.filter((f) => f.role === 'button').length} buttons)`);
444
+ return out;
445
+ }
446
+
447
+ module.exports = { scanAccessibility };
package/smartFill.js ADDED
@@ -0,0 +1,362 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The smart fill loop. One round per page:
5
+ * 1. Scan the page via scanAccessibility (AX tree + mmid injection).
6
+ * 2. POST those fields to /smartfill/plan-fill with canonical facts.
7
+ * 3. Execute the returned action plan deterministically.
8
+ *
9
+ * Replaces the old fillFields/fillLocator stack. The action executor here
10
+ * is the ONLY place that touches the DOM — every "how do I fill X" question
11
+ * lives in one switch statement instead of being spread across filler.js,
12
+ * scanPage.js, and the orchestrator.
13
+ *
14
+ * Falls back to legacy fillFields when /smartfill/plan-fill returns 502
15
+ * (LLM down, parser bug, etc.) — so a planner outage degrades to the old
16
+ * agent rather than freezing.
17
+ */
18
+
19
+ const { scanAccessibility } = require('./scanAccessibility');
20
+ const { fillFields: legacyFillFields } = require('./filler');
21
+
22
+ // ── Plan executor ───────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Execute ONE plan entry. Returns:
26
+ * { ok: true, reason: '...' } on success
27
+ * { ok: false, reason: '...' } on failure (executor will continue with next)
28
+ * { ok: 'ask_user', reason } when planner asked us to surface
29
+ */
30
+ async function executePlanItem(page, item, fieldByMmid, ctx) {
31
+ const field = fieldByMmid.get(item.mmid);
32
+ if (!field) return { ok: false, reason: 'mmid vanished' };
33
+
34
+ const labelShort = (field.label || field.selectorHint || '?').slice(0, 50);
35
+
36
+ // Re-locate the element via the mmid attribute (the executor's anchor).
37
+ // Falls back to selectorHint if mmid was wiped (rare; happens on full
38
+ // re-renders between scan and execute).
39
+ let locator = page.locator(`[mmid="${item.mmid}"]`).first();
40
+ let visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
41
+ if (!visible && field.selectorHint) {
42
+ locator = page.locator(field.selectorHint).first();
43
+ visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
44
+ }
45
+ if (!visible) {
46
+ return { ok: false, reason: `element not visible (mmid=${item.mmid})` };
47
+ }
48
+
49
+ switch (item.action) {
50
+ case 'skip':
51
+ return { ok: true, reason: `skip: ${item.reasoning || 'planner skipped'}` };
52
+
53
+ case 'ask_user':
54
+ return { ok: 'ask_user', reason: item.reasoning || 'planner requested human input' };
55
+
56
+ case 'type': {
57
+ if (!item.value) return { ok: true, reason: 'skip: empty value' };
58
+ // Typeahead path: open suggestion list, pick first match.
59
+ if (field.isTypeahead) {
60
+ return await typeAndPickSuggestion(page, locator, item.value);
61
+ }
62
+ return await reactSafeType(page, locator, item.value);
63
+ }
64
+
65
+ case 'select_option': {
66
+ // Native <select>. value is the option LABEL (model was instructed to
67
+ // pick from the options[] list).
68
+ try {
69
+ await locator.selectOption({ label: item.value }).catch(async () => {
70
+ await locator.selectOption({ value: item.value });
71
+ });
72
+ return { ok: true, reason: `select: ${item.value}` };
73
+ } catch (e) {
74
+ return { ok: false, reason: `select failed: ${e.message}` };
75
+ }
76
+ }
77
+
78
+ case 'click_option': {
79
+ // Custom dropdown: click trigger, find option by text, click it.
80
+ try {
81
+ await locator.click({ timeout: 2500 });
82
+ await page.waitForTimeout(300);
83
+ // Look for an option whose visible text equals the planner's pick.
84
+ const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"]';
85
+ const opts = page.locator(optionSel);
86
+ const count = await opts.count().catch(() => 0);
87
+ if (count === 0) return { ok: false, reason: 'dropdown opened but no options' };
88
+ const texts = await opts.allTextContents().catch(() => []);
89
+ const v = String(item.value).toLowerCase().trim();
90
+ let idx = texts.findIndex((t) => t.toLowerCase().trim() === v);
91
+ if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().includes(v) || v.includes(t.toLowerCase()));
92
+ if (idx === -1) {
93
+ await page.keyboard.press('Escape').catch(() => {});
94
+ return { ok: false, reason: `option "${item.value}" not in dropdown (have: ${texts.slice(0, 5).join(', ')})` };
95
+ }
96
+ await opts.nth(idx).click({ timeout: 2000 });
97
+ return { ok: true, reason: `clicked option: ${texts[idx]}` };
98
+ } catch (e) {
99
+ await page.keyboard.press('Escape').catch(() => {});
100
+ return { ok: false, reason: `click_option failed: ${e.message}` };
101
+ }
102
+ }
103
+
104
+ case 'set_checkbox': {
105
+ try {
106
+ const wantChecked = String(item.value).toLowerCase() === 'true';
107
+ const isChecked = await locator.isChecked().catch(() => false);
108
+ if (wantChecked !== isChecked) await locator.click({ timeout: 2000 });
109
+ return { ok: true, reason: `checkbox: ${wantChecked}` };
110
+ } catch (e) {
111
+ return { ok: false, reason: `checkbox failed: ${e.message}` };
112
+ }
113
+ }
114
+
115
+ case 'set_radio': {
116
+ // Radios identified by mmid point to ONE option; the planner's value is
117
+ // the label of the right one. Walk the radiogroup and click the match.
118
+ try {
119
+ // Strategy 1: native radio with name= — find by name + label match.
120
+ const name = field.name;
121
+ if (name) {
122
+ const group = page.locator(`input[type="radio"][name="${name}"]`);
123
+ const gc = await group.count();
124
+ for (let i = 0; i < gc; i++) {
125
+ const rad = group.nth(i);
126
+ const id = await rad.getAttribute('id').catch(() => null);
127
+ let lbl = '';
128
+ if (id) {
129
+ lbl = (await page.locator(`label[for="${id}"]`).first().textContent().catch(() => '') || '').trim();
130
+ }
131
+ if (!lbl) lbl = (await rad.evaluate(el => el.value || '').catch(() => '') || '');
132
+ if (lbl.toLowerCase().includes(String(item.value).toLowerCase())) {
133
+ await rad.check({ force: true, timeout: 1500 }).catch(() => rad.click({ force: true }));
134
+ return { ok: true, reason: `radio: ${lbl}` };
135
+ }
136
+ }
137
+ }
138
+ // Strategy 2: just click the labeled option directly (custom radios).
139
+ const v = String(item.value);
140
+ const opt = page.locator(`[role="radio"]:has-text("${v}"), label:has-text("${v}")`).first();
141
+ if (await opt.isVisible({ timeout: 800 }).catch(() => false)) {
142
+ await opt.click({ timeout: 1500 });
143
+ return { ok: true, reason: `radio (label): ${v}` };
144
+ }
145
+ return { ok: false, reason: `radio option "${item.value}" not found` };
146
+ } catch (e) {
147
+ return { ok: false, reason: `radio failed: ${e.message}` };
148
+ }
149
+ }
150
+
151
+ case 'upload_file': {
152
+ const which = String(item.value).toLowerCase().trim();
153
+ const path = which === 'cover_letter'
154
+ ? ctx.coverLetterPath
155
+ : ctx.resumePath;
156
+ if (!path) return { ok: false, reason: `no ${which} file available` };
157
+ try {
158
+ // Unhide so setInputFiles doesn't reject a 0x0 element
159
+ await locator.evaluate((el) => {
160
+ el.style.display = 'block';
161
+ el.style.opacity = '1';
162
+ el.style.visibility = 'visible';
163
+ el.style.width = '1px';
164
+ el.style.height = '1px';
165
+ }).catch(() => {});
166
+ await locator.setInputFiles(path, { timeout: 6000 });
167
+ return { ok: true, reason: `uploaded ${which}` };
168
+ } catch (e) {
169
+ return { ok: false, reason: `upload failed: ${e.message}` };
170
+ }
171
+ }
172
+
173
+ default:
174
+ return { ok: false, reason: `unknown action: ${item.action}` };
175
+ }
176
+ }
177
+
178
+ // ── Strategy helpers (shared by type + typeahead) ────────────────────────────
179
+
180
+ /**
181
+ * React-safe text fill. Same ladder as the legacy filler — fill, then
182
+ * keyboard.type, then native-setter — but with verify-after-write to catch
183
+ * React rejecting our value.
184
+ */
185
+ async function reactSafeType(page, locator, value) {
186
+ const v = String(value);
187
+ try {
188
+ await locator.fill(v, { timeout: 4000 });
189
+ const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
190
+ if (got && got.trim()) return { ok: true, reason: `typed (fill): "${v.slice(0, 30)}"` };
191
+ } catch {}
192
+ try {
193
+ await locator.click({ timeout: 1500 });
194
+ await locator.fill('', { timeout: 1500 }).catch(() => {});
195
+ await page.keyboard.type(v, { delay: 20 });
196
+ const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
197
+ if (got && got.trim()) return { ok: true, reason: `typed (keyboard): "${v.slice(0, 30)}"` };
198
+ } catch {}
199
+ try {
200
+ await locator.evaluate((el, val) => {
201
+ el.focus();
202
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
203
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
204
+ if (setter) setter.call(el, val); else el.value = val;
205
+ el.dispatchEvent(new Event('input', { bubbles: true }));
206
+ el.dispatchEvent(new Event('change', { bubbles: true }));
207
+ el.blur();
208
+ }, v);
209
+ const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
210
+ if (got && got.trim()) return { ok: true, reason: `typed (native): "${v.slice(0, 30)}"` };
211
+ } catch {}
212
+ return { ok: false, reason: 'all type strategies returned empty value' };
213
+ }
214
+
215
+ /**
216
+ * Type-and-pick for typeahead inputs (Greenhouse Location etc).
217
+ */
218
+ async function typeAndPickSuggestion(page, locator, value) {
219
+ try {
220
+ await locator.click({ timeout: 2000 });
221
+ await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
222
+ await locator.press('Delete').catch(() => {});
223
+ const firstChunk = String(value).split(/[,;]/)[0].trim();
224
+ await page.keyboard.type(firstChunk, { delay: 60 });
225
+ await page.waitForTimeout(700);
226
+ const optionSel = '[role="option"], [role="listbox"] li, .select__option, ul[class*="autocomplete"] li';
227
+ const opts = page.locator(optionSel);
228
+ const count = await opts.count().catch(() => 0);
229
+ if (count === 0) {
230
+ // Some fields accept the typed value directly. Verify.
231
+ const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
232
+ if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
233
+ return { ok: false, reason: 'typeahead opened no suggestions' };
234
+ }
235
+ const texts = await opts.allTextContents().catch(() => []);
236
+ const v = firstChunk.toLowerCase();
237
+ let idx = texts.findIndex((t) => t.toLowerCase().trim() === v);
238
+ if (idx === -1) idx = texts.findIndex((t) => t.toLowerCase().trim().startsWith(v));
239
+ if (idx === -1) idx = 0;
240
+ await opts.nth(idx).click({ timeout: 2000 });
241
+ await page.waitForTimeout(200);
242
+ return { ok: true, reason: `typeahead picked: ${texts[idx]}` };
243
+ } catch (e) {
244
+ return { ok: false, reason: `typeahead failed: ${e.message}` };
245
+ }
246
+ }
247
+
248
+ // ── Main entry: scan + plan + execute one round on the current page ──────────
249
+
250
+ /**
251
+ * @param {Page} page
252
+ * @param {object} aep — the agent execution packet (carries facts)
253
+ * @param {object} options
254
+ * @param {object} options.config — agent config (apiUrl, token)
255
+ * @param {string} options.jobId
256
+ * @param {string} options.resumePath
257
+ * @param {string|null} options.coverLetterPath
258
+ * @param {object} options.ctx — fill context (answeredFields map etc)
259
+ * @returns {Promise<{filled, skipped, failed, askUserReasons, planned}>}
260
+ */
261
+ async function smartFillPage(page, aep, options) {
262
+ const { config, jobId, resumePath, coverLetterPath, ctx } = options;
263
+
264
+ // 1. Scan via AX tree
265
+ const fields = await scanAccessibility(page).catch((e) => {
266
+ console.warn(`[smartFill] scanAccessibility failed: ${e.message}`);
267
+ return [];
268
+ });
269
+ if (fields.length === 0) {
270
+ console.warn('[smartFill] no fields detected on page');
271
+ return { filled: 0, skipped: 0, failed: 0, askUserReasons: [], planned: 0, fallback: false };
272
+ }
273
+
274
+ // 2. Build already_filled snapshot from ctx for the planner
275
+ const alreadyFilled = [];
276
+ if (ctx && ctx.answeredFields) {
277
+ for (const [label, info] of ctx.answeredFields) {
278
+ alreadyFilled.push({ label, value: String(info.value || '').slice(0, 80) });
279
+ }
280
+ }
281
+
282
+ // 3. Request the plan
283
+ let plan = null;
284
+ try {
285
+ const res = await fetch(`${config.apiUrl}/smartfill/plan-fill`, {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
288
+ body: JSON.stringify({ job_id: jobId, fields, already_filled: alreadyFilled }),
289
+ });
290
+ if (res.ok) {
291
+ const j = await res.json();
292
+ plan = Array.isArray(j.plan) ? j.plan : null;
293
+ } else {
294
+ console.warn(`[smartFill] plan-fill ${res.status}; falling back to legacy filler`);
295
+ }
296
+ } catch (e) {
297
+ console.warn(`[smartFill] plan-fill threw: ${e.message}; falling back`);
298
+ }
299
+
300
+ if (!plan) {
301
+ // Planner unavailable — degrade gracefully.
302
+ const legacyResult = await legacyFillFields(page, aep, {
303
+ ats: options.ats || 'generic',
304
+ ctx,
305
+ config,
306
+ jobId,
307
+ }).catch(() => ({ filled: 0, skipped: 0, failed: 0 }));
308
+ return { ...legacyResult, askUserReasons: [], planned: 0, fallback: true };
309
+ }
310
+
311
+ console.log(`[smartFill] planner returned ${plan.length} actions for ${fields.length} fields`);
312
+
313
+ // 4. Execute the plan in DOM order (planner doesn't dictate order; we
314
+ // mirror the field scan order so dependent fields fill after their parents).
315
+ const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
316
+ const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
317
+ let filled = 0, skipped = 0, failed = 0;
318
+ const askUserReasons = [];
319
+
320
+ for (const f of fields) {
321
+ const item = planByMmid.get(String(f.mmid));
322
+ if (!item) {
323
+ // Planner didn't include this field — skip silently (planner was told
324
+ // to return EVERY field, but if it missed one, treat as a skip).
325
+ continue;
326
+ }
327
+ const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
328
+ const result = await executePlanItem(page, item, fieldByMmid, { resumePath, coverLetterPath });
329
+ if (result.ok === 'ask_user') {
330
+ askUserReasons.push(`${labelShort}: ${result.reason}`);
331
+ console.warn(`[smartFill] ASK USER: "${labelShort}" — ${result.reason}`);
332
+ failed += 1;
333
+ continue;
334
+ }
335
+ if (result.ok) {
336
+ if (item.action === 'skip') {
337
+ console.log(`[smartFill] skip "${labelShort}" — ${result.reason}`);
338
+ skipped += 1;
339
+ } else {
340
+ console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
341
+ filled += 1;
342
+ // Track in ctx for cross-page dedup
343
+ if (ctx && ctx.answeredFields) {
344
+ ctx.answeredFields.set(f.label || f.selectorHint, {
345
+ value: item.value,
346
+ pageIndex: ctx.currentPageIndex || 0,
347
+ source: `planner:${item.action}`,
348
+ });
349
+ }
350
+ }
351
+ // Small jitter between actions so we don't slam the DOM
352
+ await page.waitForTimeout(80 + Math.random() * 120);
353
+ } else {
354
+ console.warn(`[smartFill] FAIL "${labelShort}" — ${result.reason}`);
355
+ failed += 1;
356
+ }
357
+ }
358
+
359
+ return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false };
360
+ }
361
+
362
+ module.exports = { smartFillPage };