halo-agent 1.3.4 → 1.3.6

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/filler.js CHANGED
@@ -259,7 +259,7 @@ async function uploadFile(page, triggerSelector, filePath) {
259
259
  *
260
260
  * Returns true if fill succeeded.
261
261
  */
262
- async function fillLocator(page, locator, value, label) {
262
+ async function fillLocator(page, locator, value, label, dropdownCtx = null) {
263
263
  if (!value) return false;
264
264
 
265
265
  try {
@@ -271,7 +271,35 @@ async function fillLocator(page, locator, value, label) {
271
271
  isContentEditable: el.isContentEditable,
272
272
  hasOptions: el.tagName === 'SELECT',
273
273
  optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
274
- })).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0 }));
274
+ // Typeahead heuristics: any of these signals means "type then pick a
275
+ // suggestion." Greenhouse Location, Lever City, Workday locations all
276
+ // use this pattern — a plain text input that mounts a <ul role=listbox>
277
+ // of city matches as you type. Plain .fill() leaves the field
278
+ // visually populated but React clears it on blur because no
279
+ // suggestion was committed.
280
+ isTypeahead: !!(
281
+ el.getAttribute('aria-autocomplete') === 'list' ||
282
+ el.getAttribute('aria-autocomplete') === 'both' ||
283
+ el.getAttribute('aria-haspopup') === 'listbox' ||
284
+ el.getAttribute('aria-haspopup') === 'true' ||
285
+ el.getAttribute('autocomplete') === 'off' && el.getAttribute('aria-expanded') !== null ||
286
+ // Greenhouse + Lever wrap their typeahead in role=combobox parent
287
+ el.closest('[role="combobox"]') ||
288
+ // "Locate me" sibling button is a strong Greenhouse Location signal
289
+ Array.from((el.closest('div') || el.parentElement || document).querySelectorAll('button, a')).some(b => /locate\s*me/i.test(b.textContent || ''))
290
+ ),
291
+ })).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0, isTypeahead: false }));
292
+
293
+ // ── Typeahead text input (Greenhouse Location, Lever City) ──
294
+ // Check BEFORE the generic combobox path so a plain <input> with
295
+ // typeahead-flagged ancestors still gets the type-then-pick treatment
296
+ // (those inputs aren't role=combobox themselves).
297
+ if (meta.isTypeahead && (meta.tag === 'input' || meta.tag === 'textarea')) {
298
+ const ok = await fillTypeahead(page, locator, value);
299
+ if (ok) return true;
300
+ // Fall through to default text-input path if typeahead pick failed —
301
+ // some forms accept the typed value even without selecting a suggestion.
302
+ }
275
303
 
276
304
  // ── Native <select> ──
277
305
  if (meta.tag === 'select' || meta.hasOptions) {
@@ -293,13 +321,50 @@ async function fillLocator(page, locator, value, label) {
293
321
  );
294
322
  if (fuzzy) ok = await locator.selectOption(fuzzy.v).then(() => true).catch(() => false);
295
323
  }
