@leadbay/mcp 0.39.5 → 0.39.8

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/dist/bin.js CHANGED
@@ -2996,7 +2996,7 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
2996
2996
 
2997
2997
  | Observation | Suggest | Calls |
2998
2998
  |---|---|---|
2999
- | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
2999
+ | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
3000
3000
  | Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
3001
3001
  | Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
3002
3002
  | Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
@@ -3992,7 +3992,7 @@ Trigger phrases: "is the lead search done", "any results yet on that job", "chec
3992
3992
 
3993
3993
  Do NOT use for: "is the enrichment done" \u2192 \`leadbay_bulk_enrich_status\`; "is my import done" \u2192 \`leadbay_import_status\`; "is the top-N qualification done" \u2192 \`leadbay_qualify_status\`.
3994
3994
 
3995
- Prefer when: a find_new_leads / qualify_leads result carried next_poll \u2014 pass its job_id; use wait_seconds ~60 when the user asked to wait for results.
3995
+ Prefer when: a find_new_leads / qualify_leads result carried next_poll \u2014 pass its job_id; use wait_seconds 45 when the user asked to wait for results.
3996
3996
 
3997
3997
  Examples that SHOULD invoke this tool:
3998
3998
  - "Any leads yet from that search you started?"
@@ -4019,7 +4019,7 @@ usage counters (never render them), and the \`explain\` block
4019
4019
  (basis, seed strategy, scope notes). Items are immutable once emitted \u2014
4020
4020
  polling never re-reads live data, so numbers only ever grow.
4021
4021
 
4022
- \`wait_seconds: 0\` (default) answers instantly; set ~60 to block-wait for
4022
+ \`wait_seconds: 0\` (default) answers instantly; set 45 (the maximum) to block-wait for
4023
4023
  completion when the user asked for results "in this reply". \`since\` (from a
4024
4024
  prior poll's \`next_since\`) pages only the new items. Jobs terminalize
4025
4025
  server-side: past the 30-min wall clock a job reads \`completed_partial\`
@@ -4201,7 +4201,7 @@ is a status tool, keep it terse:
4201
4201
 
4202
4202
  | Observation | Suggest | Calls |
4203
4203
  |---|---|---|
4204
- | Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
4204
+ | Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
4205
4205
  | Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | \u2014 |
4206
4206
  | \`expired\` (past the 30-day window) | "Re-read the delivered leads from your delivery ledger" \u2014 there is nothing left to render: the job terminalized and its items are no longer listed, so do NOT present an empty delivery as a result | leadbay_qualify_leads(prior_deliveries: {job_id}) |
4207
4207
  `;
@@ -5541,7 +5541,7 @@ Pick the 2-3 options that match what actually happened:
5541
5541
 
5542
5542
  | Observation | Suggest | Calls |
5543
5543
  |---|---|---|
5544
- | Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
5544
+ | Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
5545
5545
  | Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach |
5546
5546
  | Items skipped \`not_in_universe\` | "Import those companies first, then re-qualify" | leadbay_import_leads \u2192 leadbay_qualify_leads |
5547
5547
  | Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
@@ -6169,13 +6169,11 @@ key. It survives a misspelled company name and is what turns "not in your
6169
6169
  list" into an answer. With only a contact email, pass \`email\`: the company
6170
6170
  domain is derived from it, consumer mailboxes ignored.
6171
6171
 
6172
- When the registry cannot pick one company it returns \`{resolution:
6173
- "ambiguous", query, candidates:[\u2026]}\` instead of a card. Ask which one; never
6174
- guess from \`score\`.
6175
-
6176
- \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
6177
- found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
6178
- again. Do not offer an import before asking.
6172
+ Both \`resolution\` answers below are successes, not cards. \`"ambiguous"\`
6173
+ carries \`candidates[]\`: ask which one, never guess from \`score\`.
6174
+ \`"not_found"\` carries \`summary\` + \`next_step\`: nobody has it, so say that and
6175
+ do what \`next_step\` says \u2014 usually ask for the param \`would_help\` names, then
6176
+ call again. Do not offer an import before asking.
6179
6177
 
6180
6178
  ---
6181
6179
 
@@ -6316,7 +6314,7 @@ out?"\`
6316
6314
 
6317
6315
  When \`resolution\` is \`"ambiguous"\`, render no card: use \`ask_user_input_v0\`,
6318
6316
  ONE \`single_select\` question ("Which one?"), one short label per candidate
6319
- combining \`name\` and \`location\`.
6317
+ combining \`name\` and \`location\`. \`"not_found"\` renders no card either.
6320
6318
 
6321
6319
  When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
6322
6320
 
@@ -7088,17 +7086,18 @@ WHEN NOT TO USE: the user wants a lead list (leadbay_pull_leads / leadbay_pull_f
7088
7086
  `;
