halo-agent 2.0.1 → 2.0.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/smartFill.js +140 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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/smartFill.js CHANGED
@@ -55,6 +55,21 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
55
55
 
56
56
  case 'type': {
57
57
  if (!item.value) return { ok: true, reason: 'skip: empty value' };
58
+ // If the field is actually a combobox/dropdown (scanAccessibility
59
+ // tagged it that way), the planner shouldn't have typed — the value
60
+ // we got is the user's INTENT (e.g. "Straight"), and we need to
61
+ // pick the closest dropdown option ("Heterosexual"). This is the
62
+ // case where the planner saw no options[] (because they're
63
+ // hidden until clicked) and fell back to type. Recover by treating
64
+ // it as click_option.
65
+ if (field.role === 'combobox' || field.role === 'listbox') {
66
+ const result = await openAndPickOption(page, locator, item.value, {
67
+ config: ctx.config, jobId: ctx.jobId, label: field.label,
68
+ });
69
+ if (result.ok) return result;
70
+ // Fall through to text-fill if no option matched at all
71
+ // (some Greenhouse comboboxes accept free-text in addition to options).
72
+ }
58
73
  // Typeahead path: open suggestion list, pick first match.
59
74
  if (field.isTypeahead) {
60
75
  return await typeAndPickSuggestion(page, locator, item.value);
@@ -76,29 +91,9 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
76
91
  }
77
92
 
78
93
  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
- }
94
+ return await openAndPickOption(page, locator, item.value, {
95
+ config: ctx.config, jobId: ctx.jobId, label: field.label,
96
+ });
102
97
  }
103
98
 
