@leadbay/mcp 0.39.1 → 0.39.3

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
@@ -6731,7 +6731,7 @@ prose paragraph. Full recipe below.
6731
6731
 
6732
6732
  Build a single-call mixed-mode itinerary for a field sales tour. Combines \`leadbay_pull_followups\` (Monitor leads in the city \u2014 known accounts) with \`leadbay_pull_leads\` (Discover wishlist \u2014 new prospects, then client-side filtered by city) so the agent can answer the canonical #3630 US1 ask: *"I'm visiting Limoges in 4 days \u2014 propose 3 customers + 3 qualified prospects + 3 new high-potential discoveries."*
6733
6733
 
6734
- **Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any level from state down to neighborhood \u2014 state, *r\xE9gion*, county, city \u2014 the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; pick an id and re-call with \`city_id\`.
6734
+ **Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any level from state down to neighborhood \u2014 state, *r\xE9gion*, county, city \u2014 the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; re-call with \`city_id\` set to the id you pick AND \`city\` set to that candidate's \`name\`. Both are needed: the id scopes the follow-ups, the name scopes the Discover leads, and with \`city_id\` alone \`discover_leads\` comes back empty.
6735
6735
 
6736
6736
  **One workspace = one country \u2014 a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country.
6737
6737
 
@@ -6761,7 +6761,7 @@ not an itinerary. So for ANY country-level \`city\` \u2014 this workspace's own
6761
6761
  visiting and re-call with that. \`status: "country_level_location"\` carries the
6762
6762
  same instruction in its \`hint\`.
6763
6763
 
6764
- **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 filters by \`location.city/state/country/full\` substring match against the requested city. The \`discover_filter_note\` string in the response tells the agent the match ratio so it can be honest about coverage ("matched 3/30 by city/state" vs. "matched 12/30").
6764
+ **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.
6765
6765
 
6766
6766
  **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.
6767
6767
 
@@ -14249,8 +14249,8 @@ function summarise(responses) {
14249
14249
  return { answered, total, avg_qualification_boost: avg, best_response_excerpt: excerpt };
14250
14250
  }
14251
14251
  function buildPullLeadsNextSteps(args) {
14252
- const { leadCount, hasMore, nextPage, computingWishlist, computingScores } = args;
14253
- if (leadCount <= 0) {
14252
+ const { leadCount: leadCount2, hasMore, nextPage, computingWishlist, computingScores } = args;
14253
+ if (leadCount2 <= 0) {
14254
14254
  if (computingWishlist || computingScores) {
14255
14255
  return {
14256
14256
  question: "This lens is still warming up. What next?",
@@ -14503,15 +14503,15 @@ var init_pull_leads = __esm({
14503
14503
  const currentPage = res.pagination?.page ?? page;
14504
14504
  const hasMore = currentPage < totalPages - 1;
14505
14505
  const nextPage = hasMore ? currentPage + 1 : null;
14506
- const leadCount = res.items.length;
14506
+ const leadCount2 = res.items.length;
14507
14507
  const nextSteps = buildPullLeadsNextSteps({
14508
- leadCount,
14508
+ leadCount: leadCount2,
14509
14509
  hasMore,
14510
14510
  nextPage,
14511
14511
  computingWishlist: res.computing_wishlist,
14512
14512
  computingScores: res.computing_scores
14513
14513
  });
14514
- const lensIsEmpty = leadCount === 0 && (res.pagination?.total ?? 0) === 0;
14514
+ const lensIsEmpty = leadCount2 === 0 && (res.pagination?.total ?? 0) === 0;
14515
14515
  const emptyReason = lensIsEmpty ? await diagnoseEmptyLens(client, lensId, {
14516
14516
  wishlist: res.computing_wishlist,
14517
14517
  scores: res.computing_scores
@@ -14962,13 +14962,33 @@ var init_followups_map = __esm({
14962
14962
  });
14963
14963
 
14964
14964
  // ../core/dist/composite/tour-plan.js
14965
- function cityMatches(lead, cityHint) {
14966
- if (!cityHint)
14967
- return true;
14968
- const hint = cityHint.toLowerCase();
14969
- const loc = lead?.location ?? {};
14970
- const haystacks = [loc.city, loc.state, loc.country, loc.full].filter((v) => typeof v === "string").map((v) => v.toLowerCase());
14971
- return haystacks.some((h) => h.includes(hint) || hint.includes(h));
14965
+ function normalizeGeo(value) {
14966
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
14967
+ }
14968
+ function townName(value) {
14969
+ return normalizeGeo(value).replace(ADMIN_PREFIX, "");
14970
+ }
14971
+ function cityHintCore(cityHint) {
14972
+ const head = cityHint.split(",")[0] ?? "";
14973
+ const expanded = expandAlias(head);
14974
+ return { name: townName(expanded), isCity: expanded !== head };
14975
+ }
14976
+ function stateFieldOf(lead) {
14977
+ const state = lead?.location?.state;
14978
+ return typeof state === "string" ? normalizeGeo(state) : "";
14979
+ }
14980
+ function filterDiscoverByCity(leads, cityHint) {
14981
+ const hint = cityHint ? cityHintCore(cityHint) : { name: "", isCity: false };
14982
+ if (!hint.name)
14983
+ return { leads, matchedOn: null };
14984
+ const cityOf = (l) => typeof l?.location?.city === "string" ? townName(l.location.city) : "";
14985
+ const byCity = leads.filter((l) => cityOf(l) === hint.name);
14986
+ if (byCity.length > 0)
14987
+ return { leads: byCity, matchedOn: "city" };
14988
+ if (hint.isCity)
14989
+ return { leads: [], matchedOn: "city" };
14990
+ const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
14991
+ return { leads: byState, matchedOn: "state" };
14972
14992
  }
14973
14993
  function toMapLocation(lead, mode) {
14974
14994
  const pos = lead?.location?.pos;
@@ -15018,7 +15038,7 @@ function buildMap(monitorLeads, discoverLeads2) {
15018
15038
  }
15019
15039
  };
15020
15040
  }
15021
- var DEFAULT_FOLLOWUPS_COUNT, DEFAULT_DISCOVER_COUNT, DISCOVER_OVER_PULL, tourPlan;
15041
+ var DEFAULT_FOLLOWUPS_COUNT, DEFAULT_DISCOVER_COUNT, DISCOVER_OVER_PULL, ADMIN_PREFIX, tourPlan;
15022
15042
  var init_tour_plan = __esm({
15023
15043
  "../core/dist/composite/tour-plan.js"() {
15024
15044
  "use strict";
@@ -15026,10 +15046,12 @@ var init_tour_plan = __esm({
15026
15046
  init_pull_leads();
15027
15047
  init_interactions();
15028
15048
  init_country_guard();
15049
+ init_geo_helpers();
15029
15050
  init_tool_descriptions_generated();
15030
15051
  DEFAULT_FOLLOWUPS_COUNT = 6;
15031
15052
  DEFAULT_DISCOVER_COUNT = 6;
15032
15053
  DISCOVER_OVER_PULL = 30;
15054
+ ADMIN_PREFIX = /^(?:city|town|village|borough|township|municipality) of /;
15033
15055
  tourPlan = {
15034
15056
  name: "leadbay_tour_plan",
15035
15057
  annotations: {
@@ -15049,7 +15071,7 @@ var init_tour_plan = __esm({
15049
15071
  },
15050
15072
  city_id: {
15051
15073
  type: "string",
15052
- description: "Pre-resolved admin_area id (numeric string). Bypasses the resolver."
15074
+ description: "Pre-resolved admin_area id (numeric string). Bypasses the resolver. Pass `city` alongside it with the NAME of the area you picked: the id scopes the Monitor half, and the name is the only thing that can scope the Discover half, which has no server-side geo filter. With `city_id` alone, `discover_leads` comes back empty."
15053
15075
  },
15054
15076
  followups_count: {
15055
15077
  type: "number",
@@ -15156,7 +15178,13 @@ var init_tour_plan = __esm({
15156
15178
  const discoverCount = params.discover_count ?? DEFAULT_DISCOVER_COUNT;
15157
15179
  const [followupsResult, leadsResult] = await Promise.allSettled([
15158
15180
  pullFollowups.execute(client, {
15159
- city: params.city,
15181
+ // A pre-resolved id bypasses the resolver, which is what its own
15182
+ // schema promises. Forwarding the free text alongside it sends the
15183
+ // name back through /geo/search, and the name is the thing that was
15184
+ // ambiguous — so an agent recovering from `ambiguous_locations` by
15185
+ // picking an id would be handed the same ambiguity again. Here the
15186
+ // free text stays behind and scopes the Discover half instead.
15187
+ city: params.city_id ? void 0 : params.city,
15160
15188
  city_id: params.city_id,
15161
15189
  count: followupsCount
15162
15190
  }, ctx),
@@ -15170,7 +15198,7 @@ var init_tour_plan = __esm({
15170
15198
  location_ambiguities: r.location_ambiguities,
15171
15199
  monitor_leads: [],
15172
15200
  discover_leads: [],
15173
- discover_filter_note: "City was ambiguous; pick an id and re-call to proceed.",
15201
+ discover_filter_note: "City was ambiguous; re-call with `city_id` set to the id you pick AND `city` set to that candidate's `name`. The id scopes the follow-ups; the name is what scopes the Discover leads.",
15174
15202
  map_locations: [],
15175
15203
  map_summary: {
15176
15204
  total_leads: 0,
@@ -15194,13 +15222,27 @@ var init_tour_plan = __esm({
15194
15222
  if (leadsResult.status === "rejected") {
15195
15223
  ctx?.logger?.warn?.(`tour_plan: pull_leads failed: ${leadsResult.reason?.message ?? leadsResult.reason}`);
15196
15224
  }
15197
- const filtered = rawDiscover.filter((l) => cityMatches(l, params.city));
15225
+ const cityName = params.city && !/^\d+$/.test(params.city.trim()) ? params.city : void 0;
15226
+ const knownId = params.city_id ?? (cityName ? void 0 : params.city);
15227
+ const idOnly = Boolean(knownId) && !cityName;
15228
+ const { leads: filtered, matchedOn } = idOnly ? { leads: [], matchedOn: null } : filterDiscoverByCity(rawDiscover, cityName);
15198
15229
  const discoverLeads2 = filtered.slice(0, discoverCount);
15199
15230
  const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
15200
15231
  if (pulledLensId != null) {
15201
15232
  reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
15202
15233
  }
15203
- const filterNote = params.city ? `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}'; returning top ${discoverLeads2.length}.` : `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
15234
+ let filterNote;
15235
+ if (idOnly) {
15236
+ 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.`;
15237
+ } else if (!cityName) {
15238
+ filterNote = `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
15239
+ } else if (matchedOn === null) {
15240
+ filterNote = `No usable city filter in '${params.city}'; returning top ${discoverLeads2.length} Discover leads.`;
15241
+ } else if (filtered.length === 0) {
15242
+ 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}'.`;
15243
+ } else {
15244
+ filterNote = `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
15245
+ }
15204
15246
  return {
15205
15247
  city: params.city ?? null,
15206
15248
  city_id: params.city_id ?? null,
@@ -20766,27 +20808,167 @@ var init_bulk_enrich_status = __esm({
20766
20808
  }
20767
20809
  });
20768
20810
 
20769
- // ../core/dist/composite/adjust-audience.js
20770
- function tokens(s) {
20771
- if (!s)
20772
- return [];
20773
- return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
20811
+ // ../core/dist/composite/_sector-resolver.js
20812
+ function normalizeSectorText(value) {
20813
+ return (value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^0-9a-z]+/g, " ").trim();
20774
20814
  }
20775
- function bestMatches(text3, taxonomy) {
20776
- const want = new Set(tokens(text3));
20777
- if (want.size === 0)
20778
- return [];
20779
- const ranked = taxonomy.map((s) => {
20780
- const have = new Set(tokens(s.name));
20815
+ function singular(token) {
20816
+ if (token.length > 4 && token.endsWith("ies"))
20817
+ return `${token.slice(0, -3)}y`;
20818
+ return token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token;
20819
+ }
20820
+ function sectorTokens(value) {
20821
+ const all = normalizeSectorText(value).split(" ").filter(Boolean).map(singular);
20822
+ const core = all.filter((t) => !FILLER.has(t));
20823
+ return core.length > 0 ? core : all;
20824
+ }
20825
+ function sectorLabel(row) {
20826
+ if (!row)
20827
+ return "";
20828
+ const label = row.label;
20829
+ if (typeof label === "string" && label.length > 0)
20830
+ return label;
20831
+ return typeof row.name === "string" ? row.name : "";
20832
+ }
20833
+ function leadCount(row) {
20834
+ const n = row.number_of_leads;
20835
+ return typeof n === "number" && Number.isFinite(n) ? n : 0;
20836
+ }
20837
+ function rankCandidates(scored, tieBreak) {
20838
+ const out = [];
20839
+ const seen = /* @__PURE__ */ new Set();
20840
+ scored.sort((a, b) => b.score - a.score || (tieBreak === "breadth" ? leadCount(b.row) - leadCount(a.row) || b.closeness - a.closeness : b.closeness - a.closeness || leadCount(b.row) - leadCount(a.row)) || sectorLabel(a.row).length - sectorLabel(b.row).length).forEach(({ row, score }) => {
20841
+ const label = sectorLabel(row);
20842
+ if (!label || seen.has(label) || out.length >= MAX_CANDIDATES)
20843
+ return;
20844
+ seen.add(label);
20845
+ out.push({ id: row.id, name: label, score: Number(score.toFixed(2)) });
20846
+ });
20847
+ return out;
20848
+ }
20849
+ function matchSector(text3, taxonomy) {
20850
+ const wanted = normalizeSectorText(text3);
20851
+ if (!wanted)
20852
+ return { kind: "unresolved", candidates: [] };
20853
+ const exact = taxonomy.filter((row) => normalizeSectorText(sectorLabel(row)) === wanted);
20854
+ if (exact.length > 0) {
20855
+ const label = sectorLabel(exact.reduce((best, row) => leadCount(row) > leadCount(best) ? row : best));
20856
+ const ids = taxonomy.filter((row) => sectorLabel(row) === label).map((row) => row.id);
20857
+ return { kind: "resolved", label, ids, exact: true };
20858
+ }
20859
+ const want = new Set(sectorTokens(text3));
20860
+ const sameWords = taxonomy.filter((row) => {
20861
+ const have = new Set(sectorTokens(sectorLabel(row)));
20862
+ return have.size === want.size && [...want].every((token) => have.has(token));
20863
+ });
20864
+ const sameWordLabels = new Set(sameWords.map((row) => sectorLabel(row)).filter(Boolean));
20865
+ if (sameWordLabels.size === 1) {
20866
+ const label = [...sameWordLabels][0];
20867
+ const ids = taxonomy.filter((row) => sectorLabel(row) === label).map((row) => row.id);
20868
+ return { kind: "resolved", label, ids, exact: false };
20869
+ }
20870
+ const scored = [];
20871
+ for (const row of taxonomy) {
20872
+ const have = new Set(sectorTokens(sectorLabel(row)));
20873
+ if (have.size === 0)
20874
+ continue;
20781
20875
  let overlap = 0;
20782
- for (const t of want)
20783
- if (have.has(t))
20876
+ for (const token of want)
20877
+ if (have.has(token))
20784
20878
  overlap += 1;
20785
- const score = overlap / Math.max(want.size, 1);
20786
- return { id: s.id, name: s.name ?? "", score };
20787
- }).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
20788
- return ranked.slice(0, 5);
20879
+ if (overlap === 0)
20880
+ continue;
20881
+ scored.push({ row, score: overlap / want.size, closeness: overlap / have.size });
20882
+ }
20883
+ const full = scored.filter((s) => s.score >= 1);
20884
+ return {
20885
+ kind: "unresolved",
20886
+ candidates: full.length > 0 ? rankCandidates(full, "breadth") : rankCandidates(scored, "closeness").slice(0, MAX_PARTIAL_CANDIDATES)
20887
+ };
20888
+ }
20889
+ function sectorSections(taxonomy) {
20890
+ const roots = taxonomy.filter((row) => !row.parent);
20891
+ const labels = [];
20892
+ for (const row of roots.sort((a, b) => leadCount(b) - leadCount(a))) {
20893
+ const label = sectorLabel(row);
20894
+ if (label && !labels.includes(label))
20895
+ labels.push(label);
20896
+ }
20897
+ return labels;
20898
+ }
20899
+ async function fetchSectorTaxonomy(client, ctx) {
20900
+ const me = await client.resolveMe().catch(() => null);
20901
+ const lang = me?.language ?? "en";
20902
+ const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
20903
+ const unlabelled = taxonomy.filter((row) => !sectorLabel(row)).length;
20904
+ if (unlabelled > 0) {
20905
+ ctx?.logger?.warn?.(`sector taxonomy: ${unlabelled}/${taxonomy.length} row(s) carry no label`);
20906
+ }
20907
+ return taxonomy;
20908
+ }
20909
+ function resolveSectorValues(values, taxonomy) {
20910
+ const out = {
20911
+ values: [],
20912
+ rewritten: [],
20913
+ unresolved: [],
20914
+ sections: sectorSections(taxonomy)
20915
+ };
20916
+ for (const raw of values) {
20917
+ const asked = typeof raw === "string" ? raw : String(raw ?? "");
20918
+ if (NUMERIC_ID.test(asked.trim())) {
20919
+ out.values.push(asked.trim());
20920
+ continue;
20921
+ }
20922
+ const match = matchSector(asked, taxonomy);
20923
+ if (match.kind === "resolved") {
20924
+ out.values.push(match.label);
20925
+ if (match.label !== asked)
20926
+ out.rewritten.push({ asked, used: match.label });
20927
+ continue;
20928
+ }
20929
+ out.values.push(asked);
20930
+ out.unresolved.push({ asked, closest: match.candidates });
20931
+ }
20932
+ return out;
20789
20933
  }
20934
+ var FILLER, MAX_CANDIDATES, MAX_PARTIAL_CANDIDATES, NUMERIC_ID;
20935
+ var init_sector_resolver = __esm({
20936
+ "../core/dist/composite/_sector-resolver.js"() {
20937
+ "use strict";
20938
+ FILLER = /* @__PURE__ */ new Set([
20939
+ // en
20940
+ "and",
20941
+ "of",
20942
+ "the",
20943
+ "for",
20944
+ "or",
20945
+ "activity",
20946
+ // fr
20947
+ "et",
20948
+ "de",
20949
+ "des",
20950
+ "du",
20951
+ "d",
20952
+ "la",
20953
+ "le",
20954
+ "les",
20955
+ "l",
20956
+ "en",
20957
+ "aux",
20958
+ "au",
20959
+ "activite",
20960
+ "pour",
20961
+ "sur",
20962
+ "par",
20963
+ "a"
20964
+ ]);
20965
+ MAX_CANDIDATES = 8;
20966
+ MAX_PARTIAL_CANDIDATES = 4;
20967
+ NUMERIC_ID = /^\d+$/;
20968
+ }
20969
+ });
20970
+
20971
+ // ../core/dist/composite/adjust-audience.js
20790
20972
  async function resolveSectors(client, texts, ctx) {
20791
20973
  const looksLikeId2 = (s) => /^\d+$/.test(s);
20792
20974
  const direct = texts.filter(looksLikeId2);
@@ -20796,18 +20978,18 @@ async function resolveSectors(client, texts, ctx) {
20796
20978
  const me = await client.resolveMe().catch(() => null);
20797
20979
  const lang = me?.language ?? "en";
20798
20980
  const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
20799
- const nullNames = taxonomy.filter((s) => !s.name).length;
20981
+ const nullNames = taxonomy.filter((s) => !sectorLabel(s)).length;
20800
20982
  if (nullNames > 0) {
20801
20983
  ctx?.logger?.warn?.(`adjust_audience: /sectors/all returned ${nullNames}/${taxonomy.length} sector(s) with a null/missing name`);
20802
20984
  }
20803
20985
  const resolved = [...direct];
20804
20986
  const ambiguities = [];
20805
20987
  for (const text3 of free) {
20806
- const matches = bestMatches(text3, taxonomy);
20807
- if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
20808
- resolved.push(matches[0].id);
20988
+ const match = matchSector(text3, taxonomy);
20989
+ if (match.kind === "resolved") {
20990
+ resolved.push(...match.ids);
20809
20991
  } else {
20810
- ambiguities.push({ sector_text: text3, matches });
20992
+ ambiguities.push({ sector_text: text3, matches: match.candidates });
20811
20993
  }
20812
20994
  }
20813
20995
  return { resolved, ambiguities };
@@ -20914,6 +21096,7 @@ var init_adjust_audience = __esm({
20914
21096
  "use strict";
20915
21097
  init_geo_helpers();
20916
21098
  init_country_guard();
21099
+ init_sector_resolver();
20917
21100
  init_tool_descriptions_generated();
20918
21101
  adjustAudience = {
20919
21102
  name: "leadbay_adjust_audience",
@@ -22131,12 +22314,68 @@ function sortFilterLists(filters) {
22131
22314
  }
22132
22315
  return out;
22133
22316
  }
22317
+ function isSectorRejection(err) {
22318
+ const e = err;
22319
+ return !!e && e.code === "BAD_INPUT" && typeof e.message === "string" && e.message.includes("filters.sectors");
22320
+ }
22321
+ async function submitSearch(client, body, opts, ctx) {
22322
+ try {
22323
+ return {
22324
+ ok: true,
22325
+ data: await client.request("POST", "/mcp/search", body, opts),
22326
+ rewritten: []
22327
+ };
22328
+ } catch (err) {
22329
+ const asked = body.filters?.sectors;
22330
+ if (!isSectorRejection(err) || !Array.isArray(asked) || asked.length === 0) {
22331
+ throw err;
22332
+ }
22333
+ const taxonomy = await fetchSectorTaxonomy(client, ctx).catch(() => {
22334
+ throw err;
22335
+ });
22336
+ const fix = resolveSectorValues(asked, taxonomy);
22337
+ if (fix.unresolved.length === 0 && fix.rewritten.length === 0)
22338
+ throw err;
22339
+ if (fix.unresolved.length > 0)
22340
+ return { ok: false, choice: sectorChoice(fix) };
22341
+ const retried = { ...body, filters: { ...body.filters, sectors: fix.values } };
22342
+ return {
22343
+ ok: true,
22344
+ data: await client.request("POST", "/mcp/search", retried, opts),
22345
+ rewritten: fix.rewritten
22346
+ };
22347
+ }
22348
+ }
22349
+ function sectorNote(rewritten) {
22350
+ if (rewritten.length === 0)
22351
+ return void 0;
22352
+ const pairs = rewritten.map((r) => `"${r.asked}" \u2192 "${r.used}"`).join(", ");
22353
+ return {
22354
+ sectors_note: `Leadbay's sector taxonomy spells these differently, so the search ran on ${pairs}. Tell the user which sector was searched.`,
22355
+ sectors_used: rewritten
22356
+ };
22357
+ }
22358
+ function sectorChoice(fix) {
22359
+ const names = fix.unresolved.map((u) => `"${u.asked}"`).join(", ");
22360
+ return {
22361
+ mode: "needs_sector_choice",
22362
+ submitted: false,
22363
+ unresolved_sectors: fix.unresolved.map((u) => ({
22364
+ asked: u.asked,
22365
+ closest: u.closest.map((c) => c.name)
22366
+ })),
22367
+ sector_sections: fix.sections,
22368
+ resolved_sectors: fix.rewritten,
22369
+ hint: `No sector in this workspace's taxonomy is named ${names || "that"}. Nothing was submitted and nothing was spent. Re-call with a label copied character for character from \`closest\` or \`sector_sections\` (a section covers everything under it), or drop \`filters.sectors\` and put the wording in \`query\` / \`example_lead.description\` instead \u2014 the search then RANKS on it rather than fencing on it, which is usually what the user meant. \`leadbay_list_sectors\` returns the full taxonomy if none of these fit.`
22370
+ };
22371
+ }
22134
22372
  var DEFAULT_WAIT_SECONDS, findNewLeads;