7089
7087
  leadbay_tour_plan = `## WHEN TO USE
7090
7088
 
7091
- Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "customers plus prospects in <city>", "tour itinerary".
7089
+ Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "prospects within <N> km of <city>", "<city> and the surrounding area", "customers plus prospects in <city>", "tour itinerary".
7092
7090
 
7093
7091
  Do NOT use for: "follow-ups only, no new prospects" \u2192 \`leadbay_followups_map\`; "new leads only" \u2192 \`leadbay_pull_leads\`; "research one account" \u2192 \`leadbay_research_lead_by_id\`.
7094
7092
 
7095
- Prefer when: user wants known accounts plus new discoveries in one geographic itinerary; NEVER a country name \u2014 unlike the Monitor tools, do NOT omit \`city\`; a city-less tour is arbitrary nationwide leads, so ask which city or region
7093
+ Prefer when: known accounts plus new discoveries in one itinerary; pass \`radius_km\` when they name a radius; NEVER a country name, and do NOT omit \`city\`: a city-less tour is arbitrary nationwide leads, so ask which city or region
7096
7094
 
7097
7095
  Examples that SHOULD invoke this tool:
7098
7096
  - "I'm flying to Limoges in 4 days \u2014 give me 3 customers, 3 qualified prospects, and 3 new high-potential."
7099
7097
  - "I'm in San Francisco next Tuesday. Who's worth meeting?"
7100
7098
  - "Plan my tour next Tuesday in Lyon: known accounts plus discoveries."
7101
7099
  - "Build a mixed itinerary for Berlin \u2014 I want both follow-ups and fresh leads."
7100
+ - "J'ai un rdv le 19 ao\xFBt \xE0 Colmar \u2014 trouve-moi les prospects dans un rayon de 10km."
7102
7101
 
7103
7102
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
7104
7103
  - "Show me my follow-ups for the SF trip."
@@ -7153,6 +7152,8 @@ same instruction in its \`hint\`.
7153
7152
 
7154
7153
  **Counts**: \`followups_count\` (default 6 \u2014 generous so the agent can split into "customers + qualified" client-side) and \`discover_count\` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter \u2014 it then keeps the leads whose own \`location.city\` names the requested city, and falls back to \`location.state\` only when no city matched (which is what a regional ask like "Texas" or "\xCEle-de-France" looks like). \`location.country\` is never consulted. \`discover_filter_note\` reports the ratio and which field carried it, so the agent can be honest about coverage. **When it says no Discover lead is in the city, say that** \u2014 return the Monitor half and offer \`leadbay_find_new_leads\` for that city. Never fill the gap with leads from elsewhere.
7155
7154
 
7155
+ **The next town over**: a tour is a day of driving, so after the town's own leads are found the composite adds the Discover leads whose own coordinates put them within **\`radius_km\` (default 20)** of it \u2014 West Sacramento on a tour of Sacramento, Courbevoie and Ivry-sur-Seine on a tour of Paris. The town's own leads always come first. **Pass the user's own number when they give one** ("dans un rayon de 10km autour de Colmar" \u2192 \`radius_km: 10\`; "within 15 miles" \u2192 \`radius_km: 24\`), and \`radius_km: 0\` to keep the tour strictly inside the named town. The radius applies to Discover leads only; the Monitor half is scoped server-side and is untouched. When \`discover_filter_note\` splits the stops into "N in '<city>' and M in <other towns>", **repeat that split** \u2014 say which town each nearby stop is actually in rather than presenting every stop as being in the city the user named.
7156
+
7156
7157
  **What \`tour_plan\` does NOT do**: it doesn't persist the tour as a campaign artifact. To do that \u2014 create a "Limoges Tour \u2013 May 24" campaign and attach the selected accounts \u2014 chain into \`leadbay_create_campaign({lead_ids: [...selected_ids], name: 'Limoges Tour \u2013 <date>'})\` after the user picks. See the \`leadbay_plan_tour_in_city\` prompt for the full end-to-end orchestrator.
7157
7158
 
7158
7159
  ---
@@ -7793,7 +7794,7 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
7793
7794
 
7794
7795
  | Observation | Suggest | Calls |
7795
7796
  |---|---|---|
7796
- | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
7797
+ | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
7797
7798
  | Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
7798
7799
  | Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
7799
7800
  | Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
@@ -15370,23 +15371,75 @@ function stateFieldOf(lead) {
15370
15371
  const state = lead?.location?.state;
15371
15372
  return typeof state === "string" ? normalizeGeo(state) : "";
15372
15373
  }
15373
- function filterDiscoverByCity(leads, cityHint) {
15374
+ function posOf(lead) {
15375
+ const pos = lead?.location?.pos;
15376
+ const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number" && Number.isFinite(n));
15377
+ return valid ? [pos[0], pos[1]] : null;
15378
+ }
15379
+ function distanceKm(a, b) {
15380
+ const toRad = (d) => d * Math.PI / 180;
15381
+ const dLat = toRad(b[0] - a[0]);
15382
+ const dLng = toRad(b[1] - a[1]);
15383
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
15384
+ return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
15385
+ }
15386
+ function withinRadius(leads, anchors, radiusKm) {
15387
+ if (anchors.length === 0 || !(radiusKm > 0))
15388
+ return [];
15389
+ return leads.filter((l) => {
15390
+ const p = posOf(l);
15391
+ return p !== null && anchors.some((a) => distanceKm(a, p) <= radiusKm);
15392
+ });
15393
+ }
15394
+ function filterDiscoverByCity(leads, cityHint, monitorLeads, radiusKm) {
15374
15395
  const hint = cityHint ? cityHintCore(cityHint) : { name: "", isCity: false };
15375
15396
  if (!hint.name)
15376
- return { leads, matchedOn: null };
15397
+ return { leads, matchedOn: null, nearby: [], anchored: false };
15377
15398
  const cityOf = (l) => typeof l?.location?.city === "string" ? townName(l.location.city) : "";
15399
+ const positions = (ls) => ls.map(posOf).filter((p) => p !== null);
15378
15400
  const byCity = leads.filter((l) => cityOf(l) === hint.name);
15379
- if (byCity.length > 0)
15380
- return { leads: byCity, matchedOn: "city" };
15381
- if (hint.isCity)
15382
- return { leads: [], matchedOn: "city" };
15383
- const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
15384
- return { leads: byState, matchedOn: "state" };
15401
+ const anchors = positions([
15402
+ ...byCity,
15403
+ ...monitorLeads.filter((l) => cityOf(l) === hint.name)
15404
+ ]);
15405
+ if (byCity.length > 0) {
15406
+ const inTown = new Set(byCity);
15407
+ const nearby2 = withinRadius(leads.filter((l) => !inTown.has(l)), anchors, radiusKm);
15408
+ return { leads: [...byCity, ...nearby2], matchedOn: "city", nearby: nearby2, anchored: true };
15409
+ }
15410
+ const anchored = anchors.length > 0;
15411
+ if (!hint.isCity) {
15412
+ const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
15413
+ if (byState.length > 0)
15414
+ return { leads: byState, matchedOn: "state", nearby: [], anchored };
15415
+ }
15416
+ const nearby = withinRadius(leads, anchors, radiusKm);
15417
+ if (nearby.length > 0)
15418
+ return { leads: nearby, matchedOn: "city", nearby, anchored };
15419
+ return {
15420
+ leads: [],
15421
+ matchedOn: hint.isCity ? "city" : "state",
15422
+ nearby: [],
15423
+ anchored
15424
+ };
15425
+ }
15426
+ function townsOf(leads) {
15427
+ const seen = [];
15428
+ let unnamed = 0;
15429
+ for (const l of leads) {
15430
+ const city = typeof l?.location?.city === "string" ? l.location.city.trim() : "";
15431
+ const name = city.replace(/^(?:City|Town|Village|Borough|Township|Municipality) of /i, "");
15432
+ if (!name)
15433
+ unnamed += 1;
15434
+ else if (!seen.includes(name))
15435
+ seen.push(name);
15436
+ }
15437
+ const rest = unnamed > 0 ? `${unnamed} whose record names no town` : "";
15438
+ return [seen.join(", "), rest].filter(Boolean).join(", plus ");
15385
15439
  }
15386
15440
  function toMapLocation(lead, mode) {
15387
- const pos = lead?.location?.pos;
15388
- const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number");
15389
- if (!valid)
15441
+ const pos = posOf(lead);
15442
+ if (pos === null)
15390
15443
  return null;
15391
15444
  const loc = lead.location;
15392
15445
  const c = lead.recommended_contact;
@@ -15431,7 +15484,7 @@ function buildMap(monitorLeads, discoverLeads2) {
15431
15484
  }
15432
15485
  };
15433
15486
  }
15434
- var DEFAULT_FOLLOWUPS_COUNT, DEFAULT_DISCOVER_COUNT, DISCOVER_OVER_PULL, ADMIN_PREFIX, tourPlan;
15487
+ var DEFAULT_FOLLOWUPS_COUNT, DEFAULT_DISCOVER_COUNT, DISCOVER_OVER_PULL, DEFAULT_RADIUS_KM, ADMIN_PREFIX, tourPlan;
15435
15488
  var init_tour_plan = __esm({
15436
15489
  "../core/dist/composite/tour-plan.js"() {
15437
15490
  "use strict";
@@ -15444,6 +15497,7 @@ var init_tour_plan = __esm({
15444
15497
  DEFAULT_FOLLOWUPS_COUNT = 6;
15445
15498
  DEFAULT_DISCOVER_COUNT = 6;
15446
15499
  DISCOVER_OVER_PULL = 30;
15500
+ DEFAULT_RADIUS_KM = 20;
15447
15501
  ADMIN_PREFIX = /^(?:city|town|village|borough|township|municipality) of /;
15448
15502
  tourPlan = {
15449
15503
  name: "leadbay_tour_plan",
@@ -15473,6 +15527,10 @@ var init_tour_plan = __esm({
15473
15527
  discover_count: {
15474
15528
  type: "number",
15475
15529
  description: `Top-N Discover leads (active lens wishlist) to return after client-side city filter. Default ${DEFAULT_DISCOVER_COUNT}.`
15530
+ },
15531
+ radius_km: {
15532
+ type: "number",
15533
+ description: `How far outside the named town a Discover stop may be, in km. Default ${DEFAULT_RADIUS_KM}, which is roughly a metro area \u2014 it is what puts West Sacramento on a tour of Sacramento and Courbevoie on a tour of Paris. Pass the user's own number when they name one ("dans un rayon de 10km", "zone 20km", "within 15 miles" \u2192 24). Pass 0 to keep the tour inside the town's own name. Leads in the town itself always come first; the radius only adds to them. It applies to Discover leads only \u2014 the Monitor half is scoped server-side and is untouched.`
15476
15534
  }
15477
15535
  },
15478
15536
  additionalProperties: false
@@ -15489,12 +15547,12 @@ var init_tour_plan = __esm({
15489
15547
  },
15490
15548
  discover_leads: {
15491
15549
  type: "array",
15492
- description: "Fresh Discover leads from the active lens, filtered client-side to match the city. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
15550
+ description: "Fresh Discover leads from the active lens, filtered client-side to match the city, then extended with the ones whose own coordinates put them within `radius_km` of it. The town's own leads come first. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
15493
15551
  items: { type: "object" }
15494
15552
  },
15495
15553
  discover_filter_note: {
15496
15554
  type: "string",
15497
- description: "Human-readable summary of the client-side geo filter applied to Discover leads (e.g. 'matched 3/30 by city/state')."
15555
+ description: "Human-readable summary of the client-side geo filter applied to Discover leads. Says how many stops are in the named town, how many are within `radius_km` of it, and which towns those are in \u2014 repeat that split to the user rather than presenting every stop as being in the city they named."
15498
15556
  },
15499
15557
  map_locations: {
15500
15558
  type: "array",
@@ -15618,12 +15676,21 @@ var init_tour_plan = __esm({
15618
15676
  const cityName = params.city && !/^\d+$/.test(params.city.trim()) ? params.city : void 0;
15619
15677
  const knownId = params.city_id ?? (cityName ? void 0 : params.city);
15620
15678
  const idOnly = Boolean(knownId) && !cityName;
15621
- const { leads: filtered, matchedOn } = idOnly ? { leads: [], matchedOn: null } : filterDiscoverByCity(rawDiscover, cityName);
15679
+ const radiusKm = params.radius_km ?? DEFAULT_RADIUS_KM;
15680
+ const { leads: filtered, matchedOn, nearby, anchored } = idOnly ? {
15681
+ leads: [],
15682
+ matchedOn: null,
15683
+ nearby: [],
15684
+ anchored: false
15685
+ } : filterDiscoverByCity(rawDiscover, cityName, monitorLeads, radiusKm);
15622
15686
  const discoverLeads2 = filtered.slice(0, discoverCount);
15623
15687
  const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
15624
15688
  if (pulledLensId != null) {
15625
15689
  reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
15626
15690
  }
15691
+ const nearbySet = new Set(nearby);
15692
+ const nearShown = discoverLeads2.filter((l) => nearbySet.has(l));
15693
+ const inTownShown = discoverLeads2.length - nearShown.length;
15627
15694
  let filterNote;
15628
15695
  if (idOnly) {
15629
15696
  filterNote = `Discover leads need the NAME of the place, and this call passed an area id (${knownId}) and no name. Re-call with \`city\` set to the name of that area, keeping \`city_id\` so the Monitor half stays on the area you picked. The follow-ups below are already scoped to it.`;
