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 +116 -37
- package/orchestrator.js +23 -7
- package/package.json +1 -1
- package/scanPage.js +26 -1
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
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
477
|
-
const
|
|
478
|
-
let
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
489
|
-
|
|
521
|
+
|
|
522
|
+
if (pickIdx !== -1) {
|
|
523
|
+
await optionLocators.nth(pickIdx).click({ timeout: 2000 });
|
|
490
524
|
return true;
|
|
491
525
|
}
|
|
492
526
|
|
|
493
|
-
//
|
|
494
|
-
|
|
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
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
|
-
}).
|
|
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
|
/**
|