324
+ // 5. AI synonym pick — "Straight" → "Heterosexual", "Asian" →
325
+ // "South Asian" etc. We hand the LLM the user's literal answer
326
+ // plus the EXACT option list and let it pick the equivalent.
327
+ if (!ok && dropdownCtx && opts.length > 0) {
328
+ try {
329
+ const optionTexts = opts.map(o => o.t).filter(Boolean);
330
+ const res = await fetch(`${dropdownCtx.config.apiUrl}/smartfill/field-answer`, {
331
+ method: 'POST',
332
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${dropdownCtx.config.token}` },
333
+ body: JSON.stringify({
334
+ job_id: dropdownCtx.jobId,
335
+ field_label: label || '',
336
+ field_type: 'select',
337
+ options: optionTexts,
338
+ previously_answered: [{ label: 'user intent', value: String(value) }],
339
+ }),
340
+ });
341
+ if (res.ok) {
342
+ const { value: picked } = await res.json();
343
+ if (picked && picked !== 'SKIP') {
344
+ const pickedV = picked.toLowerCase().trim();
345
+ const match = opts.find(o => o.t.toLowerCase().trim() === pickedV)
346
+ || opts.find(o => o.t.toLowerCase().includes(pickedV) || pickedV.includes(o.t.toLowerCase()));
347
+ if (match) {
348
+ ok = await locator.selectOption(match.v).then(() => true).catch(() => false);
349
+ if (ok) console.log(`[filler] AI-picked select: "${label}" "${value}" → "${match.t}"`);
350
+ }
351
+ }
352
+ }
353
+ } catch (e) {
354
+ console.warn(`[filler] AI native-select pick failed for "${label}": ${e.message}`);
355
+ }
356
+ }
296
357
  return ok;
297
358
  }
298
359
 
299
360
  // ── Custom dropdown / combobox (React Select, Ashby, Lever custom selects) ──
300
361
  if (meta.role === 'combobox' || meta.role === 'listbox' ||
301
362
  meta.tag === 'div' || meta.tag === 'button') {
302
- return await fillCustomDropdown(page, locator, value);
363
+ return await fillCustomDropdown(page, locator, value, dropdownCtx ? {
364
+ config: dropdownCtx.config,
365
+ jobId: dropdownCtx.jobId,
366
+ label,
367
+ } : {});
303
368
  }
304
369
 
305
370
  // ── Radio group (native) ──
@@ -362,49 +427,144 @@ async function fillLocator(page, locator, value, label) {
362
427
  * Strategy: click trigger → wait for option list → find closest matching option → click it.
363
428
  * Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
364
429
  */
365
- async function fillCustomDropdown(page, triggerLocator, value) {
430
+ /**
431
+ * Type-and-pick handler for autocomplete/typeahead inputs (Greenhouse
432
+ * Location, Lever City, Workday location pickers). Strategy:
433
+ *
434
+ * 1. Click the input to focus
435
+ * 2. Clear any existing text
436
+ * 3. Type the value SLOWLY, character-by-character (suggestions fire
437
+ * on each keystroke; typing too fast can race the network)
438
+ * 4. Wait for a listbox/option container to render
439
+ * 5. Pick the best matching option (exact > startsWith > first)
440
+ * 6. Verify the input now shows a committed value
441
+ *
442
+ * Returns false if no suggestion list appears — caller can fall back to
443
+ * a plain fill.
444
+ */
445
+ async function fillTypeahead(page, locator, value) {
446
+ try {
447
+ await locator.click({ timeout: 2000 });
448
+ // Clear via select-all + delete (more reliable than .fill('') for
449
+ // autocompletes that have their own state)
450
+ await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
451
+ await locator.press('Delete').catch(() => {});
452
+ // Type the FIRST PART (city name only) — typing "San Francisco, CA"
453
+ // when the typeahead expects "San Francisco" can suppress matches.
454
+ const firstChunk = String(value).split(/[,;]/)[0].trim();
455
+ await page.keyboard.type(firstChunk, { delay: 60 });
456
+ // Wait briefly for suggestions to render
457
+ await page.waitForTimeout(700);
458
+ // Look for a listbox / option container — multiple common patterns
459
+ const optionSel = [
460
+ '[role="option"]',
461
+ '[role="listbox"] li',
462
+ '[role="listbox"] [role="option"]',
463
+ '[class*="option"][class*="select"]',
464
+ '[class*="suggestion"]',
465
+ 'ul[class*="autocomplete"] li',
466
+ // Greenhouse-specific
467
+ '.select__option',
468
+ '.select__menu [role="option"]',
469
+ ].join(', ');
470
+ const options = page.locator(optionSel);
471
+ const count = await options.count().catch(() => 0);
472
+ if (count === 0) return false;
473
+ // Prefer exact-match option; fall back to "starts with"; else first.
474
+ const allTexts = await options.allTextContents().catch(() => []);
475
+ const v = firstChunk.toLowerCase();
476
+ let pickIdx = allTexts.findIndex(t => t.toLowerCase().trim() === v);
477
+ if (pickIdx === -1) pickIdx = allTexts.findIndex(t => t.toLowerCase().trim().startsWith(v));
478
+ if (pickIdx === -1) pickIdx = 0;
479
+ await options.nth(pickIdx).click({ timeout: 2000 });
480
+ await page.waitForTimeout(200);
481
+ // Verify the input now shows a real value
482
+ const got = await locator.inputValue({ timeout: 1000 }).catch(() => null);
483
+ return !!(got && got.trim());
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+
489
+ async function fillCustomDropdown(page, triggerLocator, value, options = {}) {
366
490
  try {
367
491
  await triggerLocator.click();
368
492
  await page.waitForTimeout(350);
369
493
 
370
- // Look for an open listbox, menu, or option list
371
- const optionSelectors = [
372
- `[role="option"]:has-text("${value}")`,
373
- `[role="menuitem"]:has-text("${value}")`,
374
- `li:has-text("${value}")`,
375
- `.Select-option:has-text("${value}")`,
376
- `[class*="option"]:has-text("${value}")`,
377
- ];
378
-
379
- for (const sel of optionSelectors) {
380
- try {
381
- const opt = page.locator(sel).first();
382
- if (await opt.isVisible({ timeout: 800 })) {
383
- await opt.click();
384
- return true;
385
- }
386
- } catch {}
494
+ // STEP 1: Discover ALL available options (visible after opening). We
495
+ // need this for two reasons:
496
+ // a) substring/exact match locally (cheap, no LLM round trip)
497
+ // b) if no local match, ask the LLM to pick from this exact list —
498
+ // that's how "Straight" maps to "Heterosexual" without us having
499
+ // to hardcode a synonym table for every form
500
+ // We collect texts BEFORE any click attempts so the LLM has the same
501
+ // option universe the user would see.
502
+ const optionLocators = page.locator('[role="option"], [role="menuitem"], .select__option, li[class*="option"]');
503
+ const optionCount = await optionLocators.count().catch(() => 0);
504
+ const optionTexts = [];
505
+ for (let i = 0; i < Math.min(optionCount, 40); i++) {
506
+ const t = (await optionLocators.nth(i).textContent().catch(() => '')).trim();
507
+ if (t) optionTexts.push(t);
387
508
  }
388
509
 
389
- // Fuzzy: get all visible options and find closest match
390
- const allOptions = await page.locator('[role="option"], [role="menuitem"]').all();
391
- let bestMatch = null;
392
- let bestScore = 0;
393
- for (const opt of allOptions) {
394
- const text = (await opt.textContent().catch(() => '')).trim().toLowerCase();
395
- const target = value.toLowerCase();
396
- const score = text === target ? 1 :
397
- text.includes(target) ? 0.8 :
398
- target.includes(text) ? 0.7 : 0;
399
- if (score > bestScore) { bestScore = score; bestMatch = opt; }
510
+ // STEP 2: Local exact match (case-insensitive)
511
+ const v = String(value).toLowerCase().trim();
512
+ let pickIdx = optionTexts.findIndex(t => t.toLowerCase().trim() === v);
513
+
514
+ // STEP 3: Local substring match (either direction)
515
+ if (pickIdx === -1) {
516
+ pickIdx = optionTexts.findIndex(t => {
517
+ const tt = t.toLowerCase().trim();
518
+ return tt.includes(v) || v.includes(tt);
519
+ });
400
520
  }
401
- if (bestMatch && bestScore >= 0.7) {
402
- await bestMatch.click();
521
+
522
+ if (pickIdx !== -1) {
523
+ await optionLocators.nth(pickIdx).click({ timeout: 2000 });
403
524
  return true;
404
525
  }
405
526
 
406
- // Nothing found press Escape to close the dropdown
407
- await page.keyboard.press('Escape');
527
+ // STEP 4: LLM picks from the option universe — the smart-fill path.
528
+ // Sends user's intent ("Straight") + question text + the EXACT list
529
+ // of options Greenhouse is rendering ("Heterosexual", "Gay or
530
+ // Lesbian", "Bisexual", "Prefer not to say"). Backend already has
531
+ // the /smartfill/field-answer endpoint with options[] support.
532
+ if (optionTexts.length > 0 && options.config && options.jobId && options.label) {
533
+ try {
534
+ const res = await fetch(`${options.config.apiUrl}/smartfill/field-answer`, {
535
+ method: 'POST',
536
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.config.token}` },
537
+ body: JSON.stringify({
538
+ job_id: options.jobId,
539
+ field_label: options.label,
540
+ field_type: 'select',
541
+ options: optionTexts,
542
+ // Pass user's stated intent so the LLM picks the closest
543
+ // option that REPRESENTS that intent, not just text similarity.
544
+ previously_answered: [{ label: 'user intent', value: String(value) }],
545
+ }),
546
+ });
547
+ if (res.ok) {
548
+ const { value: picked } = await res.json();
549
+ if (picked && picked !== 'SKIP') {
550
+ // Match the picked text back to an option locator
551
+ const pickedV = picked.toLowerCase().trim();
552
+ const aiIdx = optionTexts.findIndex(t => t.toLowerCase().trim() === pickedV)
553
+ ?? optionTexts.findIndex(t => t.toLowerCase().includes(pickedV) || pickedV.includes(t.toLowerCase()));
554
+ if (aiIdx !== -1 && aiIdx >= 0) {
555
+ await optionLocators.nth(aiIdx).click({ timeout: 2000 });
556
+ console.log(`[filler] AI-picked dropdown: "${options.label}" "${value}" → "${optionTexts[aiIdx]}"`);
557
+ return true;
558
+ }
559
+ }
560
+ }
561
+ } catch (e) {
562
+ console.warn(`[filler] AI dropdown pick failed for "${options.label}": ${e.message}`);
563
+ }
564
+ }
565
+
566
+ // Nothing matched — close dropdown gracefully so the next field can fill
567
+ await page.keyboard.press('Escape').catch(() => {});
408
568
  return false;
