halo-agent 1.3.4 → 1.3.5

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
@@ -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) {
@@ -362,6 +390,65 @@ async function fillLocator(page, locator, value, label) {
362
390
  * Strategy: click trigger → wait for option list → find closest matching option → click it.
363
391
  * Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
364
392
  */
393
+ /**
394
+ * Type-and-pick handler for autocomplete/typeahead inputs (Greenhouse
395
+ * Location, Lever City, Workday location pickers). Strategy:
396
+ *
397
+ * 1. Click the input to focus
398
+ * 2. Clear any existing text
399
+ * 3. Type the value SLOWLY, character-by-character (suggestions fire
400
+ * on each keystroke; typing too fast can race the network)
401
+ * 4. Wait for a listbox/option container to render
402
+ * 5. Pick the best matching option (exact > startsWith > first)
403
+ * 6. Verify the input now shows a committed value
404
+ *
405
+ * Returns false if no suggestion list appears — caller can fall back to
406
+ * a plain fill.
407
+ */
408
+ async function fillTypeahead(page, locator, value) {
409
+ try {
410
+ await locator.click({ timeout: 2000 });
411
+ // Clear via select-all + delete (more reliable than .fill('') for
412
+ // autocompletes that have their own state)
413
+ await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
414
+ await locator.press('Delete').catch(() => {});
415
+ // Type the FIRST PART (city name only) — typing "San Francisco, CA"
416
+ // when the typeahead expects "San Francisco" can suppress matches.
417
+ const firstChunk = String(value).split(/[,;]/)[0].trim();
418
+ await page.keyboard.type(firstChunk, { delay: 60 });
419
+ // Wait briefly for suggestions to render
420
+ await page.waitForTimeout(700);
421
+ // Look for a listbox / option container — multiple common patterns
422
+ const optionSel = [
423
+ '[role="option"]',
424
+ '[role="listbox"] li',
425
+ '[role="listbox"] [role="option"]',
426
+ '[class*="option"][class*="select"]',
427
+ '[class*="suggestion"]',
428
+ 'ul[class*="autocomplete"] li',
429
+ // Greenhouse-specific
430
+ '.select__option',
431
+ '.select__menu [role="option"]',
432
+ ].join(', ');
433
+ const options = page.locator(optionSel);
434
+ const count = await options.count().catch(() => 0);
435
+ if (count === 0) return false;
436
+ // Prefer exact-match option; fall back to "starts with"; else first.
437
+ const allTexts = await options.allTextContents().catch(() => []);
438
+ const v = firstChunk.toLowerCase();
439
+ let pickIdx = allTexts.findIndex(t => t.toLowerCase().trim() === v);
440
+ if (pickIdx === -1) pickIdx = allTexts.findIndex(t => t.toLowerCase().trim().startsWith(v));
441
+ if (pickIdx === -1) pickIdx = 0;
442
+ await options.nth(pickIdx).click({ timeout: 2000 });
443
+ await page.waitForTimeout(200);
444
+ // Verify the input now shows a real value
445
+ const got = await locator.inputValue({ timeout: 1000 }).catch(() => null);
446
+ return !!(got && got.trim());
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+
365
452
  async function fillCustomDropdown(page, triggerLocator, value) {
366
453
  try {
367
454
  await triggerLocator.click();
@@ -665,8 +752,37 @@ async function fillFields(page, aep, options = {}) {
665
752
  continue;
666
753
  }
667
754
 
668
- // File uploads — handled separately by uploadResume() in orchestrator
669
- if (source === 'file_upload') continue;
755
+ // File uploads — resume is handled separately by uploadResume() in the
756
+ // gate handler. Cover letter is its own thing: ATSes like Greenhouse and
757
+ // Ashby render a dedicated cover-letter file input alongside the resume
758
+ // one. When the user compiled a cover letter PDF (in PacketPrep studio),
759
+ // upload it here against the matching field.
760
+ if (source === 'file_upload') {
761
+ if (field.category === 'file:cover_letter' && aep.__coverLetterLocalPath) {
762
+ try {
763
+ // setInputFiles works directly on hidden <input type=file>; no need
764
+ // to traverse to a sibling button when we already have the input.
765
+ const inputLoc = field.selector ? page.locator(field.selector).first() : null;
766
+ if (inputLoc) {
767
+ // Some forms hide the input via CSS — unhide briefly so Playwright
768
+ // doesn't reject the setInputFiles call on a 0x0 element.
769
+ await inputLoc.evaluate(el => {
770
+ el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible';
771
+ el.style.width = '1px'; el.style.height = '1px';
772
+ }).catch(() => {});
773
+ await inputLoc.setInputFiles(aep.__coverLetterLocalPath, { timeout: 5000 });
774
+ console.log(`[filler] filled (file): "${labelShort}" = cover-letter.pdf`);
775
+ filled++;
776
+ if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value: 'cover-letter.pdf', pageIndex: ctx.currentPageIndex, source: 'file' });
777
+ await delay();
778
+ continue;
779
+ }
780
+ } catch (e) {
781
+ console.warn(`[filler] cover-letter upload failed for "${labelShort}": ${e.message}`);
782
+ }
783
+ }
784
+ continue;
785
+ }
670
786
 
671
787
  // EEO / consent — skip silently (vision handles EEO, user handles consent)
672
788
  if (source === 'skip') continue;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
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
@@ -597,8 +597,57 @@ function resolveFieldValues(fields, aep) {
597
597
  return { field, value: null, source: 'file_upload' };
598
598
  }
599
599
 
600
- // EEO/consentskip (handled by vision or user)
601
- if (cat === 'eeo' || cat === 'consent') {
600
+ // Consent checkboxes user-only (we don't auto-accept terms).
601
+ if (cat === 'consent') {
602
+ return { field, value: null, source: 'skip' };
603
+ }
604
+
605
+ // EEO — answer from profile self-ID when set, OR infer common
606
+ // implications from what IS set. The profile already collects
607
+ // gender_self_id / race / veteran / disability. We hand those back
608
+ // directly for matching dropdown labels, plus do a small amount of
609
+ // inference for derived questions ("transgender experience?" → No
610
+ // when user is cisgender, "veteran?" → No is the safe default the
611
+ // user can override). Anything we can't infer falls through to
612
+ // 'skip' so it gets reviewed.
613
+ if (cat === 'eeo') {
614
+ const label = (field.label || '').toLowerCase();
615
+ const pf = aep.profile_fill || {};
616
+ // Direct mapping by question text
617
+ if (/gender|sex/.test(label) && !/transgender|trans\s*experience/.test(label)) {
618
+ if (pf.gender) return { field, value: pf.gender, source: 'profile:eeo' };
619
+ }
620
+ if (/race|ethnicit|hispanic/.test(label)) {
621
+ if (pf.race) return { field, value: pf.race, source: 'profile:eeo' };
622
+ }
623
+ if (/veteran|military/.test(label)) {
624
+ if (pf.veteran) return { field, value: pf.veteran, source: 'profile:eeo' };
625
+ // Inference: most users are non-veterans; safer default is "No"
626
+ // (recruiters expect this field answered; blank → required-field
627
+ // error). User can change in profile.
628
+ return { field, value: 'I am not a protected veteran', source: 'profile:eeo-default' };
629
+ }
630
+ if (/disabilit/.test(label)) {
631
+ if (pf.disability) return { field, value: pf.disability, source: 'profile:eeo' };
632
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
633
+ }
634
+ // Transgender / trans experience: cisgender implication when gender
635
+ // is set to non-trans. Cisgender users almost never want to
636
+ // accidentally self-ID as trans; defaulting to "No" when gender is
637
+ // explicit is safer than skipping (which causes a required-field
638
+ // error). Don't infer when gender is unset — that becomes 'skip'.
639
+ if (/transgender|trans\s*experience/.test(label)) {
640
+ if (pf.gender) return { field, value: 'No', source: 'profile:eeo-inferred' };
641
+ }
642
+ // Sexual orientation — never infer. User must self-ID.
643
+ if (/sexual\s*orientation|orientation\b/.test(label)) {
644
+ return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
645
+ }
646
+ // Pronouns — derivable from gender
647
+ if (/pronoun/.test(label)) {
648
+ if (pf.gender && /female|woman/i.test(pf.gender)) return { field, value: 'she/her', source: 'profile:eeo-inferred' };
649
+ if (pf.gender && /male|man/i.test(pf.gender)) return { field, value: 'he/him', source: 'profile:eeo-inferred' };
650
+ }
602
651
  return { field, value: null, source: 'skip' };
603
652
  }
604
653