104
99
  case 'set_checkbox': {
@@ -182,6 +177,122 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
182
177
  * keyboard.type, then native-setter — but with verify-after-write to catch
183
178
  * React rejecting our value.
184
179
  */
180
+ /**
181
+ * Open a custom dropdown and pick the closest option matching `value`.
182
+ * Handles the two architectural pitfalls today's Reddit run exposed:
183
+ *
184
+ * 1. Stale option lists. Greenhouse's intl-tel-input mounts a
185
+ * <ul role="listbox"> of country dial codes the first time the
186
+ * Phone field is touched, and never unmounts it. A naive
187
+ * page.locator('[role="option"]') matches those FIRST, so we
188
+ * end up trying to click "Afghanistan" when the user wanted
189
+ * "Heterosexual." Fix: snapshot the page's option count BEFORE
190
+ * opening this dropdown, then only look at options that
191
+ * appeared AFTER the click — they belong to OUR dropdown.
192
+ *
193
+ * 2. Off-screen / hidden options. allTextContents returns text for
194
+ * hidden options too. Filter to visible-only before matching.
195
+ *
196
+ * Falls back to LLM synonym matching when local exact/substring
197
+ * matching fails, so "Straight" still picks "Heterosexual."
198
+ */
199
+ async function openAndPickOption(page, triggerLocator, value, llmCtx) {
200
+ try {
201
+ // Snapshot option-list state BEFORE opening so we can identify
202
+ // the new options.
203
+ const optionSel = '[role="option"], [role="menuitem"], .select__option, li[class*="option"]';
204
+ const beforeCount = await page.locator(optionSel).count().catch(() => 0);
205
+
206
+ await triggerLocator.click({ timeout: 2500 });
207
+ await page.waitForTimeout(350);
208
+
209
+ // Newly mounted options live at indexes >= beforeCount. If no new
210
+ // ones appeared, the dropdown may have rendered options earlier
211
+ // (already-open select). Fall through to scanning all visible.
212
+ const allOpts = page.locator(optionSel);
213
+ const totalCount = await allOpts.count().catch(() => 0);
214
+ const newCount = totalCount - beforeCount;
215
+
216
+ // Collect candidate {text, idx} pairs — prefer new options, but if
217
+ // none, scan all visible ones.
218
+ const startIdx = newCount > 0 ? beforeCount : 0;
219
+ const endIdx = totalCount;
220
+ const candidates = [];
221
+ for (let i = startIdx; i < endIdx; i++) {
222
+ const opt = allOpts.nth(i);
223
+ const visible = await opt.isVisible({ timeout: 200 }).catch(() => false);
224
+ if (!visible) continue;
225
+ const text = (await opt.textContent().catch(() => '') || '').replace(/\s+/g, ' ').trim();
226
+ if (!text) continue;
227
+ candidates.push({ idx: i, text });
228
+ }
229
+
230
+ if (candidates.length === 0) {
231
+ await page.keyboard.press('Escape').catch(() => {});
232
+ return { ok: false, reason: 'dropdown opened but no visible options found' };
233
+ }
234
+
235
+ // STEP 1: exact match (case-insensitive)
236
+ const v = String(value).toLowerCase().trim();
237
+ let pick = candidates.find((c) => c.text.toLowerCase() === v);
238
+
239
+ // STEP 2: substring either direction (handles "Asian" → "South Asian")
240
+ if (!pick) pick = candidates.find((c) => {
241
+ const t = c.text.toLowerCase();
242
+ return t.includes(v) || v.includes(t);
243
+ });
244
+
245
+ // STEP 3: token overlap (handles "Hispanic" → "Hispanic or Latino")
246
+ if (!pick) {
247
+ const vTokens = v.split(/\s+/).filter((t) => t.length > 2);
248
+ pick = candidates.find((c) => {
249
+ const tTokens = c.text.toLowerCase().split(/\s+/);
250
+ return vTokens.some((vt) => tTokens.some((tt) => tt.includes(vt) || vt.includes(tt)));
251
+ });
252
+ }
253
+
254
+ if (!pick) {
255
+ // No local match — try LLM pick if we have context. This is the
256
+ // "Straight" → "Heterosexual" path when synonyms don't appear in
257
+ // canonical facts.
258
+ if (llmCtx && llmCtx.config && llmCtx.jobId && llmCtx.label) {
259
+ try {
260
+ const res = await fetch(`${llmCtx.config.apiUrl}/smartfill/field-answer`, {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${llmCtx.config.token}` },
263
+ body: JSON.stringify({
264
+ job_id: llmCtx.jobId,
265
+ field_label: llmCtx.label,
266
+ field_type: 'select',
267
+ options: candidates.map((c) => c.text),
268
+ previously_answered: [{ label: 'user intent', value: String(value) }],
269
+ }),
270
+ });
271
+ if (res.ok) {
272
+ const { value: picked } = await res.json();
273
+ if (picked && picked !== 'SKIP') {
274
+ const pv = picked.toLowerCase().trim();
275
+ pick = candidates.find((c) => c.text.toLowerCase() === pv)
276
+ || candidates.find((c) => c.text.toLowerCase().includes(pv) || pv.includes(c.text.toLowerCase()));
277
+ }
278
+ }
279
+ } catch {}
280
+ }
281
+ }
282
+
283
+ if (!pick) {
284
+ await page.keyboard.press('Escape').catch(() => {});
285
+ return { ok: false, reason: `no option matched "${value}" (had ${candidates.length}: ${candidates.slice(0, 4).map((c) => c.text).join(' / ')}${candidates.length > 4 ? '...' : ''})` };
286
+ }
287
+
288
+ await allOpts.nth(pick.idx).click({ timeout: 2000 });
289
+ return { ok: true, reason: `clicked option: ${pick.text}` };
290
+ } catch (e) {
291
+ await page.keyboard.press('Escape').catch(() => {});
292
+ return { ok: false, reason: `openAndPickOption failed: ${e.message}` };
293
+ }
294
+ }
295
+
185
296
  async function reactSafeType(page, locator, value) {
186
297
  const v = String(value);
187
298
  try {
@@ -325,7 +436,12 @@ async function smartFillPage(page, aep, options) {
325
436
  continue;
326
437
  }
327
438
  const labelShort = (f.label || f.selectorHint || '?').slice(0, 50);
328
- const result = await executePlanItem(page, item, fieldByMmid, { resumePath, coverLetterPath });
439
+ const result = await executePlanItem(page, item, fieldByMmid, {
440
+ resumePath, coverLetterPath,
441
+ // Threaded so openAndPickOption can hit /smartfill/field-answer for
442
+ // synonym matching when local fuzzy fails ("Straight" → "Heterosexual").
443
+ config, jobId,
444
+ });
329
445
  if (result.ok === 'ask_user') {
330
446
  askUserReasons.push(`${labelShort}: ${result.reason}`);
331
447
  console.warn(`[smartFill] ASK USER: "${labelShort}" — ${result.reason}`);