409
569
  } catch {
410
570
  return false;
@@ -642,6 +802,12 @@ async function fillFields(page, aep, options = {}) {
642
802
  const speed = options.speed || 'normal';
643
803
  const ctx = options.ctx || null;
644
804
  const ats = options.ats || 'generic';
805
+ // dropdownCtx flows to fillLocator → fillCustomDropdown so custom selects
806
+ // can call the backend's /smartfill/field-answer with the question text +
807
+ // available options. That's the "Straight" → "Heterosexual" synonym fix.
808
+ const dropdownCtx = options.config && options.jobId
809
+ ? { config: options.config, jobId: options.jobId }
810
+ : null;
645
811
  const delay = () => new Promise(r => setTimeout(r, jitter(100 + Math.random() * 150)));
646
812
 
647
813
  let filled = 0, skipped = 0, failed = 0;
@@ -665,8 +831,37 @@ async function fillFields(page, aep, options = {}) {
665
831
  continue;
666
832
  }
667
833
 
668
- // File uploads — handled separately by uploadResume() in orchestrator
669
- if (source === 'file_upload') continue;
834
+ // File uploads — resume is handled separately by uploadResume() in the
835
+ // gate handler. Cover letter is its own thing: ATSes like Greenhouse and
836
+ // Ashby render a dedicated cover-letter file input alongside the resume
837
+ // one. When the user compiled a cover letter PDF (in PacketPrep studio),
838
+ // upload it here against the matching field.
839
+ if (source === 'file_upload') {
840
+ if (field.category === 'file:cover_letter' && aep.__coverLetterLocalPath) {
841
+ try {
842
+ // setInputFiles works directly on hidden <input type=file>; no need
843
+ // to traverse to a sibling button when we already have the input.
844
+ const inputLoc = field.selector ? page.locator(field.selector).first() : null;
845
+ if (inputLoc) {
846
+ // Some forms hide the input via CSS — unhide briefly so Playwright
847
+ // doesn't reject the setInputFiles call on a 0x0 element.
848
+ await inputLoc.evaluate(el => {
849
+ el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible';
850
+ el.style.width = '1px'; el.style.height = '1px';
851
+ }).catch(() => {});
852
+ await inputLoc.setInputFiles(aep.__coverLetterLocalPath, { timeout: 5000 });
853
+ console.log(`[filler] filled (file): "${labelShort}" = cover-letter.pdf`);
854
+ filled++;
855
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value: 'cover-letter.pdf', pageIndex: ctx.currentPageIndex, source: 'file' });
856
+ await delay();
857
+ continue;
858
+ }
859
+ } catch (e) {
860
+ console.warn(`[filler] cover-letter upload failed for "${labelShort}": ${e.message}`);
861
+ }
862
+ }
863
+ continue;
864
+ }
670
865
 