22135
22373
  var init_find_new_leads = __esm({
22136
22374
  "../core/dist/composite/find-new-leads.js"() {
22137
22375
  "use strict";
22138
22376
  init_mcp_job_helpers();
22139
22377
  init_country_guard();
22378
+ init_sector_resolver();
22140
22379
  init_tool_descriptions_generated();
22141
22380
  DEFAULT_WAIT_SECONDS = 45;
22142
22381
  findNewLeads = {
@@ -22177,7 +22416,7 @@ var init_find_new_leads = __esm({
22177
22416
  },
22178
22417
  filters: {
22179
22418
  type: "object",
22180
- description: "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit; an unresolvable value is a 400 naming it.",
22419
+ description: "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit. `sectors` must be a label from Leadbay's own taxonomy (leadbay_list_sectors) \u2014 everyday wording like 'Professional Services' is not one; a spelling/plural slip is corrected for you, but a word with no label comes back as mode:'needs_sector_choice' with the labels to pick from. An unresolvable location is a 400 naming it.",
22181
22420
  properties: {
22182
22421
  sectors: { type: "array", items: { type: "string" } },
22183
22422
  locations: { type: "array", items: { type: "string" } },
@@ -22349,19 +22588,27 @@ var init_find_new_leads = __esm({
22349
22588
  dry_run: dryRun
22350
22589
  });
22351
22590
  if (dryRun === true) {
22352
- const forecast = await client.request("POST", "/mcp/search", body);
22591
+ const attempt2 = await submitSearch(client, body, void 0, ctx);
22592
+ if (!attempt2.ok)
22593
+ return { ...attempt2.choice, ...countryNote, region: client.region };
22353
22594
  return {
22354
22595
  dry_run: true,
22355
- ...forecast,
22596
+ ...attempt2.data,
22597
+ ...sectorNote(attempt2.rewritten),
22356
22598
  ...countryNote,
22357
22599
  region: client.region
22358
22600
  };
22359
22601
  }
22360
22602
  if (isPaid && !consented) {
22361
- const forecast = vetoed ? null : await client.request("POST", "/mcp/search", {
22362
- ...body,
22363
- dry_run: true
22364
- });
22603
+ let forecast = null;
22604
+ let quoteRewritten = [];
22605
+ if (!vetoed) {
22606
+ const attempt2 = await submitSearch(client, { ...body, dry_run: true }, void 0, ctx);
22607
+ if (!attempt2.ok)
22608
+ return { ...attempt2.choice, ...countryNote, region: client.region };
22609
+ forecast = attempt2.data;
22610
+ quoteRewritten = attempt2.rewritten;
22611
+ }
22365
22612
  return {
22366
22613
  mode: "needs_confirmation",
22367
22614
  submitted: false,
@@ -22374,11 +22621,15 @@ var init_find_new_leads = __esm({
22374
22621
  estimated_cost: forecast?.estimated_cost ?? null,
22375
22622
  items_requested: forecast?.items_requested ?? null,
22376
22623
  hint: vetoed ? "confirm:false vetoed the run \u2014 nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." : "Tell the user what will run and that it uses their plan's quota (no amounts, no money), get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.",
22624
+ ...sectorNote(quoteRewritten),
22377
22625
  ...countryNote,
22378
22626
  region: client.region
22379
22627
  };
22380
22628
  }
22381
- const submit = await client.request("POST", "/mcp/search", body, { preSendSignal: ctx?.signal });
22629
+ const attempt = await submitSearch(client, body, { preSendSignal: ctx?.signal }, ctx);
22630
+ if (!attempt.ok)
22631
+ return { ...attempt.choice, ...countryNote, region: client.region };
22632
+ const submit = attempt.data;
22382
22633
  const mocked = mockedSubmitPreview(submit, "leadbay_find_new_leads", client.region);
22383
22634
  if (mocked)
22384
22635
  return mocked;
@@ -22429,6 +22680,7 @@ var init_find_new_leads = __esm({
22429
22680
  since: snapshot.next_since ?? null,
22430
22681
  suggested_wait_seconds: done ? 0 : 60
22431
22682
  },
22683
+ ...sectorNote(attempt.rewritten),
22432
22684
  ...countryNote,
22433
22685
  region: client.region
22434
22686
  };
@@ -23977,8 +24229,8 @@ var init_report_outreach = __esm({
23977
24229
  if (!params.dry_run && params.verification.source === "user_confirmed" && typeof ctx?.elicit === "function") {
23978
24230
  try {
23979
24231
  const targetIds = params.lead_ids ?? [params.lead_id];
23980
- const leadCount = targetIds.length;
23981
- const elicitMsg = leadCount === 1 ? `An AI agent wants to log outreach on lead ${targetIds[0]}: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.` : `An AI agent wants to log outreach on ${leadCount} leads: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.`;
24232
+ const leadCount2 = targetIds.length;
24233
+ const elicitMsg = leadCount2 === 1 ? `An AI agent wants to log outreach on lead ${targetIds[0]}: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.` : `An AI agent wants to log outreach on ${leadCount2} leads: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.`;
23982
24234
  const result = await ctx.elicit({
23983
24235
  message: elicitMsg,
23984
24236
  requestedSchema: {
@@ -31336,7 +31588,7 @@ var OAUTH_BASE_URLS = {
31336
31588
  fr: "https://staging.api.leadbay.app"
31337
31589
  }
31338
31590
  };
31339
- var VERSION = "0.39.1";
31591
+ var VERSION = "0.39.3";
31340
31592
  var HELP = `
31341
31593
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
31342
31594
 
@@ -9679,7 +9679,7 @@ prose paragraph. Full recipe below.
9679
9679
 
9680
9680
  Build a single-call mixed-mode itinerary for a field sales tour. Combines \`leadbay_pull_followups\` (Monitor leads in the city \u2014 known accounts) with \`leadbay_pull_leads\` (Discover wishlist \u2014 new prospects, then client-side filtered by city) so the agent can answer the canonical #3630 US1 ask: *"I'm visiting Limoges in 4 days \u2014 propose 3 customers + 3 qualified prospects + 3 new high-potential discoveries."*
9681
9681
 
9682
- **Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any level from state down to neighborhood \u2014 state, *r\xE9gion*, county, city \u2014 the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; pick an id and re-call with \`city_id\`.
9682
+ **Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any level from state down to neighborhood \u2014 state, *r\xE9gion*, county, city \u2014 the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; re-call with \`city_id\` set to the id you pick AND \`city\` set to that candidate's \`name\`. Both are needed: the id scopes the follow-ups, the name scopes the Discover leads, and with \`city_id\` alone \`discover_leads\` comes back empty.
9683
9683
 
9684
9684
  **One workspace = one country \u2014 a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country.
9685
9685
 
@@ -9709,7 +9709,7 @@ not an itinerary. So for ANY country-level \`city\` \u2014 this workspace's own
9709
9709
  visiting and re-call with that. \`status: "country_level_location"\` carries the
9710
9710
  same instruction in its \`hint\`.
9711
9711
 
9712
- **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 filters by \`location.city/state/country/full\` substring match against the requested city. The \`discover_filter_note\` string in the response tells the agent the match ratio so it can be honest about coverage ("matched 3/30 by city/state" vs. "matched 12/30").
9712
+ **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.
9713
9713
 
9714
9714
  **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.
9715
9715
 
@@ -16691,8 +16691,8 @@ function summarise(responses) {
16691
16691
  return { answered, total, avg_qualification_boost: avg, best_response_excerpt: excerpt };
16692
16692
  }
16693
16693
  function buildPullLeadsNextSteps(args) {
16694
- const { leadCount, hasMore, nextPage, computingWishlist, computingScores } = args;
16695
- if (leadCount <= 0) {
16694
+ const { leadCount: leadCount2, hasMore, nextPage, computingWishlist, computingScores } = args;
16695
+ if (leadCount2 <= 0) {
16696
16696
  if (computingWishlist || computingScores) {
16697
16697
  return {
16698
16698
  question: "This lens is still warming up. What next?",
@@ -16937,15 +16937,15 @@ var pullLeads = {
16937
16937
  const currentPage = res.pagination?.page ?? page;
16938
16938
  const hasMore = currentPage < totalPages - 1;
16939
16939
  const nextPage = hasMore ? currentPage + 1 : null;
16940
- const leadCount = res.items.length;
16940
+ const leadCount2 = res.items.length;
16941
16941
  const nextSteps = buildPullLeadsNextSteps({
16942
- leadCount,
16942
+ leadCount: leadCount2,
16943
16943
  hasMore,
16944
16944
  nextPage,
16945
16945
  computingWishlist: res.computing_wishlist,
16946
16946
  computingScores: res.computing_scores
16947
16947
  });
16948
- const lensIsEmpty = leadCount === 0 && (res.pagination?.total ?? 0) === 0;
16948
+ const lensIsEmpty = leadCount2 === 0 && (res.pagination?.total ?? 0) === 0;
16949
16949
  const emptyReason = lensIsEmpty ? await diagnoseEmptyLens(client, lensId, {
16950
16950
  wishlist: res.computing_wishlist,
16951
16951
  scores: res.computing_scores
@@ -17373,13 +17373,34 @@ var followupsMap = {
17373
17373
  var DEFAULT_FOLLOWUPS_COUNT = 6;
17374
17374
  var DEFAULT_DISCOVER_COUNT = 6;
17375
17375
  var DISCOVER_OVER_PULL = 30;
17376
- function cityMatches(lead, cityHint) {
17377
- if (!cityHint)
17378
- return true;
17379
- const hint = cityHint.toLowerCase();
17380
- const loc = lead?.location ?? {};
17381
- const haystacks = [loc.city, loc.state, loc.country, loc.full].filter((v) => typeof v === "string").map((v) => v.toLowerCase());
17382
- return haystacks.some((h) => h.includes(hint) || hint.includes(h));
17376
+ function normalizeGeo(value) {
17377
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
17378
+ }
17379
+ var ADMIN_PREFIX = /^(?:city|town|village|borough|township|municipality) of /;
17380
+ function townName(value) {
17381
+ return normalizeGeo(value).replace(ADMIN_PREFIX, "");
17382
+ }
17383
+ function cityHintCore(cityHint) {
17384
+ const head = cityHint.split(",")[0] ?? "";
17385
+ const expanded = expandAlias(head);
17386
+ return { name: townName(expanded), isCity: expanded !== head };
17387
+ }
17388
+ function stateFieldOf(lead) {
17389
+ const state = lead?.location?.state;
17390
+ return typeof state === "string" ? normalizeGeo(state) : "";
17391
+ }
17392
+ function filterDiscoverByCity(leads, cityHint) {
17393
+ const hint = cityHint ? cityHintCore(cityHint) : { name: "", isCity: false };
17394
+ if (!hint.name)
17395
+ return { leads, matchedOn: null };
17396
+ const cityOf = (l) => typeof l?.location?.city === "string" ? townName(l.location.city) : "";
17397
+ const byCity = leads.filter((l) => cityOf(l) === hint.name);
17398
+ if (byCity.length > 0)
17399
+ return { leads: byCity, matchedOn: "city" };
17400
+ if (hint.isCity)
17401
+ return { leads: [], matchedOn: "city" };
17402
+ const byState = leads.filter((l) => stateFieldOf(l) === hint.name);
17403
+ return { leads: byState, matchedOn: "state" };
17383
17404
  }
17384
17405
  function toMapLocation(lead, mode) {
17385
17406
  const pos = lead?.location?.pos;
@@ -17448,7 +17469,7 @@ var tourPlan = {
17448
17469
  },
17449
17470
  city_id: {
17450
17471
  type: "string",
17451
- description: "Pre-resolved admin_area id (numeric string). Bypasses the resolver."
17472
+ description: "Pre-resolved admin_area id (numeric string). Bypasses the resolver. Pass `city` alongside it with the NAME of the area you picked: the id scopes the Monitor half, and the name is the only thing that can scope the Discover half, which has no server-side geo filter. With `city_id` alone, `discover_leads` comes back empty."
17452
17473
  },
17453
17474
  followups_count: {
17454
17475
  type: "number",
@@ -17555,7 +17576,13 @@ var tourPlan = {
17555
17576
  const discoverCount = params.discover_count ?? DEFAULT_DISCOVER_COUNT;
17556
17577
  const [followupsResult, leadsResult] = await Promise.allSettled([
17557
17578
  pullFollowups.execute(client, {
17558
- city: params.city,
17579
+ // A pre-resolved id bypasses the resolver, which is what its own
17580
+ // schema promises. Forwarding the free text alongside it sends the
17581
+ // name back through /geo/search, and the name is the thing that was
17582
+ // ambiguous — so an agent recovering from `ambiguous_locations` by
17583
+ // picking an id would be handed the same ambiguity again. Here the
17584
+ // free text stays behind and scopes the Discover half instead.
17585
+ city: params.city_id ? void 0 : params.city,
17559
17586
  city_id: params.city_id,
17560
17587
  count: followupsCount
17561
17588
  }, ctx),
@@ -17569,7 +17596,7 @@ var tourPlan = {
17569
17596
  location_ambiguities: r.location_ambiguities,
17570
17597
  monitor_leads: [],
17571
17598
  discover_leads: [],
17572
- discover_filter_note: "City was ambiguous; pick an id and re-call to proceed.",
17599
+ discover_filter_note: "City was ambiguous; re-call with `city_id` set to the id you pick AND `city` set to that candidate's `name`. The id scopes the follow-ups; the name is what scopes the Discover leads.",
17573
17600
  map_locations: [],
17574
17601
  map_summary: {
17575
17602
  total_leads: 0,
@@ -17593,13 +17620,27 @@ var tourPlan = {
17593
17620
  if (leadsResult.status === "rejected") {
17594
17621
  ctx?.logger?.warn?.(`tour_plan: pull_leads failed: ${leadsResult.reason?.message ?? leadsResult.reason}`);
17595
17622
  }
17596
- const filtered = rawDiscover.filter((l) => cityMatches(l, params.city));
17623
+ const cityName = params.city && !/^\d+$/.test(params.city.trim()) ? params.city : void 0;
17624
+ const knownId = params.city_id ?? (cityName ? void 0 : params.city);
17625
+ const idOnly = Boolean(knownId) && !cityName;
17626
+ const { leads: filtered, matchedOn } = idOnly ? { leads: [], matchedOn: null } : filterDiscoverByCity(rawDiscover, cityName);
17597
17627
  const discoverLeads2 = filtered.slice(0, discoverCount);
17598
17628
  const pulledLensId = leadsResult.status === "fulfilled" ? leadsResult.value?.lens?.id : null;
17599
17629
  if (pulledLensId != null) {
17600
17630
  reportLeadInteractions(client, pulledLensId, discoverLeads2.map((l) => l.id), ["LEAD_SEEN"], ctx?.logger);
17601
17631
  }
17602
- const filterNote = params.city ? `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}'; returning top ${discoverLeads2.length}.` : `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
17632
+ let filterNote;
17633
+ if (idOnly) {
17634
+ 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.`;
17635
+ } else if (!cityName) {
17636
+ filterNote = `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
17637
+ } else if (matchedOn === null) {
17638
+ filterNote = `No usable city filter in '${params.city}'; returning top ${discoverLeads2.length} Discover leads.`;
17639
+ } else if (filtered.length === 0) {
17640
+ 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}'.`;
17641
+ } else {
17642
+ filterNote = `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}' by ${matchedOn === "city" ? "city" : "state/region"}; returning top ${discoverLeads2.length}.`;
17643
+ }
17603
17644
  return {
17604
17645
  city: params.city ?? null,
17605
17646
  city_id: params.city_id ?? null,
@@ -22957,27 +22998,161 @@ var bulkEnrichStatus = {
22957
22998
  }
22958
22999
  };
22959
23000
 
22960
- // ../core/dist/composite/adjust-audience.js
22961
- function tokens(s) {
22962
- if (!s)
22963
- return [];
22964
- return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
23001
+ // ../core/dist/composite/_sector-resolver.js
23002
+ var FILLER = /* @__PURE__ */ new Set([
23003
+ // en
23004
+ "and",
23005
+ "of",
23006
+ "the",
23007
+ "for",
23008
+ "or",
23009
+ "activity",
23010
+ // fr
23011
+ "et",
23012
+ "de",
23013
+ "des",
23014
+ "du",
23015
+ "d",
23016
+ "la",
23017
+ "le",
23018
+ "les",
23019
+ "l",
23020
+ "en",
23021
+ "aux",
23022
+ "au",
23023
+ "activite",
23024
+ "pour",
23025
+ "sur",
23026
+ "par",
23027
+ "a"
23028
+ ]);
23029
+ function normalizeSectorText(value) {
23030
+ return (value ?? "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^0-9a-z]+/g, " ").trim();
22965
23031
  }
22966
- function bestMatches(text3, taxonomy) {
22967
- const want = new Set(tokens(text3));
22968
- if (want.size === 0)
22969
- return [];
22970
- const ranked = taxonomy.map((s) => {
22971
- const have = new Set(tokens(s.name));
23032
+ function singular(token) {
23033
+ if (token.length > 4 && token.endsWith("ies"))
23034
+ return `${token.slice(0, -3)}y`;
23035
+ return token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token;
23036
+ }
23037
+ function sectorTokens(value) {
23038
+ const all = normalizeSectorText(value).split(" ").filter(Boolean).map(singular);
23039
+ const core = all.filter((t) => !FILLER.has(t));
23040
+ return core.length > 0 ? core : all;
23041
+ }
23042
+ function sectorLabel(row) {
23043
+ if (!row)
23044
+ return "";
23045
+ const label = row.label;
23046
+ if (typeof label === "string" && label.length > 0)
23047
+ return label;
23048
+ return typeof row.name === "string" ? row.name : "";
23049
+ }
23050
+ function leadCount(row) {
23051
+ const n = row.number_of_leads;
23052
+ return typeof n === "number" && Number.isFinite(n) ? n : 0;
23053
+ }
23054
+ var MAX_CANDIDATES = 8;
23055
+ var MAX_PARTIAL_CANDIDATES = 4;
23056
+ function rankCandidates(scored, tieBreak) {
23057
+ const out = [];
23058
+ const seen = /* @__PURE__ */ new Set();
23059
+ scored.sort((a, b) => b.score - a.score || (tieBreak === "breadth" ? leadCount(b.row) - leadCount(a.row) || b.closeness - a.closeness : b.closeness - a.closeness || leadCount(b.row) - leadCount(a.row)) || sectorLabel(a.row).length - sectorLabel(b.row).length).forEach(({ row, score }) => {
23060
+ const label = sectorLabel(row);
23061
+ if (!label || seen.has(label) || out.length >= MAX_CANDIDATES)
23062
+ return;
23063
+ seen.add(label);
23064
+ out.push({ id: row.id, name: label, score: Number(score.toFixed(2)) });
23065
+ });
23066
+ return out;
23067
+ }
23068
+ function matchSector(text3, taxonomy) {
23069
+ const wanted = normalizeSectorText(text3);
23070
+ if (!wanted)
23071
+ return { kind: "unresolved", candidates: [] };
23072
+ const exact = taxonomy.filter((row) => normalizeSectorText(sectorLabel(row)) === wanted);
23073
+ if (exact.length > 0) {
23074
+ const label = sectorLabel(exact.reduce((best, row) => leadCount(row) > leadCount(best) ? row : best));
23075
+ const ids = taxonomy.filter((row) => sectorLabel(row) === label).map((row) => row.id);
23076
+ return { kind: "resolved", label, ids, exact: true };
23077
+ }
23078
+ const want = new Set(sectorTokens(text3));
23079
+ const sameWords = taxonomy.filter((row) => {
23080
+ const have = new Set(sectorTokens(sectorLabel(row)));
23081
+ return have.size === want.size && [...want].every((token) => have.has(token));
23082
+ });
23083
+ const sameWordLabels = new Set(sameWords.map((row) => sectorLabel(row)).filter(Boolean));
23084
+ if (sameWordLabels.size === 1) {
23085
+ const label = [...sameWordLabels][0];
23086
+ const ids = taxonomy.filter((row) => sectorLabel(row) === label).map((row) => row.id);
23087
+ return { kind: "resolved", label, ids, exact: false };
23088
+ }
23089
+ const scored = [];
23090
+ for (const row of taxonomy) {
23091
+ const have = new Set(sectorTokens(sectorLabel(row)));
23092
+ if (have.size === 0)
23093
+ continue;
22972
23094
  let overlap = 0;
22973
- for (const t of want)
22974
- if (have.has(t))
23095
+ for (const token of want)
23096
+ if (have.has(token))
22975
23097
  overlap += 1;
22976
- const score = overlap / Math.max(want.size, 1);
22977
- return { id: s.id, name: s.name ?? "", score };
22978
- }).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
22979
- return ranked.slice(0, 5);
23098
+ if (overlap === 0)
23099
+ continue;
23100
+ scored.push({ row, score: overlap / want.size, closeness: overlap / have.size });
23101
+ }
23102
+ const full = scored.filter((s) => s.score >= 1);
23103
+ return {
23104
+ kind: "unresolved",
23105
+ candidates: full.length > 0 ? rankCandidates(full, "breadth") : rankCandidates(scored, "closeness").slice(0, MAX_PARTIAL_CANDIDATES)
23106
+ };
23107
+ }
23108
+ function sectorSections(taxonomy) {
23109
+ const roots = taxonomy.filter((row) => !row.parent);
23110
+ const labels = [];
23111
+ for (const row of roots.sort((a, b) => leadCount(b) - leadCount(a))) {
23112
+ const label = sectorLabel(row);
23113
+ if (label && !labels.includes(label))
23114
+ labels.push(label);
23115
+ }
23116
+ return labels;
23117
+ }
23118
+ async function fetchSectorTaxonomy(client, ctx) {
23119
+ const me = await client.resolveMe().catch(() => null);
23120
+ const lang = me?.language ?? "en";
23121
+ const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
23122
+ const unlabelled = taxonomy.filter((row) => !sectorLabel(row)).length;
23123
+ if (unlabelled > 0) {
23124
+ ctx?.logger?.warn?.(`sector taxonomy: ${unlabelled}/${taxonomy.length} row(s) carry no label`);
23125
+ }
23126
+ return taxonomy;
23127
+ }
23128
+ var NUMERIC_ID = /^\d+$/;
23129
+ function resolveSectorValues(values, taxonomy) {
23130
+ const out = {
23131
+ values: [],
23132
+ rewritten: [],
23133
+ unresolved: [],
23134
+ sections: sectorSections(taxonomy)
23135
+ };
23136
+ for (const raw of values) {
23137
+ const asked = typeof raw === "string" ? raw : String(raw ?? "");
23138
+ if (NUMERIC_ID.test(asked.trim())) {
23139
+ out.values.push(asked.trim());
23140
+ continue;
23141
+ }
23142
+ const match = matchSector(asked, taxonomy);
23143
+ if (match.kind === "resolved") {
23144
+ out.values.push(match.label);
23145
+ if (match.label !== asked)
23146
+ out.rewritten.push({ asked, used: match.label });
23147
+ continue;
23148
+ }
23149
+ out.values.push(asked);
23150
+ out.unresolved.push({ asked, closest: match.candidates });
23151
+ }
23152
+ return out;
22980
23153
  }
23154
+
23155
+ // ../core/dist/composite/adjust-audience.js
22981
23156
  async function resolveSectors(client, texts, ctx) {
22982
23157
  const looksLikeId2 = (s) => /^\d+$/.test(s);
22983
23158
  const direct = texts.filter(looksLikeId2);
@@ -22987,18 +23162,18 @@ async function resolveSectors(client, texts, ctx) {
22987
23162
  const me = await client.resolveMe().catch(() => null);
22988
23163
  const lang = me?.language ?? "en";
22989
23164
  const taxonomy = await client.request("GET", `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=false`);
22990
- const nullNames = taxonomy.filter((s) => !s.name).length;
23165
+ const nullNames = taxonomy.filter((s) => !sectorLabel(s)).length;
22991
23166
  if (nullNames > 0) {
22992
23167
  ctx?.logger?.warn?.(`adjust_audience: /sectors/all returned ${nullNames}/${taxonomy.length} sector(s) with a null/missing name`);
22993
23168
  }
22994
23169
  const resolved = [...direct];
22995
23170
  const ambiguities = [];
22996
23171
  for (const text3 of free) {
22997
- const matches = bestMatches(text3, taxonomy);
22998
- if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
22999
- resolved.push(matches[0].id);
23172
+ const match = matchSector(text3, taxonomy);
23173
+ if (match.kind === "resolved") {
23174
+ resolved.push(...match.ids);
23000
23175
  } else {
23001
- ambiguities.push({ sector_text: text3, matches });
23176
+ ambiguities.push({ sector_text: text3, matches: match.candidates });
23002
23177
  }
23003
23178
  }
23004
23179
  return { resolved, ambiguities };
@@ -24293,6 +24468,61 @@ function sortFilterLists(filters) {
24293
24468
  }
24294
24469
  return out;
24295
24470
  }
24471
+ function isSectorRejection(err) {
24472
+ const e = err;
24473
+ return !!e && e.code === "BAD_INPUT" && typeof e.message === "string" && e.message.includes("filters.sectors");
24474
+ }
24475
+ async function submitSearch(client, body, opts, ctx) {
24476
+ try {
24477
+ return {
24478
+ ok: true,
24479
+ data: await client.request("POST", "/mcp/search", body, opts),
24480
+ rewritten: []
24481
+ };
24482
+ } catch (err) {
24483
+ const asked = body.filters?.sectors;
24484
+ if (!isSectorRejection(err) || !Array.isArray(asked) || asked.length === 0) {
24485
+ throw err;
24486
+ }
24487
+ const taxonomy = await fetchSectorTaxonomy(client, ctx).catch(() => {
24488
+ throw err;
24489
+ });
24490
+ const fix = resolveSectorValues(asked, taxonomy);
24491
+ if (fix.unresolved.length === 0 && fix.rewritten.length === 0)
24492
+ throw err;
24493
+ if (fix.unresolved.length > 0)
24494
+ return { ok: false, choice: sectorChoice(fix) };
24495
+ const retried = { ...body, filters: { ...body.filters, sectors: fix.values } };
24496
+ return {
24497
+ ok: true,
24498
+ data: await client.request("POST", "/mcp/search", retried, opts),
24499
+ rewritten: fix.rewritten
24500
+ };
24501
+ }
24502
+ }
24503
+ function sectorNote(rewritten) {
24504
+ if (rewritten.length === 0)
24505
+ return void 0;
24506
+ const pairs = rewritten.map((r) => `"${r.asked}" \u2192 "${r.used}"`).join(", ");
24507
+ return {
24508
+ sectors_note: `Leadbay's sector taxonomy spells these differently, so the search ran on ${pairs}. Tell the user which sector was searched.`,
24509
+ sectors_used: rewritten
24510
+ };
24511
+ }
24512
+ function sectorChoice(fix) {
24513
+ const names = fix.unresolved.map((u) => `"${u.asked}"`).join(", ");
24514
+ return {
24515
+ mode: "needs_sector_choice",
24516
+ submitted: false,
24517
+ unresolved_sectors: fix.unresolved.map((u) => ({
24518
+ asked: u.asked,
24519
+ closest: u.closest.map((c) => c.name)
24520
+ })),
24521
+ sector_sections: fix.sections,
24522
+ resolved_sectors: fix.rewritten,
24523
+ hint: `No sector in this workspace's taxonomy is named ${names || "that"}. Nothing was submitted and nothing was spent. Re-call with a label copied character for character from \`closest\` or \`sector_sections\` (a section covers everything under it), or drop \`filters.sectors\` and put the wording in \`query\` / \`example_lead.description\` instead \u2014 the search then RANKS on it rather than fencing on it, which is usually what the user meant. \`leadbay_list_sectors\` returns the full taxonomy if none of these fit.`
24524
+ };
24525
+ }
24296
24526
  var findNewLeads = {
24297
24527
  name: "leadbay_find_new_leads",
24298
24528
  annotations: {
@@ -24331,7 +24561,7 @@ var findNewLeads = {
24331
24561
  },
24332
24562
  filters: {
24333
24563
  type: "object",
24334
- description: "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit; an unresolvable value is a 400 naming it.",
24564
+ description: "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit. `sectors` must be a label from Leadbay's own taxonomy (leadbay_list_sectors) \u2014 everyday wording like 'Professional Services' is not one; a spelling/plural slip is corrected for you, but a word with no label comes back as mode:'needs_sector_choice' with the labels to pick from. An unresolvable location is a 400 naming it.",
24335
24565
  properties: {
24336
24566
  sectors: { type: "array", items: { type: "string" } },
24337
24567
  locations: { type: "array", items: { type: "string" } },
@@ -24503,19 +24733,27 @@ var findNewLeads = {
24503
24733
  dry_run: dryRun
24504
24734
  });
24505
24735
  if (dryRun === true) {
24506
- const forecast = await client.request("POST", "/mcp/search", body);
24736
+ const attempt2 = await submitSearch(client, body, void 0, ctx);
24737
+ if (!attempt2.ok)
24738
+ return { ...attempt2.choice, ...countryNote, region: client.region };
24507
24739
  return {
24508
24740
  dry_run: true,
24509
- ...forecast,
24741
+ ...attempt2.data,
24742
+ ...sectorNote(attempt2.rewritten),
24510
24743
  ...countryNote,
24511
24744
  region: client.region
24512
24745
  };
24513
24746
  }
24514
24747
  if (isPaid && !consented) {
24515
- const forecast = vetoed ? null : await client.request("POST", "/mcp/search", {
24516
- ...body,
24517
- dry_run: true
24518
- });
24748
+ let forecast = null;
24749
+ let quoteRewritten = [];
24750
+ if (!vetoed) {
24751
+ const attempt2 = await submitSearch(client, { ...body, dry_run: true }, void 0, ctx);
24752
+ if (!attempt2.ok)
24753
+ return { ...attempt2.choice, ...countryNote, region: client.region };
24754
+ forecast = attempt2.data;
24755
+ quoteRewritten = attempt2.rewritten;
24756
+ }
24519
24757
  return {
24520
24758
  mode: "needs_confirmation",
24521
24759
  submitted: false,
@@ -24528,11 +24766,15 @@ var findNewLeads = {
24528
24766
  estimated_cost: forecast?.estimated_cost ?? null,
24529
24767
  items_requested: forecast?.items_requested ?? null,
24530
24768
  hint: vetoed ? "confirm:false vetoed the run \u2014 nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." : "Tell the user what will run and that it uses their plan's quota (no amounts, no money), get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.",
24769
+ ...sectorNote(quoteRewritten),
24531
24770
  ...countryNote,
24532
24771
  region: client.region
24533
24772
  };
24534
24773
  }
24535
- const submit = await client.request("POST", "/mcp/search", body, { preSendSignal: ctx?.signal });
24774
+ const attempt = await submitSearch(client, body, { preSendSignal: ctx?.signal }, ctx);
24775
+ if (!attempt.ok)
24776
+ return { ...attempt.choice, ...countryNote, region: client.region };
24777
+ const submit = attempt.data;
24536
24778
  const mocked = mockedSubmitPreview(submit, "leadbay_find_new_leads", client.region);
24537
24779
  if (mocked)
24538
24780
  return mocked;
@@ -24583,6 +24825,7 @@ var findNewLeads = {
24583
24825
  since: snapshot.next_since ?? null,
24584
24826
  suggested_wait_seconds: done ? 0 : 60
24585
24827
  },
24828
+ ...sectorNote(attempt.rewritten),
24586
24829
  ...countryNote,
24587
24830
  region: client.region
24588
24831
  };
@@ -26064,8 +26307,8 @@ var reportOutreach = {
26064
26307
  if (!params.dry_run && params.verification.source === "user_confirmed" && typeof ctx?.elicit === "function") {
26065
26308
  try {
26066
26309
  const targetIds = params.lead_ids ?? [params.lead_id];
26067
- const leadCount = targetIds.length;
26068
- const elicitMsg = leadCount === 1 ? `An AI agent wants to log outreach on lead ${targetIds[0]}: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.` : `An AI agent wants to log outreach on ${leadCount} leads: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.`;
26310
+ const leadCount2 = targetIds.length;
26311
+ const elicitMsg = leadCount2 === 1 ? `An AI agent wants to log outreach on lead ${targetIds[0]}: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.` : `An AI agent wants to log outreach on ${leadCount2} leads: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.`;
26069
26312
  const result = await ctx.elicit({
26070
26313
  message: elicitMsg,
26071
26314
  requestedSchema: {
@@ -28619,7 +28862,7 @@ function parseWriteEnv(env = process.env) {
28619
28862
  }
28620
28863
 
28621
28864
  // src/http-server.ts
28622
- var VERSION = true ? "0.39.1" : "0.0.0-dev";
28865
+ var VERSION = true ? "0.39.3" : "0.0.0-dev";
28623
28866
  var PORT = Number(process.env.PORT ?? 8080);
28624
28867
  var HOST = process.env.HOST ?? "0.0.0.0";
28625
28868
  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.1" : "0.0.0-dev";
1808
+ VERSION = true ? "0.39.3" : "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.1" : "0.0.0-dev";
1071
+ var VERSION = true ? "0.39.3" : "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.1",
3
+ "version": "0.39.3",
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",