halo-agent 1.3.5 → 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 {
@@ -321,13 +321,50 @@ async function fillLocator(page, locator, value, label) {
321
321
  );
322
322
  if (fuzzy) ok = await locator.selectOption(fuzzy.v).then(() => true).catch(() => false);
323
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
+ }
324
357
  return ok;
325
358
  }
326
359
 
327
360
  // ── Custom dropdown / combobox (React Select, Ashby, Lever custom selects) ──
328
361
  if (meta.role === 'combobox' || meta.role === 'listbox' ||
329
362
  meta.tag === 'div' || meta.tag === 'button') {
330
- 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
+ } : {});
331
368
  }
332
369
 
333
370
  // ── Radio group (native) ──
@@ -449,49 +486,85 @@ async function fillTypeahead(page, locator, value) {
449
486
  }
450
487
  }
451
488
 
452
- async function fillCustomDropdown(page, triggerLocator, value) {
489
+ async function fillCustomDropdown(page, triggerLocator, value, options = {}) {
453
490
  try {
454
491
  await triggerLocator.click();
455
492
  await page.waitForTimeout(350);
456
493
 
457
- // Look for an open listbox, menu, or option list
458
- const optionSelectors = [
459
- `[role="option"]:has-text("${value}")`,
460
- `[role="menuitem"]:has-text("${value}")`,
461
- `li:has-text("${value}")`,
462
- `.Select-option:has-text("${value}")`,
463
- `[class*="option"]:has-text("${value}")`,
464
- ];
465
-
466
- for (const sel of optionSelectors) {
467
- try {
468
- const opt = page.locator(sel).first();
469
- if (await opt.isVisible({ timeout: 800 })) {
470
- await opt.click();
471
- return true;
472
- }
473
- } 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);
474
508
  }
475
509
 
476
- // Fuzzy: get all visible options and find closest match
477
- const allOptions = await page.locator('[role="option"], [role="menuitem"]').all();
478
- let bestMatch = null;
479
- let bestScore = 0;
480
- for (const opt of allOptions) {
481
- const text = (await opt.textContent().catch(() => '')).trim().toLowerCase();
482
- const target = value.toLowerCase();
483
- const score = text === target ? 1 :
484
- text.includes(target) ? 0.8 :
485
- target.includes(text) ? 0.7 : 0;
486
- 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
+ });
487
520
  }
488
- if (bestMatch && bestScore >= 0.7) {
489
- await bestMatch.click();
521
+
522
+ if (pickIdx !== -1) {
523
+ await optionLocators.nth(pickIdx).click({ timeout: 2000 });
490
524
  return true;
491
525
  }
492
526
 
493
- // Nothing found press Escape to close the dropdown
494
- 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(() => {});
495
568
  return false;
496
569
  } catch {
497
570
  return false;
@@ -729,6 +802,12 @@ async function fillFields(page, aep, options = {}) {
729
802
  const speed = options.speed || 'normal';
730
803
  const ctx = options.ctx || null;
731
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;
732
811
  const delay = () => new Promise(r => setTimeout(r, jitter(100 + Math.random() * 150)));
733
812
 
734
813
  let filled = 0, skipped = 0, failed = 0;
@@ -862,7 +941,7 @@ async function fillFields(page, aep, options = {}) {
862
941
  await delay();
863
942
  } else {
864
943
  // Try clicking the locator directly (custom styled radio)
865
- const ok2 = await fillLocator(page, locator, value, field.label);
944
+ const ok2 = await fillLocator(page, locator, value, field.label, dropdownCtx);
866
945
  if (ok2) { filled++; if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source }); await delay(); }
867
946
  else { skipped++; }
868
947
  }
@@ -884,7 +963,7 @@ async function fillFields(page, aep, options = {}) {
884
963
 
885
964
  // All other types — use universal fillLocator
886
965
  try {
887
- const ok = await fillLocator(page, locator, value, field.label);
966
+ const ok = await fillLocator(page, locator, value, field.label, dropdownCtx);
888
967
  if (ok) {
889
968
  const valShort = String(value).slice(0, 40).replace(/\n/g, ' ');
890
969
  console.log(`[filler] filled (${source}): "${labelShort}" = "${valShort}${String(value).length > 40 ? '...' : ''}"`);
package/orchestrator.js CHANGED
@@ -130,7 +130,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
130
130
  console.log(`[orchestrator] Using vision fallback for ${ats_type}`);
131
131
  fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
132
132
  } else {
133
- 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 });
134
134
  }
135
135
 
136
136
  // Fetch AI answers for any fields that scanPage identified as needing AI
@@ -157,7 +157,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
157
157
  }
158
158
  }
159
159
  // Second fill pass with newly fetched answers
160
- 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 });
161
161
  fillResult.filled += retryResult.filled;
162
162
  fillResult.skipped = retryResult.skipped;
163
163
  console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
@@ -280,7 +280,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
280
280
  ctx.currentPageIndex++;
281
281
 
282
282
  // Fill any new fields that appeared on this page
283
- 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 });
284
284
  cumulativeFilled += pageResult.filled || 0;
285
285
 
286
286
  // Fetch AI answers for custom fields scanPage identified on this page
@@ -297,7 +297,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
297
297
  }
298
298
  } catch {}
299
299
  }
300
- 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 });
301
301
  cumulativeFilled += retryPage.filled || 0;
302
302
  }
303
303
 
@@ -436,6 +436,22 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
436
436
  }
437
437
 
438
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
+ }
439
455
  console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
440
456
  await reportStatus('REVIEWING', {
441
457
  review_screenshot_r2_key: confirmKey || null,
@@ -1030,7 +1046,7 @@ async function runExtensionFill({
1030
1046
  log(`Vision fill for ${ats_type}`);
1031
1047
  fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
1032
1048
  } else {
1033
- 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 });
1034
1050
  }
1035
1051
 
1036
1052
  reportFillStatus('FILLING', { fieldsFilled: fillResult.filled || 0, message: `Filled ${fillResult.filled || 0} fields...` });
@@ -1055,7 +1071,7 @@ async function runExtensionFill({
1055
1071
  }
1056
1072
  } catch (e) { log(`AI answer failed for "${f.label}": ${e.message}`); }
1057
1073
  }
1058
- 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 });
1059
1075
  fillResult.filled += retry.filled;
1060
1076
  fillResult.skipped = retry.skipped;
1061
1077
  }
@@ -1119,7 +1135,7 @@ async function runExtensionFill({
1119
1135
  }
1120
1136
 
1121
1137
  ctx.currentPageIndex++;
1122
- 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 });
1123
1139
  cumulativeFilled += pageResult.filled || 0;
1124
1140
 
1125
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.5",
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
  /**