671
866
  // EEO / consent — skip silently (vision handles EEO, user handles consent)
672
867
  if (source === 'skip') continue;
@@ -746,7 +941,7 @@ async function fillFields(page, aep, options = {}) {
746
941
  await delay();
747
942
  } else {
748
943
  // Try clicking the locator directly (custom styled radio)
749
- const ok2 = await fillLocator(page, locator, value, field.label);
944
+ const ok2 = await fillLocator(page, locator, value, field.label, dropdownCtx);
750
945
  if (ok2) { filled++; if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source }); await delay(); }
751
946
  else { skipped++; }
752
947
  }
@@ -768,7 +963,7 @@ async function fillFields(page, aep, options = {}) {
768
963
 
769
964
  // All other types — use universal fillLocator
770
965
  try {
771
- const ok = await fillLocator(page, locator, value, field.label);
966
+ const ok = await fillLocator(page, locator, value, field.label, dropdownCtx);
772
967
  if (ok) {
773
968
  const valShort = String(value).slice(0, 40).replace(/\n/g, ' ');
774
969
  console.log(`[filler] filled (${source}): "${labelShort}" = "${valShort}${String(value).length > 40 ? '...' : ''}"`);
package/orchestrator.js CHANGED
@@ -68,6 +68,18 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
68
68
  tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
69
69
  }
70
70
 
71
+ // Download cover-letter PDF too — separate file because Greenhouse/Ashby
72
+ // have separate file inputs for each. Only present when the user
73
+ // compiled it in PacketPrep (cover_letter_pdf_status='ready').
74
+ let tempCoverLetterFile = null;
75
+ if (aep.cover_letter_pdf?.pdf_presigned_url) {
76
+ tempCoverLetterFile = await downloadResume(aep.cover_letter_pdf.pdf_presigned_url);
77
+ if (tempCoverLetterFile) console.log('[orchestrator] Cover letter PDF downloaded');
78
+ }
79
+ // Expose to fillFields via aep so the file:cover_letter category resolver
80
+ // can hand it to the uploader.
81
+ aep.__coverLetterLocalPath = tempCoverLetterFile;
82
+
71
83
  // Check for an existing checkpoint — if a previous run got past page 1
72
84
  // before crashing, resume from where it stopped. The fillFields path uses
73
85
  // ctx.answeredFields to skip already-completed labels, so restoring the
@@ -118,7 +130,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
118
130
  console.log(`[orchestrator] Using vision fallback for ${ats_type}`);
119
131
  fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
120
132
  } else {
121
- fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
133
+ fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
122
134
  }
123
135
 
124
136
  // Fetch AI answers for any fields that scanPage identified as needing AI
@@ -145,7 +157,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
145
157
  }