@@ -15631,10 +15698,16 @@ var init_tour_plan = __esm({
15631
15698
  filterNote = `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
15632
15699
  } else if (matchedOn === null) {
15633
15700
  filterNote = `No usable city filter in '${params.city}'; returning top ${discoverLeads2.length} Discover leads.`;
15701
+ } else if (filtered.length === 0 && anchored && radiusKm > 0) {
15702
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town, and none is within ${radiusKm} km of the leads that do (checked ${rawDiscover.length} candidates). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
15634
15703
  } else if (filtered.length === 0) {
15635
15704
  filterNote = `No Discover lead in the active lens is in '${params.city}' (checked ${rawDiscover.length} candidates by city, then by state/region). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
15705
+ } else if (nearShown.length > 0 && inTownShown === 0) {
15706
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town; ${nearby.length}/${rawDiscover.length} are within ${radiusKm} km of it. Returning top ${discoverLeads2.length}, in ${townsOf(nearShown)}. Name the town each stop is actually in rather than calling them all '${params.city}'.`;
15707
+ } else if (nearShown.length > 0) {
15708
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by city, plus ${nearby.length} within ${radiusKm} km of it. Returning top ${discoverLeads2.length}: ${inTownShown} in '${params.city}' and ${nearShown.length} in ${townsOf(nearShown)}. Name the town each nearby stop is in.`;
15636
15709
  } else {
15637
- filterNote = `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
15710
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
15638
15711
  }
15639
15712
  return {
15640
15713
  city: params.city ?? null,
@@ -17122,7 +17195,7 @@ var init_research_lead_by_name_fuzzy = __esm({
17122
17195
  // of a research card.
17123
17196
  outputSchema: {
17124
17197
  type: "object",
17125
- description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id.",
17198
+ description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id. When neither the user's leads nor the registry hold the company, returns {resolution:'not_found', query, summary, would_help, next_step} \u2014 a successful answer, not an error: do what next_step says, which is normally to ask the user for the field would_help names ('website' or 'registry_number') and call again. would_help is empty when the search was scoped to one lens, where the remedy is to drop the scope, not to ask the user for a field.",
17126
17199
  additionalProperties: true
17127
17200
  },
17128
17201
  execute: async (client, params, ctx) => {
@@ -17136,7 +17209,18 @@ var init_research_lead_by_name_fuzzy = __esm({
17136
17209
  if (scoped.length > 0) {
17137
17210
  return await delegate(scoped, params.lensId);
17138
17211
  }
17139
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" in lens ${params.lensId}`, "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.");
17212
+ return {
17213
+ resolution: "not_found",
17214
+ query,
17215
+ summary: `No lead matching "${query}" in lens ${params.lensId}`,
17216
+ would_help: [],
17217
+ next_step: "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.",
17218
+ _meta: {
17219
+ region: client.region,
17220
+ lens_id: params.lensId,
17221
+ resolved_query: query
17222
+ }
17223
+ };
17140
17224
  }
17141
17225
  async function delegate(matches, fallbackLens) {
17142
17226
  const [primary, ...rest] = matches;
@@ -17231,7 +17315,23 @@ var init_research_lead_by_name_fuzzy = __esm({
17231
17315
  const wanted = resolved.type === "none" && resolved.would_help.length > 0 ? resolved.would_help : ["website", "registry_number"];
17232
17316
  const asks = wanted.map((f) => f === "registry_number" ? "a registry number (SIREN/SIRET) for `registry_number`" : f === "website" ? "the company website for `website`" : `\`${f}\``).join(" or ");
17233
17317
  const hint = resolved.type === "unidentifiable" ? `The registry could not identify a company from this input (${resolved.reason}). Ask the user for ${asks}, then call this tool again with it.` : `The registry found no company for what was supplied. It would match on ${asks}. Ask the user for that \u2014 "what's their website?" usually settles it \u2014 then call this tool again. Do not offer an import before asking.`;
17234
- throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, hint, "POST /leads/resolve");
17318
+ if (!corpusSearched) {
17319
+ throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, `${hint} But this lookup did not complete \u2014 the lead search route was unreachable, so their own leads were never checked. Retry once before telling the user the company is absent.`, "POST /leads/resolve");
17320
+ }
17321
+ return {
17322
+ resolution: "not_found",
17323
+ query,
17324
+ resolver_payload: payload,
17325
+ summary: `No company matching "${query}" ${searched}`,
17326
+ would_help: wanted,
17327
+ next_step: hint,
17328
+ _meta: {
17329
+ region: client.region,
17330
+ resolved_from: "resolver",
17331
+ resolved_query: query,
17332
+ endpoint: "POST /leads/resolve"
17333
+ }
17334
+ };
17235
17335
  }
17236
17336
  };
17237
17337
  }
@@ -21457,6 +21557,21 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
21457
21557
  const items = current?.lens_filter?.items ?? [];
21458
21558
  const item = items[0] ?? { criteria: [] };
21459
21559
  const criteria = item.criteria ? [...item.criteria] : [];
21560
+ const dropIds = (type, fromExcluded, ids) => {
21561
+ if (ids.length === 0)
21562
+ return;
21563
+ const idx = criteria.findIndex((c) => c.type === type && !!c.is_excluded === fromExcluded);
21564
+ if (idx < 0)
21565
+ return;
21566
+ const cur = criteria[idx];
21567
+ const key = type === "sector_ids" ? "sectors" : "locations";
21568
+ const held = cur[key] ?? [];
21569
+ const kept = held.filter((s) => !ids.includes(s));
21570
+ if (kept.length === 0)
21571
+ criteria.splice(idx, 1);
21572
+ else
21573
+ criteria[idx] = { ...cur, [key]: kept };
21574
+ };
21460
21575
  if (toAddSectors.length > 0) {
21461
21576
  const idx = criteria.findIndex((c) => c.type === "sector_ids" && !c.is_excluded);
21462
21577
  if (idx >= 0) {
@@ -21471,6 +21586,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
21471
21586
  });
21472
21587
  }
21473
21588
  }
21589
+ dropIds("sector_ids", true, toAddSectors);
21474
21590
  if (toExcludeSectors.length > 0) {
21475
21591
  const idx = criteria.findIndex((c) => c.type === "sector_ids" && c.is_excluded);
21476
21592
  if (idx >= 0) {
@@ -21485,6 +21601,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
21485
21601
  });
21486
21602
  }
21487
21603
  }
21604
+ dropIds("sector_ids", false, toExcludeSectors);
21488
21605
  if (toAddLocations.length > 0) {
21489
21606
  const idx = criteria.findIndex((c) => c.type === "location_ids" && !c.is_excluded);
21490
21607
  if (idx >= 0) {
@@ -21499,6 +21616,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
21499
21616
  });
21500
21617
  }
21501
21618
  }
21619
+ dropIds("location_ids", true, toAddLocations);
21502
21620
  if (toExcludeLocations.length > 0) {
21503
21621
  const idx = criteria.findIndex((c) => c.type === "location_ids" && c.is_excluded);
21504
21622
  if (idx >= 0) {
@@ -21513,6 +21631,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
21513
21631
  });
21514
21632
  }
21515
21633
  }