146
158
  }
147
159
  // Second fill pass with newly fetched answers
148
- const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
160
+ const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
149
161
  fillResult.filled += retryResult.filled;
150
162
  fillResult.skipped = retryResult.skipped;
151
163
  console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
@@ -268,7 +280,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
268
280
  ctx.currentPageIndex++;
269
281
 
270
282
  // Fill any new fields that appeared on this page
271
- const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
283
+ const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
272
284
  cumulativeFilled += pageResult.filled || 0;
273
285
 
274
286
  // Fetch AI answers for custom fields scanPage identified on this page
@@ -285,7 +297,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
285
297
  }
286
298
  } catch {}
287
299
  }
288
- const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
300
+ const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
289
301
  cumulativeFilled += retryPage.filled || 0;
290
302
  }
291
303
 
@@ -424,6 +436,22 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
424
436
  }
425
437
 
426
438
  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
+ }
427
455
  console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
428
456
  await reportStatus('REVIEWING', {
429
457
  review_screenshot_r2_key: confirmKey || null,
@@ -1018,7 +1046,7 @@ async function runExtensionFill({
1018
1046
  log(`Vision fill for ${ats_type}`);
1019
1047
  fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
1020
1048
  } else {
1021
- fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
1049
+ fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
1022
1050
  }
1023
1051
 
1024
1052
  reportFillStatus('FILLING', { fieldsFilled: fillResult.filled || 0, message: `Filled ${fillResult.filled || 0} fields...` });
@@ -1043,7 +1071,7 @@ async function runExtensionFill({
1043
1071
  }
1044
1072
  } catch (e) { log(`AI answer failed for "${f.label}": ${e.message}`); }
1045
1073
  }
1046
- const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
1074
+ const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
1047
1075
  fillResult.filled += retry.filled;
1048
1076
  fillResult.skipped = retry.skipped;
1049
1077
  }
@@ -1107,7 +1135,7 @@ async function runExtensionFill({
1107
1135
  }
1108
1136
 
1109
1137
  ctx.currentPageIndex++;
1110
- const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
1138
+ const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
1111
1139
  cumulativeFilled += pageResult.filled || 0;
1112
1140
 
1113
1141
  if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/scanPage.js CHANGED
@@ -268,6 +268,21 @@ async function scanGreenhouse(page) {
268
268
  if (f.id && ghSystemIds[f.id]) {
269
269
  return { ...f, category: f.id === 'resume' || f.id === 'cover_letter' ? 'file:' + f.id : 'profile:' + ghSystemIds[f.id] };
270
270
  }
271
+ // File inputs — Greenhouse's modern job-boards UI labels both file
272
+ // inputs as "Attach" (the visible button text), not "Resume" or
273
+ // "Cover Letter". Distinguish by name= attribute instead.
274
+ // resume: name="resume" or name="job_application[resume]"
275
+ // cover ltr: name="cover_letter" or name="job_application[cover_letter]"
276
+ if (f.inputType === 'file') {
277
+ const n = (f.name || '').toLowerCase();
278
+ if (n.includes('cover_letter') || n.includes('coverletter')) return { ...f, category: 'file:cover_letter' };
279
+ if (n.includes('resume') || n.includes('cv')) return { ...f, category: 'file:resume' };
280
+ // Order on the page: first file input is resume, second is cover letter.
281
+ // Without name/id signals, fall back to ordering. We mark unknowns
282
+ // 'file:resume' here and re-tag the second occurrence below after
283
+ // the map (where we have the whole list).
284
+ return { ...f, category: 'file:unknown' };
285
+ }
271
286
  // Custom question fields: id like question_12345678
272
287
  if (f.id && /^question_\d+$/.test(f.id)) {
273
288
  return { ...f, category: 'custom' };
@@ -281,7 +296,17 @@ async function scanGreenhouse(page) {
281
296
  return { ...f, category: 'profile:' + f.id.split('--')[0] };
282
297
  }
283
298
  return { ...f, category: classifyField(f) };
284
- }).filter(f => f.category !== 'ignore' && f.inputType !== 'hidden');
299
+ }).reduce((acc, f) => {
300
+ // Position-based resolution for ambiguous file inputs: first unknown
301
+ // file → resume, second → cover_letter. Greenhouse always renders
302
+ // resume above cover letter; the DOM walk preserves that order.
303
+ if (f.category === 'file:unknown') {
304
+ const sawResume = acc.some(p => p.category === 'file:resume');
305
+ f.category = sawResume ? 'file:cover_letter' : 'file:resume';
306
+ }
307
+ acc.push(f);
308
+ return acc;
309
+ }, []).filter(f => f.category !== 'ignore' && f.inputType !== 'hidden');
285
310
  }
286
311
 
287
312
  /**
@@ -597,8 +622,57 @@ function resolveFieldValues(fields, aep) {
597
622
  return { field, value: null, source: 'file_upload' };
598
623
  }
599
624
 
600
- // EEO/consentskip (handled by vision or user)
601
- if (cat === 'eeo' || cat === 'consent') {
625
+ // Consent checkboxes user-only (we don't auto-accept terms).
626
+ if (cat === 'consent') {
627
+ return { field, value: null, source: 'skip' };
628
+ }
629
+
630
+ // EEO — answer from profile self-ID when set, OR infer common
631
+ // implications from what IS set. The profile already collects
632
+ // gender_self_id / race / veteran / disability. We hand those back
633
+ // directly for matching dropdown labels, plus do a small amount of
634
+ // inference for derived questions ("transgender experience?" → No
635
+ // when user is cisgender, "veteran?" → No is the safe default the
636
+ // user can override). Anything we can't infer falls through to
637
+ // 'skip' so it gets reviewed.
638
+ if (cat === 'eeo') {
639
+ const label = (field.label || '').toLowerCase();
640
+ const pf = aep.profile_fill || {};
641
+ // Direct mapping by question text
642
+ if (/gender|sex/.test(label) && !/transgender|trans\s*experience/.test(label)) {
643
+ if (pf.gender) return { field, value: pf.gender, source: 'profile:eeo' };
644
+ }
645
+ if (/race|ethnicit|hispanic/.test(label)) {
646
+ if (pf.race) return { field, value: pf.race, source: 'profile:eeo' };
647
+ }
648
+ if (/veteran|military/.test(label)) {
649
+ if (pf.veteran) return { field, value: pf.veteran, source: 'profile:eeo' };
650
+ // Inference: most users are non-veterans; safer default is "No"
651
+ // (recruiters expect this field answered; blank → required-field
652
+ // error). User can change in profile.
653
+ return { field, value: 'I am not a protected veteran', source: 'profile:eeo-default' };
654
+ }
655
+ if (/disabilit/.test(label)) {
656
+ if (pf.disability) return { field, value: pf.disability, source: 'profile:eeo' };
657
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
658
+ }
659
+ // Transgender / trans experience: cisgender implication when gender
660
+ // is set to non-trans. Cisgender users almost never want to
661
+ // accidentally self-ID as trans; defaulting to "No" when gender is
662
+ // explicit is safer than skipping (which causes a required-field
663
+ // error). Don't infer when gender is unset — that becomes 'skip'.
664
+ if (/transgender|trans\s*experience/.test(label)) {
665
+ if (pf.gender) return { field, value: 'No', source: 'profile:eeo-inferred' };
666
+ }
667
+ // Sexual orientation — never infer. User must self-ID.
668
+ if (/sexual\s*orientation|orientation\b/.test(label)) {
669
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
670
+ }
671
+ // Pronouns — derivable from gender
672
+ if (/pronoun/.test(label)) {
673
+ if (pf.gender && /female|woman/i.test(pf.gender)) return { field, value: 'she/her', source: 'profile:eeo-inferred' };
674
+ if (pf.gender && /male|man/i.test(pf.gender)) return { field, value: 'he/him', source: 'profile:eeo-inferred' };
675
+ }
602
676
  return { field, value: null, source: 'skip' };
603
677
  }
604
678