21634
+ dropIds("location_ids", false, toExcludeLocations);
21516
21635
  if (sizes && sizes.length > 0) {
21517
21636
  const normalizedSizes = sizes.map((s) => ({
21518
21637
  min: s.min ?? 0,
@@ -22673,12 +22792,15 @@ function normalizeSearchFilters(filters) {
22673
22792
  }
22674
22793
  return out;
22675
22794
  }
22676
- function clampWaitSeconds(requested, fallback) {
22677
- if (requested == null || Number.isNaN(requested))
22678
- return fallback;
22679
- return Math.min(Math.max(requested, 0), 180);
22795
+ function clampWaitSeconds(requested, fallback, startedAtMs) {
22796
+ const asked = requested == null || Number.isNaN(requested) ? fallback : requested;
22797
+ const bounded = Math.min(Math.max(asked, 0), MAX_WAIT_SECONDS);
22798
+ if (startedAtMs == null)
22799
+ return bounded;
22800
+ const spentSeconds = (Date.now() - startedAtMs) / 1e3;
22801
+ return Math.max(0, Math.min(bounded, MAX_WAIT_SECONDS - spentSeconds));
22680
22802
  }
22681
- var TERMINAL_JOB_STATES, MCP_JOB_POLL, SNAPSHOT_TIMEOUT_MS, PAGE_LIMIT, MAX_JOB_ITEMS, MIN_PAGES, maxPagesFor, JOB_ID_CHARSET, MAX_JOB_ID_LENGTH, UUID_RE2, LEAD_REASON_CHARS, SIGNAL_CHARS, MAX_SIGNALS, MAX_ALTERNATIVE_CONTACTS, RESULT_CHARS, LEAN_ROW_CHARS, COUNTRY_ALIASES, SUBNATIONAL_EXEMPTIONS, ALL_EXEMPTIONS, COUNTRY_LOCATION_VALUES, MAX_EXCLUDE_LEAD_IDS, MAX_LEAD_REFS;
22803
+ var TERMINAL_JOB_STATES, MCP_JOB_POLL, SNAPSHOT_TIMEOUT_MS, PAGE_LIMIT, MAX_JOB_ITEMS, MIN_PAGES, maxPagesFor, JOB_ID_CHARSET, MAX_JOB_ID_LENGTH, UUID_RE2, LEAD_REASON_CHARS, SIGNAL_CHARS, MAX_SIGNALS, MAX_ALTERNATIVE_CONTACTS, RESULT_CHARS, LEAN_ROW_CHARS, COUNTRY_ALIASES, SUBNATIONAL_EXEMPTIONS, ALL_EXEMPTIONS, COUNTRY_LOCATION_VALUES, MAX_EXCLUDE_LEAD_IDS, MAX_LEAD_REFS, MAX_WAIT_SECONDS;
22682
22804
  var init_mcp_job_helpers = __esm({
22683
22805
  "../core/dist/composite/_mcp-job-helpers.js"() {
22684
22806
  "use strict";
@@ -22742,6 +22864,7 @@ var init_mcp_job_helpers = __esm({
22742
22864
  COUNTRY_LOCATION_VALUES = buildCountryLocationValues();
22743
22865
  MAX_EXCLUDE_LEAD_IDS = 500;
22744
22866
  MAX_LEAD_REFS = 500;
22867
+ MAX_WAIT_SECONDS = 45;
22745
22868
  }
22746
22869
  });
22747
22870
 
@@ -22931,13 +23054,14 @@ var init_find_new_leads = __esm({
22931
23054
  },
22932
23055
  wait_seconds: {
22933
23056
  type: "number",
22934
- description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window; qualified exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
23057
+ description: "How long to poll before returning (default and maximum 45 \u2014 the whole call is bounded by it, so a slow submit shortens the poll; 0 = submit + one snapshot). Free searches usually finish inside the window; qualified exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
22935
23058
  }
22936
23059
  },
22937
23060
  required: ["count", "request_id"],
22938
23061
  additionalProperties: false
22939
23062
  },
22940
23063
  execute: async (client, params, ctx) => {
23064
+ const startedAt = Date.now();
22941
23065
  params = coerceArrayParams(params, [
22942
23066
  "contact_titles",
22943
23067
  "channels",
@@ -23076,7 +23200,7 @@ var init_find_new_leads = __esm({
23076
23200
  const mocked = mockedSubmitPreview(submit, "leadbay_find_new_leads", client.region);
23077
23201
  if (mocked)
23078
23202
  return mocked;
23079
- const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS);
23203
+ const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS, startedAt);
23080
23204
  const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, params.count);
23081
23205
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
23082
23206
  const { leads, skipped } = splitItems(snapshot);
@@ -23121,7 +23245,7 @@ var init_find_new_leads = __esm({
23121
23245
  // INCREMENTALLY instead of re-reading (and re-rendering) the
23122
23246
  // rows already delivered in this response.
23123
23247
  since: snapshot.next_since ?? null,
23124
- suggested_wait_seconds: done ? 0 : 60
23248
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
23125
23249
  },
23126
23250
  ...sectorNote(attempt.rewritten),
23127
23251
  ...countryNote,
@@ -23246,7 +23370,7 @@ function identityAnswer(jobId, snapshot, items, offset) {
23246
23370
  job_id: jobId,
23247
23371
  compact: true,
23248
23372
  offset: nextOffset,
23249
- suggested_wait_seconds: done ? 0 : 60
23373
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
23250
23374
  }
23251
23375
  };
23252
23376
  }
@@ -23471,12 +23595,13 @@ var init_qualify_leads = __esm({
23471
23595
  },
23472
23596
  wait_seconds: {
23473
23597
  type: "number",
23474
- description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Large or research-heavy batches can take minutes \u2014 the result then carries still_running:true and the job_id for leadbay_lead_job_status."
23598
+ description: "How long to poll before returning (default and maximum 45 \u2014 the whole call is bounded by it, so a slow submit shortens the poll; 0 = submit + one snapshot). Large or research-heavy batches can take minutes \u2014 the result then carries still_running:true and the job_id for leadbay_lead_job_status."
23475
23599
  }
23476
23600
  },
23477
23601
  additionalProperties: false
23478
23602
  },
23479
23603
  execute: async (client, params, ctx) => {
23604
+ const startedAt = Date.now();
23480
23605
  params = coerceArrayParams(params, [
23481
23606
  "lead_refs",
23482
23607
  "contact_titles",
@@ -23536,7 +23661,7 @@ var init_qualify_leads = __esm({
23536
23661
  const mocked = mockedSubmitPreview(submit, "leadbay_qualify_leads", client.region);
23537
23662
  if (mocked)
23538
23663
  return mocked;
23539
- const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS2);
23664
+ const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS2, startedAt);
23540
23665
  const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, submit.items_requested);
23541
23666
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
23542
23667
  const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
@@ -23613,7 +23738,7 @@ var init_qualify_leads = __esm({
23613
23738
  // INCREMENTALLY instead of re-reading (and re-rendering) the
23614
23739
  // rows already delivered in this response.
23615
23740
  since: snapshot.next_since ?? null,
23616
- suggested_wait_seconds: done ? 0 : 60
23741
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
23617
23742
  },
23618
23743
  region: client.region
23619
23744
  };
@@ -23657,7 +23782,7 @@ var init_lead_job_status = __esm({
23657
23782
  },
23658
23783
  wait_seconds: {
23659
23784
  type: "number",
23660
- description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds until the job is terminal \u2014 use ~60 when the user asked to wait for results."
23785
+ description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds (maximum 45) until the job is terminal \u2014 use 45 when the user asked to wait for results."
23661
23786
  },
23662
23787
  compact: {
23663
23788
  type: "boolean",
@@ -23709,7 +23834,7 @@ var init_lead_job_status = __esm({
23709
23834
  // Same incremental handoff as the submit tools — following
23710
23835
  // next_poll without the cursor re-reads the rows just returned.
23711
23836
  since: snapshot.next_since ?? null,
23712
- suggested_wait_seconds: done ? 0 : 60
23837
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
23713
23838
  },
23714
23839
  region: client.region
23715
23840
  };
@@ -27129,7 +27254,7 @@ searched. Render the delivery table and judge fit honestly: are these the
27129
27254
  kind of companies I asked for?
27130
27255
 
27131
27256
  - **\`still_running: true\`** \u2192 the job is ALIVE. Do not judge the seed and do
27132
- not relaunch \u2014 poll \`leadbay_lead_job_status\` (\`wait_seconds: 60\`) until
27257
+ not relaunch \u2014 poll \`leadbay_lead_job_status\` (\`wait_seconds: 45\`) until
27133
27258
  it goes terminal, reporting progress. Relaunching now burns an active-job
27134
27259
  slot and rate-limit budget on a search that may be about to deliver.
27135
27260
  - **On-profile** (terminal) \u2192 offer Phase 4.
@@ -27187,7 +27312,7 @@ When I want qualification evidence and/or reachable contacts:
27187
27312
  \`exploration_cap\` can examine more than that, so send the most recent 500
27188
27313
  rejects rather than an over-long list the tool refuses outright.
27189
27314
  3. While the job runs, poll with \`leadbay_lead_job_status\`
27190
- (\`wait_seconds: 60\`); report progress, not silence.
27315
+ (\`wait_seconds: 45\`); report progress, not silence.
27191
27316
 
27192
27317
  # PHASE 5 \u2014 DELIVER
27193
27318
 
@@ -27260,7 +27385,7 @@ Split the returned \`monitor_leads\` into two buckets client-side using their en
27260
27385
 
27261
27386
  \`discover_leads\` are the **New** bucket.
27262
27387
 
27263
- Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded.
27388
+ Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded. When it splits the stops into the ones in the city and the ones within \`radius_km\` of it, repeat that split and name the town each nearby stop is in \u2014 a company in Courbevoie is a stop on a Paris day, but it is not in Paris. If I named a radius ("within 10 km", "rayon de 20km"), pass it as \`radius_km\`.
27264
27389
 
27265
27390
  # PHASE 3 \u2014 PRESENT THE ITINERARY + OFFER THE MAP
27266
27391
 
@@ -27839,10 +27964,12 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
27839
27964
 
27840
27965
 
27841
27966
  # PHASE 2 \u2014 NOT FOUND
27842
- If the resolver returns \`LEAD_NOT_FOUND\`, read its hint: it names the field
27843
- that would have found the company (\`would_help\`, usually \`website\`). **Ask the
27844
- user for that field first** \u2014 "what's their website?" \u2014 and call the tool again
27845
- with it. Only when they cannot supply it should you say both their leads and
27967
+ If the resolver returns \`resolution: "not_found"\`, that call SUCCEEDED \u2014 it is
27968
+ the answer, not a failure. Do what its \`next_step\` says. Usually that is to ask
27969
+ for the field \`would_help\` names (normally \`website\`): **ask the user for it
27970
+ first** \u2014 "what's their website?" \u2014 and call the tool again with it. When
27971
+ \`would_help\` is empty the search was scoped to one lens, and \`next_step\` says
27972
+ to drop the scope rather than to ask the user for anything. Only when they cannot supply it should you say both their leads and
27846
27973
  the Leadbay registry were searched.
27847
27974
  **Do NOT call \`leadbay_import_and_qualify\` automatically.**
27848
27975
  Offer to import and qualify as a separate, explicit next step; only call it
@@ -32035,7 +32162,7 @@ var OAUTH_BASE_URLS = {
32035
32162
  fr: "https://staging.api.leadbay.app"
32036
32163
  }
32037
32164
  };
32038
- var VERSION = "0.39.5";
32165
+ var VERSION = "0.39.8";
32039
32166
  var HELP = `
32040
32167
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
32041
32168
 
@@ -1400,7 +1400,7 @@ searched. Render the delivery table and judge fit honestly: are these the
1400
1400
  kind of companies I asked for?
1401
1401
 
1402
1402
  - **\`still_running: true\`** \u2192 the job is ALIVE. Do not judge the seed and do
1403
- not relaunch \u2014 poll \`leadbay_lead_job_status\` (\`wait_seconds: 60\`) until
1403
+ not relaunch \u2014 poll \`leadbay_lead_job_status\` (\`wait_seconds: 45\`) until
1404
1404
  it goes terminal, reporting progress. Relaunching now burns an active-job
1405
1405
  slot and rate-limit budget on a search that may be about to deliver.
1406
1406
  - **On-profile** (terminal) \u2192 offer Phase 4.
@@ -1458,7 +1458,7 @@ When I want qualification evidence and/or reachable contacts:
1458
1458
  \`exploration_cap\` can examine more than that, so send the most recent 500
1459
1459
  rejects rather than an over-long list the tool refuses outright.
1460
1460
  3. While the job runs, poll with \`leadbay_lead_job_status\`
1461
- (\`wait_seconds: 60\`); report progress, not silence.
1461
+ (\`wait_seconds: 45\`); report progress, not silence.
1462
1462
 
1463
1463
  # PHASE 5 \u2014 DELIVER
1464
1464
 
@@ -1531,7 +1531,7 @@ Split the returned \`monitor_leads\` into two buckets client-side using their en
1531
1531
 
1532
1532
  \`discover_leads\` are the **New** bucket.
1533
1533
 
1534
- Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded.
1534
+ Aim for a 3+3+3 split if possible. If the customers bucket has fewer than 3, fill from qualified. If discover_filter_note indicates a low match ratio for the city, mention it: "Only N/30 fresh leads matched your city" \u2014 better honest than padded. When it splits the stops into the ones in the city and the ones within \`radius_km\` of it, repeat that split and name the town each nearby stop is in \u2014 a company in Courbevoie is a stop on a Paris day, but it is not in Paris. If I named a radius ("within 10 km", "rayon de 20km"), pass it as \`radius_km\`.
1535
1535
 
1536
1536
  # PHASE 3 \u2014 PRESENT THE ITINERARY + OFFER THE MAP
1537
1537
 
@@ -2110,10 +2110,12 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
2110
2110
 
2111
2111
 
2112
2112
  # PHASE 2 \u2014 NOT FOUND
2113
- If the resolver returns \`LEAD_NOT_FOUND\`, read its hint: it names the field
2114
- that would have found the company (\`would_help\`, usually \`website\`). **Ask the
2115
- user for that field first** \u2014 "what's their website?" \u2014 and call the tool again
2116
- with it. Only when they cannot supply it should you say both their leads and
2113
+ If the resolver returns \`resolution: "not_found"\`, that call SUCCEEDED \u2014 it is
2114
+ the answer, not a failure. Do what its \`next_step\` says. Usually that is to ask
2115
+ for the field \`would_help\` names (normally \`website\`): **ask the user for it
2116
+ first** \u2014 "what's their website?" \u2014 and call the tool again with it. When
2117
+ \`would_help\` is empty the search was scoped to one lens, and \`next_step\` says
2118
+ to drop the scope rather than to ask the user for anything. Only when they cannot supply it should you say both their leads and
2117
2119
  the Leadbay registry were searched.
2118
2120
  **Do NOT call \`leadbay_import_and_qualify\` automatically.**
2119
2121
  Offer to import and qualify as a separate, explicit next step; only call it
@@ -5944,7 +5946,7 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
5944
5946
 
5945
5947
  | Observation | Suggest | Calls |
5946
5948
  |---|---|---|
5947
- | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
5949
+ | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
5948
5950
  | Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
5949
5951
  | Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
5950
5952
  | Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
@@ -6940,7 +6942,7 @@ Trigger phrases: "is the lead search done", "any results yet on that job", "chec
6940
6942
 
6941
6943
  Do NOT use for: "is the enrichment done" \u2192 \`leadbay_bulk_enrich_status\`; "is my import done" \u2192 \`leadbay_import_status\`; "is the top-N qualification done" \u2192 \`leadbay_qualify_status\`.
6942
6944
 
6943
- Prefer when: a find_new_leads / qualify_leads result carried next_poll \u2014 pass its job_id; use wait_seconds ~60 when the user asked to wait for results.
6945
+ Prefer when: a find_new_leads / qualify_leads result carried next_poll \u2014 pass its job_id; use wait_seconds 45 when the user asked to wait for results.
6944
6946
 
6945
6947
  Examples that SHOULD invoke this tool:
6946
6948
  - "Any leads yet from that search you started?"
@@ -6967,7 +6969,7 @@ usage counters (never render them), and the \`explain\` block
6967
6969
  (basis, seed strategy, scope notes). Items are immutable once emitted \u2014
6968
6970
  polling never re-reads live data, so numbers only ever grow.
6969
6971
 
6970
- \`wait_seconds: 0\` (default) answers instantly; set ~60 to block-wait for
6972
+ \`wait_seconds: 0\` (default) answers instantly; set 45 (the maximum) to block-wait for
6971
6973
  completion when the user asked for results "in this reply". \`since\` (from a
6972
6974
  prior poll's \`next_since\`) pages only the new items. Jobs terminalize
6973
6975
  server-side: past the 30-min wall clock a job reads \`completed_partial\`
@@ -7149,7 +7151,7 @@ is a status tool, keep it terse:
7149
7151
 
7150
7152
  | Observation | Suggest | Calls |
7151
7153
  |---|---|---|
7152
- | Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
7154
+ | Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
7153
7155
  | Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | \u2014 |
7154
7156
  | \`expired\` (past the 30-day window) | "Re-read the delivered leads from your delivery ledger" \u2014 there is nothing left to render: the job terminalized and its items are no longer listed, so do NOT present an empty delivery as a result | leadbay_qualify_leads(prior_deliveries: {job_id}) |
7155
7157
  `;
@@ -8489,7 +8491,7 @@ Pick the 2-3 options that match what actually happened:
8489
8491
 
8490
8492
  | Observation | Suggest | Calls |
8491
8493
  |---|---|---|
8492
- | Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
8494
+ | Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
8493
8495
  | Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach |
8494
8496
  | Items skipped \`not_in_universe\` | "Import those companies first, then re-qualify" | leadbay_import_leads \u2192 leadbay_qualify_leads |
8495
8497
  | Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
@@ -9117,13 +9119,11 @@ key. It survives a misspelled company name and is what turns "not in your
9117
9119
  list" into an answer. With only a contact email, pass \`email\`: the company
9118
9120
  domain is derived from it, consumer mailboxes ignored.
9119
9121
 
9120
- When the registry cannot pick one company it returns \`{resolution:
9121
- "ambiguous", query, candidates:[\u2026]}\` instead of a card. Ask which one; never
9122
- guess from \`score\`.
9123
-
9124
- \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
9125
- found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
9126
- again. Do not offer an import before asking.
9122
+ Both \`resolution\` answers below are successes, not cards. \`"ambiguous"\`
9123
+ carries \`candidates[]\`: ask which one, never guess from \`score\`.
9124
+ \`"not_found"\` carries \`summary\` + \`next_step\`: nobody has it, so say that and
9125
+ do what \`next_step\` says \u2014 usually ask for the param \`would_help\` names, then
9126
+ call again. Do not offer an import before asking.
9127
9127
 
9128
9128
  ---
9129
9129
 
@@ -9264,7 +9264,7 @@ out?"\`
9264
9264
 
9265
9265
  When \`resolution\` is \`"ambiguous"\`, render no card: use \`ask_user_input_v0\`,
9266
9266
  ONE \`single_select\` question ("Which one?"), one short label per candidate
9267
- combining \`name\` and \`location\`.
9267
+ combining \`name\` and \`location\`. \`"not_found"\` renders no card either.
9268
9268
 
9269
9269
  When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
9270
9270
 
@@ -10036,17 +10036,18 @@ WHEN NOT TO USE: the user wants a lead list (leadbay_pull_leads / leadbay_pull_f
10036
10036
  `;
10037
10037
  var leadbay_tour_plan = `## WHEN TO USE
10038
10038
 
10039
- Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "customers plus prospects in <city>", "tour itinerary".
10039
+ Trigger phrases: "visiting <city> in <N> days", "I'm in <city> next week / Tuesday \u2014 who's worth meeting", "I'm going to <city> \u2014 who should I see", "who's worth meeting in <city>", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "prospects within <N> km of <city>", "<city> and the surrounding area", "customers plus prospects in <city>", "tour itinerary".
10040
10040
 
10041
10041
  Do NOT use for: "follow-ups only, no new prospects" \u2192 \`leadbay_followups_map\`; "new leads only" \u2192 \`leadbay_pull_leads\`; "research one account" \u2192 \`leadbay_research_lead_by_id\`.
10042
10042
 
10043
- Prefer when: user wants known accounts plus new discoveries in one geographic itinerary; NEVER a country name \u2014 unlike the Monitor tools, do NOT omit \`city\`; a city-less tour is arbitrary nationwide leads, so ask which city or region
10043
+ Prefer when: known accounts plus new discoveries in one itinerary; pass \`radius_km\` when they name a radius; NEVER a country name, and do NOT omit \`city\`: a city-less tour is arbitrary nationwide leads, so ask which city or region
10044
10044
 
10045
10045
  Examples that SHOULD invoke this tool:
10046
10046
  - "I'm flying to Limoges in 4 days \u2014 give me 3 customers, 3 qualified prospects, and 3 new high-potential."
10047
10047
  - "I'm in San Francisco next Tuesday. Who's worth meeting?"
10048
10048
  - "Plan my tour next Tuesday in Lyon: known accounts plus discoveries."
10049
10049
  - "Build a mixed itinerary for Berlin \u2014 I want both follow-ups and fresh leads."
10050
+ - "J'ai un rdv le 19 ao\xFBt \xE0 Colmar \u2014 trouve-moi les prospects dans un rayon de 10km."
10050
10051
 
10051
10052
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
10052
10053
  - "Show me my follow-ups for the SF trip."
@@ -10101,6 +10102,8 @@ same instruction in its \`hint\`.
10101
10102
 
10102
10103
  **Counts**: \`followups_count\` (default 6 \u2014 generous so the agent can split into "customers + qualified" client-side) and \`discover_count\` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter \u2014 it then keeps the leads whose own \`location.city\` names the requested city, and falls back to \`location.state\` only when no city matched (which is what a regional ask like "Texas" or "\xCEle-de-France" looks like). \`location.country\` is never consulted. \`discover_filter_note\` reports the ratio and which field carried it, so the agent can be honest about coverage. **When it says no Discover lead is in the city, say that** \u2014 return the Monitor half and offer \`leadbay_find_new_leads\` for that city. Never fill the gap with leads from elsewhere.
10103
10104
 
10105
+ **The next town over**: a tour is a day of driving, so after the town's own leads are found the composite adds the Discover leads whose own coordinates put them within **\`radius_km\` (default 20)** of it \u2014 West Sacramento on a tour of Sacramento, Courbevoie and Ivry-sur-Seine on a tour of Paris. The town's own leads always come first. **Pass the user's own number when they give one** ("dans un rayon de 10km autour de Colmar" \u2192 \`radius_km: 10\`; "within 15 miles" \u2192 \`radius_km: 24\`), and \`radius_km: 0\` to keep the tour strictly inside the named town. The radius applies to Discover leads only; the Monitor half is scoped server-side and is untouched. When \`discover_filter_note\` splits the stops into "N in '<city>' and M in <other towns>", **repeat that split** \u2014 say which town each nearby stop is actually in rather than presenting every stop as being in the city the user named.
10106
+
10104
10107
  **What \`tour_plan\` does NOT do**: it doesn't persist the tour as a campaign artifact. To do that \u2014 create a "Limoges Tour \u2013 May 24" campaign and attach the selected accounts \u2014 chain into \`leadbay_create_campaign({lead_ids: [...selected_ids], name: 'Limoges Tour \u2013 <date>'})\` after the user picks. See the \`leadbay_plan_tour_in_city\` prompt for the full end-to-end orchestrator.
10105
10108
 
10106
10109
  ---
@@ -10741,7 +10744,7 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
10741
10744
 
10742
10745
  | Observation | Suggest | Calls |
10743
10746
  |---|---|---|
10744
- | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
10747
+ | Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
10745
10748
  | Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
10746
10749
  | Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
10747
10750
  | Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
@@ -17758,6 +17761,7 @@ var followupsMap = {
17758
17761
  var DEFAULT_FOLLOWUPS_COUNT = 6;
17759
17762
  var DEFAULT_DISCOVER_COUNT = 6;
17760
17763
  var DISCOVER_OVER_PULL = 30;
17764
+ var DEFAULT_RADIUS_KM = 20;
17761
17765
  function normalizeGeo(value) {
17762
17766
  return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
17763
17767
  }
@@ -17782,23 +17786,75 @@ function stateFieldOf(lead) {
17782
17786
  const state = lead?.location?.state;
17783
17787
  return typeof state === "string" ? normalizeGeo(state) : "";
17784
17788
  }
17785
- function filterDiscoverByCity(leads, cityHint) {
17789
+ function posOf(lead) {
17790
+ const pos = lead?.location?.pos;
17791
+ const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number" && Number.isFinite(n));
17792
+ return valid ? [pos[0], pos[1]] : null;
17793
+ }
17794
+ function distanceKm(a, b) {
17795
+ const toRad = (d) => d * Math.PI / 180;
17796
+ const dLat = toRad(b[0] - a[0]);
17797
+ const dLng = toRad(b[1] - a[1]);
17798
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2;
17799
+ return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
17800
+ }
17801
+ function withinRadius(leads, anchors, radiusKm) {
17802
+ if (anchors.length === 0 || !(radiusKm > 0))
17803
+ return [];
17804
+ return leads.filter((l) => {
17805
+ const p = posOf(l);
17806
+ return p !== null && anchors.some((a) => distanceKm(a, p) <= radiusKm);
17807
+ });
17808
+ }
17809
+ function filterDiscoverByCity(leads, cityHint, monitorLeads, radiusKm) {
17786
17810
  const hint = cityHint ? cityHintCore(cityHint) : { name: "", isCity: false };
17787
17811
  if (!hint.name)
17788
- return { leads, matchedOn: null };
17812
+ return { leads, matchedOn: null, nearby: [], anchored: false };
17789
17813
  const cityOf = (l) => typeof l?.location?.city === "string" ? townName(l.location.city) : "";
17814
+ const positions = (ls) => ls.map(posOf).filter((p) => p !== null);
17790
17815
  const byCity = leads.filter((l) => cityOf(l) === hint.name);
17791
- if (byCity.length > 0)
17792
- return { leads: byCity, matchedOn: "city" };
17793
- if (hint.isCity)
17794
- return { leads: [], matchedOn: "city" };
17795
- const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
17796
- return { leads: byState, matchedOn: "state" };
17816
+ const anchors = positions([
17817
+ ...byCity,
17818
+ ...monitorLeads.filter((l) => cityOf(l) === hint.name)
17819
+ ]);
17820
+ if (byCity.length > 0) {
17821
+ const inTown = new Set(byCity);
17822
+ const nearby2 = withinRadius(leads.filter((l) => !inTown.has(l)), anchors, radiusKm);
17823
+ return { leads: [...byCity, ...nearby2], matchedOn: "city", nearby: nearby2, anchored: true };
17824
+ }
17825
+ const anchored = anchors.length > 0;
17826
+ if (!hint.isCity) {
17827
+ const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
17828
+ if (byState.length > 0)
17829
+ return { leads: byState, matchedOn: "state", nearby: [], anchored };
17830
+ }
17831
+ const nearby = withinRadius(leads, anchors, radiusKm);
17832
+ if (nearby.length > 0)
17833
+ return { leads: nearby, matchedOn: "city", nearby, anchored };
17834
+ return {
17835
+ leads: [],
17836
+ matchedOn: hint.isCity ? "city" : "state",
17837
+ nearby: [],
17838
+ anchored
17839
+ };
17840
+ }
17841
+ function townsOf(leads) {
17842
+ const seen = [];
17843
+ let unnamed = 0;
17844
+ for (const l of leads) {
17845
+ const city = typeof l?.location?.city === "string" ? l.location.city.trim() : "";
17846
+ const name = city.replace(/^(?:City|Town|Village|Borough|Township|Municipality) of /i, "");
17847
+ if (!name)
17848
+ unnamed += 1;
17849
+ else if (!seen.includes(name))
17850
+ seen.push(name);
17851
+ }
17852
+ const rest = unnamed > 0 ? `${unnamed} whose record names no town` : "";
17853
+ return [seen.join(", "), rest].filter(Boolean).join(", plus ");
17797
17854
  }
17798
17855
  function toMapLocation(lead, mode) {
17799
- const pos = lead?.location?.pos;
17800
- const valid = Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number");
17801
- if (!valid)
17856
+ const pos = posOf(lead);
17857
+ if (pos === null)
17802
17858
  return null;
17803
17859
  const loc = lead.location;
17804
17860
  const c = lead.recommended_contact;
@@ -17871,6 +17927,10 @@ var tourPlan = {
17871
17927
  discover_count: {
17872
17928
  type: "number",
17873
17929
  description: `Top-N Discover leads (active lens wishlist) to return after client-side city filter. Default ${DEFAULT_DISCOVER_COUNT}.`
17930
+ },
17931
+ radius_km: {
17932
+ type: "number",
17933
+ description: `How far outside the named town a Discover stop may be, in km. Default ${DEFAULT_RADIUS_KM}, which is roughly a metro area \u2014 it is what puts West Sacramento on a tour of Sacramento and Courbevoie on a tour of Paris. Pass the user's own number when they name one ("dans un rayon de 10km", "zone 20km", "within 15 miles" \u2192 24). Pass 0 to keep the tour inside the town's own name. Leads in the town itself always come first; the radius only adds to them. It applies to Discover leads only \u2014 the Monitor half is scoped server-side and is untouched.`
17874
17934
  }
17875
17935
  },
17876
17936
  additionalProperties: false
@@ -17887,12 +17947,12 @@ var tourPlan = {
17887
17947
  },
17888
17948
  discover_leads: {
17889
17949
  type: "array",
17890
- description: "Fresh Discover leads from the active lens, filtered client-side to match the city. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
17950
+ description: "Fresh Discover leads from the active lens, filtered client-side to match the city, then extended with the ones whose own coordinates put them within `radius_km` of it. The town's own leads come first. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
17891
17951
  items: { type: "object" }
17892
17952
  },
17893
17953
  discover_filter_note: {
17894
17954
  type: "string",
17895
- description: "Human-readable summary of the client-side geo filter applied to Discover leads (e.g. 'matched 3/30 by city/state')."
17955
+ description: "Human-readable summary of the client-side geo filter applied to Discover leads. Says how many stops are in the named town, how many are within `radius_km` of it, and which towns those are in \u2014 repeat that split to the user rather than presenting every stop as being in the city they named."
17896
17956
  },
17897
17957
  map_locations: {
17898
17958
  type: "array",
@@ -18016,12 +18076,21 @@ var tourPlan = {
18016
18076
  const cityName = params.city && !/^\d+$/.test(params.city.trim()) ? params.city : void 0;
18017
18077
  const knownId = params.city_id ?? (cityName ? void 0 : params.city);
18018
18078
  const idOnly = Boolean(knownId) && !cityName;
18019
- const { leads: filtered, matchedOn } = idOnly ? { leads: [], matchedOn: null } : filterDiscoverByCity(rawDiscover, cityName);
18079
+ const radiusKm = params.radius_km ?? DEFAULT_RADIUS_KM;
18080
+ const { leads: filtered, matchedOn, nearby, anchored } = idOnly ? {
18081
+ leads: [],
18082
+ matchedOn: null,
18083
+ nearby: [],
18084
+ anchored: false
18085
+ } : filterDiscoverByCity(rawDiscover, cityName, monitorLeads, radiusKm);
18020
18086
  const discoverLeads2 = filtered.slice(0, discoverCount);
18021
18087
  const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
18022
18088
  if (pulledLensId != null) {
18023
18089
  reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
18024
18090
  }
18091
+ const nearbySet = new Set(nearby);
18092
+ const nearShown = discoverLeads2.filter((l) => nearbySet.has(l));
18093
+ const inTownShown = discoverLeads2.length - nearShown.length;
18025
18094
  let filterNote;
18026
18095
  if (idOnly) {
18027
18096
  filterNote = `Discover leads need the NAME of the place, and this call passed an area id (${knownId}) and no name. Re-call with \`city\` set to the name of that area, keeping \`city_id\` so the Monitor half stays on the area you picked. The follow-ups below are already scoped to it.`;
@@ -18029,10 +18098,16 @@ var tourPlan = {
18029
18098
  filterNote = `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
18030
18099
  } else if (matchedOn === null) {
18031
18100
  filterNote = `No usable city filter in '${params.city}'; returning top ${discoverLeads2.length} Discover leads.`;
18101
+ } else if (filtered.length === 0 && anchored && radiusKm > 0) {
18102
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town, and none is within ${radiusKm} km of the leads that do (checked ${rawDiscover.length} candidates). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
18032
18103
  } else if (filtered.length === 0) {
18033
18104
  filterNote = `No Discover lead in the active lens is in '${params.city}' (checked ${rawDiscover.length} candidates by city, then by state/region). Say so; do NOT present leads from elsewhere as if they were in '${params.city}'.`;
18105
+ } else if (nearShown.length > 0 && inTownShown === 0) {
18106
+ filterNote = `No Discover lead in the active lens names '${params.city}' as its town; ${nearby.length}/${rawDiscover.length} are within ${radiusKm} km of it. Returning top ${discoverLeads2.length}, in ${townsOf(nearShown)}. Name the town each stop is actually in rather than calling them all '${params.city}'.`;
18107
+ } else if (nearShown.length > 0) {
18108
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by city, plus ${nearby.length} within ${radiusKm} km of it. Returning top ${discoverLeads2.length}: ${inTownShown} in '${params.city}' and ${nearShown.length} in ${townsOf(nearShown)}. Name the town each nearby stop is in.`;
18034
18109
  } else {
18035
- filterNote = `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
18110
+ filterNote = `Matched ${filtered.length - nearby.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
18036
18111
  }
18037
18112
  return {
18038
18113
  city: params.city ?? null,
@@ -19447,7 +19522,7 @@ var researchLeadByNameFuzzy = {
19447
19522
  // of a research card.
19448
19523
  outputSchema: {
19449
19524
  type: "object",
19450
- description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id.",
19525
+ description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id. When neither the user's leads nor the registry hold the company, returns {resolution:'not_found', query, summary, would_help, next_step} \u2014 a successful answer, not an error: do what next_step says, which is normally to ask the user for the field would_help names ('website' or 'registry_number') and call again. would_help is empty when the search was scoped to one lens, where the remedy is to drop the scope, not to ask the user for a field.",
19451
19526
  additionalProperties: true
19452
19527
  },
19453
19528
  execute: async (client, params, ctx) => {
@@ -19461,7 +19536,18 @@ var researchLeadByNameFuzzy = {
19461
19536
  if (scoped.length > 0) {
19462
19537
  return await delegate(scoped, params.lensId);
19463
19538
  }
19464
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" in lens ${params.lensId}`, "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.");
19539
+ return {
19540
+ resolution: "not_found",
19541
+ query,
19542
+ summary: `No lead matching "${query}" in lens ${params.lensId}`,
19543
+ would_help: [],
19544
+ next_step: "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.",
19545
+ _meta: {
19546
+ region: client.region,
19547
+ lens_id: params.lensId,
19548
+ resolved_query: query
19549
+ }
19550
+ };
19465
19551
  }
19466
19552
  async function delegate(matches, fallbackLens) {
19467
19553
  const [primary, ...rest] = matches;
@@ -19556,7 +19642,23 @@ var researchLeadByNameFuzzy = {
19556
19642
  const wanted = resolved.type === "none" && resolved.would_help.length > 0 ? resolved.would_help : ["website", "registry_number"];
19557
19643
  const asks = wanted.map((f) => f === "registry_number" ? "a registry number (SIREN/SIRET) for `registry_number`" : f === "website" ? "the company website for `website`" : `\`${f}\``).join(" or ");
19558
19644
  const hint = resolved.type === "unidentifiable" ? `The registry could not identify a company from this input (${resolved.reason}). Ask the user for ${asks}, then call this tool again with it.` : `The registry found no company for what was supplied. It would match on ${asks}. Ask the user for that \u2014 "what's their website?" usually settles it \u2014 then call this tool again. Do not offer an import before asking.`;
19559
- throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, hint, "POST /leads/resolve");
19645
+ if (!corpusSearched) {
19646
+ throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, `${hint} But this lookup did not complete \u2014 the lead search route was unreachable, so their own leads were never checked. Retry once before telling the user the company is absent.`, "POST /leads/resolve");
19647
+ }
19648
+ return {
19649
+ resolution: "not_found",
19650
+ query,
19651
+ resolver_payload: payload,
19652
+ summary: `No company matching "${query}" ${searched}`,
19653
+ would_help: wanted,
19654
+ next_step: hint,
19655
+ _meta: {
19656
+ region: client.region,
19657
+ resolved_from: "resolver",
19658
+ resolved_query: query,
19659
+ endpoint: "POST /leads/resolve"
19660
+ }
19661
+ };
19560
19662
  }
19561
19663
  };
19562
19664
 
@@ -23640,6 +23742,21 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
23640
23742
  const items = current?.lens_filter?.items ?? [];
23641
23743
  const item = items[0] ?? { criteria: [] };
23642
23744
  const criteria = item.criteria ? [...item.criteria] : [];
23745
+ const dropIds = (type, fromExcluded, ids) => {
23746
+ if (ids.length === 0)
23747
+ return;
23748
+ const idx = criteria.findIndex((c) => c.type === type && !!c.is_excluded === fromExcluded);
23749
+ if (idx < 0)
23750
+ return;
23751
+ const cur = criteria[idx];
23752
+ const key = type === "sector_ids" ? "sectors" : "locations";
23753
+ const held = cur[key] ?? [];
23754
+ const kept = held.filter((s) => !ids.includes(s));
23755
+ if (kept.length === 0)
23756
+ criteria.splice(idx, 1);
23757
+ else
23758
+ criteria[idx] = { ...cur, [key]: kept };
23759
+ };
23643
23760
  if (toAddSectors.length > 0) {
23644
23761
  const idx = criteria.findIndex((c) => c.type === "sector_ids" && !c.is_excluded);
23645
23762
  if (idx >= 0) {
@@ -23654,6 +23771,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
23654
23771
  });
23655
23772
  }
23656
23773
  }
23774
+ dropIds("sector_ids", true, toAddSectors);
23657
23775
  if (toExcludeSectors.length > 0) {
23658
23776
  const idx = criteria.findIndex((c) => c.type === "sector_ids" && c.is_excluded);
23659
23777
  if (idx >= 0) {
@@ -23668,6 +23786,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
23668
23786
  });
23669
23787
  }
23670
23788
  }
23789
+ dropIds("sector_ids", false, toExcludeSectors);
23671
23790
  if (toAddLocations.length > 0) {
23672
23791
  const idx = criteria.findIndex((c) => c.type === "location_ids" && !c.is_excluded);
23673
23792
  if (idx >= 0) {
@@ -23682,6 +23801,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
23682
23801
  });
23683
23802
  }
23684
23803
  }
23804
+ dropIds("location_ids", true, toAddLocations);
23685
23805
  if (toExcludeLocations.length > 0) {
23686
23806
  const idx = criteria.findIndex((c) => c.type === "location_ids" && c.is_excluded);
23687
23807
  if (idx >= 0) {
@@ -23696,6 +23816,7 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes, toAddLocati
23696
23816
  });
23697
23817
  }
23698
23818
  }
23819
+ dropIds("location_ids", false, toExcludeLocations);
23699
23820
  if (sizes && sizes.length > 0) {
23700
23821
  const normalizedSizes = sizes.map((s) => ({
23701
23822
  min: s.min ?? 0,
@@ -24891,10 +25012,14 @@ function normalizeSearchFilters(filters) {
24891
25012
  }
24892
25013
  return out;
24893
25014
  }
24894
- function clampWaitSeconds(requested, fallback) {
24895
- if (requested == null || Number.isNaN(requested))
24896
- return fallback;
24897
- return Math.min(Math.max(requested, 0), 180);
25015
+ var MAX_WAIT_SECONDS = 45;
25016
+ function clampWaitSeconds(requested, fallback, startedAtMs) {
25017
+ const asked = requested == null || Number.isNaN(requested) ? fallback : requested;
25018
+ const bounded = Math.min(Math.max(asked, 0), MAX_WAIT_SECONDS);
25019
+ if (startedAtMs == null)
25020
+ return bounded;
25021
+ const spentSeconds = (Date.now() - startedAtMs) / 1e3;
25022
+ return Math.max(0, Math.min(bounded, MAX_WAIT_SECONDS - spentSeconds));
24898
25023
  }
24899
25024
 
24900
25025
  // ../core/dist/composite/find-new-leads.js
@@ -25075,13 +25200,14 @@ var findNewLeads = {
25075
25200
  },
25076
25201
  wait_seconds: {
25077
25202
  type: "number",
25078
- description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window; qualified exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
25203
+ description: "How long to poll before returning (default and maximum 45 \u2014 the whole call is bounded by it, so a slow submit shortens the poll; 0 = submit + one snapshot). Free searches usually finish inside the window; qualified exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
25079
25204
  }
25080
25205
  },
25081
25206
  required: ["count", "request_id"],
25082
25207
  additionalProperties: false
25083
25208
  },
25084
25209
  execute: async (client, params, ctx) => {
25210
+ const startedAt = Date.now();
25085
25211
  params = coerceArrayParams(params, [
25086
25212
  "contact_titles",
25087
25213
  "channels",
@@ -25220,7 +25346,7 @@ var findNewLeads = {
25220
25346
  const mocked = mockedSubmitPreview(submit, "leadbay_find_new_leads", client.region);
25221
25347
  if (mocked)
25222
25348
  return mocked;
25223
- const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS);
25349
+ const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS, startedAt);
25224
25350
  const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, params.count);
25225
25351
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
25226
25352
  const { leads, skipped } = splitItems(snapshot);
@@ -25265,7 +25391,7 @@ var findNewLeads = {
25265
25391
  // INCREMENTALLY instead of re-reading (and re-rendering) the
25266
25392
  // rows already delivered in this response.
25267
25393
  since: snapshot.next_since ?? null,
25268
- suggested_wait_seconds: done ? 0 : 60
25394
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
25269
25395
  },
25270
25396
  ...sectorNote(attempt.rewritten),
25271
25397
  ...countryNote,
@@ -25399,7 +25525,7 @@ function identityAnswer(jobId, snapshot, items, offset) {
25399
25525
  job_id: jobId,
25400
25526
  compact: true,
25401
25527
  offset: nextOffset,
25402
- suggested_wait_seconds: done ? 0 : 60
25528
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
25403
25529
  }
25404
25530
  };
25405
25531
  }
@@ -25597,12 +25723,13 @@ var qualifyLeads = {
25597
25723
  },
25598
25724
  wait_seconds: {
25599
25725
  type: "number",
25600
- description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Large or research-heavy batches can take minutes \u2014 the result then carries still_running:true and the job_id for leadbay_lead_job_status."
25726
+ description: "How long to poll before returning (default and maximum 45 \u2014 the whole call is bounded by it, so a slow submit shortens the poll; 0 = submit + one snapshot). Large or research-heavy batches can take minutes \u2014 the result then carries still_running:true and the job_id for leadbay_lead_job_status."
25601
25727
  }
25602
25728
  },
25603
25729
  additionalProperties: false
25604
25730
  },
25605
25731
  execute: async (client, params, ctx) => {
25732
+ const startedAt = Date.now();
25606
25733
  params = coerceArrayParams(params, [
25607
25734
  "lead_refs",
25608
25735
  "contact_titles",
@@ -25662,7 +25789,7 @@ var qualifyLeads = {
25662
25789
  const mocked = mockedSubmitPreview(submit, "leadbay_qualify_leads", client.region);
25663
25790
  if (mocked)
25664
25791
  return mocked;
25665
- const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS2);
25792
+ const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS2, startedAt);
25666
25793
  const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, submit.items_requested);
25667
25794
  const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
25668
25795
  const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
@@ -25739,7 +25866,7 @@ var qualifyLeads = {
25739
25866
  // INCREMENTALLY instead of re-reading (and re-rendering) the
25740
25867
  // rows already delivered in this response.
25741
25868
  since: snapshot.next_since ?? null,
25742
- suggested_wait_seconds: done ? 0 : 60
25869
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
25743
25870
  },
25744
25871
  region: client.region
25745
25872
  };
@@ -25774,7 +25901,7 @@ var leadJobStatus = {
25774
25901
  },
25775
25902
  wait_seconds: {
25776
25903
  type: "number",
25777
- description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds until the job is terminal \u2014 use ~60 when the user asked to wait for results."
25904
+ description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds (maximum 45) until the job is terminal \u2014 use 45 when the user asked to wait for results."
25778
25905
  },
25779
25906
  compact: {
25780
25907
  type: "boolean",
@@ -25826,7 +25953,7 @@ var leadJobStatus = {
25826
25953
  // Same incremental handoff as the submit tools — following
25827
25954
  // next_poll without the cursor re-reads the rows just returned.
25828
25955
  since: snapshot.next_since ?? null,
25829
- suggested_wait_seconds: done ? 0 : 60
25956
+ suggested_wait_seconds: done ? 0 : MAX_WAIT_SECONDS
25830
25957
  },
25831
25958
  region: client.region
25832
25959
  };
@@ -29308,7 +29435,7 @@ function parseWriteEnv(env = process.env) {
29308
29435
  }
29309
29436
 
29310
29437
  // src/http-server.ts
29311
- var VERSION = true ? "0.39.5" : "0.0.0-dev";
29438
+ var VERSION = true ? "0.39.8" : "0.0.0-dev";
29312
29439
  var PORT = Number(process.env.PORT ?? 8080);
29313
29440
  var HOST = process.env.HOST ?? "0.0.0.0";
29314
29441
  var logger = {
@@ -1805,7 +1805,7 @@ var init_installer_gui = __esm({
1805
1805
  init_install_dxt();
1806
1806
  init_install_shared();
1807
1807
  init_oauth();
1808
- VERSION = true ? "0.39.5" : "0.0.0-dev";
1808
+ VERSION = true ? "0.39.8" : "0.0.0-dev";
1809
1809
  MESSAGES = {
1810
1810
  en: {
1811
1811
  installer: {
@@ -1068,7 +1068,7 @@ async function oauthLogin(opts) {
1068
1068
  }
1069
1069
 
1070
1070
  // installer/installer-gui.ts
1071
- var VERSION = true ? "0.39.5" : "0.0.0-dev";
1071
+ var VERSION = true ? "0.39.8" : "0.0.0-dev";
1072
1072
  var MESSAGES = {
1073
1073
  en: {
1074
1074
  installer: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.39.5",
3
+ "version": "0.39.8",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",